Skip to content

APIメンテナのためのクラス修飾子

Dart 3.0では、クラス宣言やミキシン宣言に置ける新しい修飾子がいくつか追加されました。もしあなたがライブラリパッケージの作者であれば、これらの修飾子を使用することで、あなたのパッケージがエクスポートする型に対してユーザがどのような操作を行うことができるかを、より詳細に制御することができます。これにより、パッケージの進化が容易になり、コードの変更がユーザーを壊してしまう可能性があるかどうかがわかりやすくなります。

また、Dart 3.0では、クラスをmixinとして使用することに関する変更も含まれています。この変更によって、あなたのクラスが壊れることはないかもしれませんが、あなたのクラスのユーザーが壊れる可能性があります。

このガイドでは、新しい修飾子の使用方法と、それがライブラリのユーザーに与える影響について説明します。

クラスの mixin 修飾子

最も重要な修飾子は mixin です。Dart 3.0より前のバージョンの言語では、クラスのwith節で任意のクラスをmixinとして使用することができます:

  • ファクトリー以外のコンストラクタを宣言している。
  • Object 以外のクラスを継承している。

このため、コンストラクタやextends句をクラスに追加しても、他のクラスがそのクラスをwith句で使用していることに気づかず、誤って他の人のコードを壊してしまうことがあります。

Dart 3.0では、デフォルトでクラスをmixinとして使用することができなくなりました。その代わりに、mixinクラスを宣言することで、その動作を明示的に許可する必要があります:

dart
mixin class Both {}

class UseAsMixin with Both {}
class UseAsSuperclass extends Both {}

パッケージを Dart 3.0 に更新し、コードを変更しなければ、エラーは発生しないかもしれません。しかし、あなたのパッケージのユーザがあなたのクラスをmixinとして使用していた場合、うっかりして壊してしまうかもしれません。

クラスをミックスインとして移行する

クラスが非ファクトリーコンストラクタ、extends 節、または with 節を持っている場合、そのクラスはすでにミキシンとして使用できません。Dart 3.0でも動作は変わりませんので、心配する必要はありません。

実際には、これは既存のクラスの約90%に当てはまります。ミキシンとして使用できる残りのクラスについては、何をサポートしたいかを決定する必要があります。

ここで、決断の助けとなる質問をいくつか挙げてみよう。1つ目は実用的なことです:

  • ユーザーを壊したくありませんか?もしその答えが「ノー」であるなら、mixinとして使われる可能性のあるすべてのクラスの前にmixinを置く。こうすることで、APIの既存の動作が維持されます。

一方、この機会にAPIが提供するアフォーダンスを再考したいのであれば、mixinクラスにはしない方がよいかもしれません。この2つの設計上の疑問について考えてみよう:

  • ユーザーが直接インスタンスを作成できるようにしたいのか?言い換えれば、そのクラスは意図的に抽象化されていないのか?

  • この宣言をミキシンとして使えるようにしたいのか?言い換えれば、with節で使えるようにしたいのか?

両方の答えが「イエス」なら、ミキシン・クラスにする。2つ目の答えが「いいえ」なら、クラスのままにしておきます。つ目の答えが「いいえ」で、2つ目の答えが「はい」の場合は、クラスからミキシン宣言に変更します。

最後の2つの選択肢、クラスのままにするか、純粋なmixinにするかは、APIを変更することになります。これを行う場合は、パッケージのメジャーバージョンを上げる必要があります。

その他のオプトイン修飾語

クラスを mixin として扱うことは、Dart 3.0 におけるパッケージの API に影響する唯一の重要な変更点です。ここまでくれば、パッケージがユーザにできることに他の変更を加えたくなければ、もうやめてもかまいません。

もし、このまま続けて以下に説明する修飾子を使用した場合、パッケージの API を破壊する変更になる可能性があり、メジャーバージョンアップが必要になることに注意してください。

interface修飾子

Dartには、純粋なインターフェースを宣言するための個別の構文はありません。その代わりに、抽象メソッドのみを含む抽象クラスを宣言します。ユーザがパッケージのAPIでそのクラスを見たとき、クラスを拡張することで再利用できるコードが含まれているのか、それともインターフェイスとして使用されることを意図しているのか、わからないかもしれません。

interface修飾子をクラスにつけることで、それを明確にすることができます。そうすることで、クラスをimplements節で使うことはできますが、 extends節で使うことはできなくなります。

クラスが抽象でないメソッドを持っている場合でも、ユーザーがそのクラスを拡張できないようにしたい場合があります。継承は、コードの再利用を可能にするため、ソフトウェアにおける最も強力な結合の1つです。しかし、このカップリングは危険で壊れやすいものでもあります。継承がパッケージの境界を越える場合、サブクラスを壊すことなくスーパークラスを進化させるのは難しいでしょう。

クラスのインターフェイスをマークすることで、ユーザはそのクラスを構築し(抽象クラスとマークされていない限り)、クラスのインターフェイスを実装することができます。

クラスがインターフェースとマークされている場合、そのクラスが宣言されているライブラリ内ではその制限を無視することができます。ライブラリ内では、そのクラスはすべて自分のコードであり、おそらく自分が何をしているかわかっているはずなので、自由に拡張することができます。この制限は、他のパッケージや、自分のパッケージ内の他のライブラリにも適用されます。

base修飾子

base修飾子はinterfaceの逆です。extends 節でクラスを使ったり、with 節で mixin や mixin クラスを使うことができます。しかし、そのクラスのライブラリ以外のコードがimplements句でそのクラスやmixinを使うことはできません。

これにより、クラスやmixinのインターフェイスのインスタンスであるすべてのオブジェクトが、実際の実装を継承することになります。特に、これはすべてのインスタンスが、あなたのクラスやミキシンが宣言しているすべてのプライベート・メンバーを含むことを意味します。これは、他の方法で発生する可能性のある実行時エラーを防ぐのに役立ちます。

このライブラリを考えてみよう:

dart
// a.dart
class A {
  void _privateMethod() {
    print('I inherited from A');
  }
}

void callPrivateMethod(A a) {
  a._privateMethod();
}

このコードだけでは問題ないように思えるが、ユーザーがこのような別のライブラリを作ることを妨げるものは何もない:

dart
// b.dart
import 'a.dart';

class B implements A {
  // No implementation of _privateMethod()!
}

main() {
  callPrivateMethod(B()); // Runtime exception!
}

クラスにbase修飾子を追加することで、このような実行時エラーを防ぐことができます。インターフェースの場合と同様、基底クラスやミキシンが宣言されているのと同じライブラリでは、この制限を無視することができます。その場合、同じライブラリのサブクラスはプライベート・メソッドを実装するように注意喚起されます。しかし、次のセクションが適用されることに注意してください:

塩基の他動性

クラスの基本をマークする目的は、その型のすべてのインスタンスがそのクラスを具体的に継承することを保証することである。これを維持するために、基底の制限は「伝染性」である。直接型であれ間接型であれ、ベースとマークされた型のすべてのサブタイプは、実装されることも防がなければならない。つまり、baseとマークされていなければならない(あるいはfinalかsealedでなければならない。)

型にbaseを適用するには、それなりの注意が必要だ。それは、ユーザーがあなたのクラスやミキシンでできることだけでなく、サブクラスが提供できるアフォーダンスにも影響します。ある型にbaseを付けると、その下の階層全体が実装禁止になります。

強烈に聞こえるかもしれないが、これは他のほとんどのプログラミング言語が常に行ってきたことだ。そのため、JavaやC#などの言語でクラスを宣言しても、事実上同じ制約を受けることになる。

final修飾子

interface と base の両方の制限を受けたい場合は、クラスや mixin クラスを final にすることができます。これにより、あなたのライブラリの外部からは、このクラスのサブタイプを作成することができなくなります。つまり、implements句、extends句、with句、on句でこのクラスを使用することができなくなります。

これは、そのクラスのユーザーにとって最も厳しい制限です。彼らができることは、(abstractとマークされていない限り)そのクラスを構築することだけです。その代わり、クラスのメンテナとしての制約が最も少なくなります。新しいメソッドを追加したり、コンストラクタをファクトリ・コンストラクタに変更したりすることができます。

sealed修飾子

最後の修飾子sealedは特別である。これは主に、パターン・マッチにおける網羅性チェックを可能にするために存在する。もしスイッチがsealedとマークされた型の直接のサブタイプすべてに対してケースを持つ場合、コンパイラはそのスイッチが網羅的であると認識します。

dart
// amigos.dart
sealed class Amigo {}

class Lucky extends Amigo {}

class Dusty extends Amigo {}

class Ned extends Amigo {}

String lastName(Amigo amigo) => switch (amigo) {
      Lucky _ => 'Day',
      Dusty _ => 'Bottoms',
      Ned _ => 'Nederlander',
    };

このスイッチは、アミーゴのサブタイプごとにケースを持っています。コンパイラーは、アミーゴのすべてのインスタンスがこれらのサブタイプのインスタンスでなければならないことを知っているので、スイッチが安全に網羅的であり、最終的なデフォルト・ケースを必要としないことを知っています。

これが健全であるために、コンパイラーは2つの制限を課しています:

  1. シールされたクラスは、それ自身は直接コンストラクトできない。そうでなければ、どのサブタイプのインスタンスでもないAmigoのインスタンスができてしまう。つまり、すべてのシールドされたクラスも暗黙のうちに抽象化されています。
  2. シールされた型の直接のサブタイプはすべて、シールされた型が宣言されたのと同じライブラリになければなりません。こうすることで、コンパイラーはそれらをすべて見つけることができる。コンパイラーは、どのケースにもマッチしないような隠れたサブタイプが他に存在しないことを知ることができる。

2つ目の制限はfinalと似ている。finalと同様、sealedとマークされたクラスは、それが宣言されたライブラリの外で直接拡張したり、実装したり、混在させたりすることはできないということです。しかし、baseやfinalとは異なり、他律的な制限はありません:

dart
// amigo.dart
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
dart
// other.dart
// This is an error:
class Bad extends Amigo {}

// But these are both fine:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}

もちろん、sealed型のサブタイプにも制限をかけたい場合は、interface、base、final、sealedのいずれかを使ってマークすればよい。

sealed vs final

ユーザーに直接サブタイプされたくないクラスがある場合、どのような場合にsealedとfinalを使い分けるべきか?簡単なルールがいくつかある:

  • もしユーザーがクラスのインスタンスを直接構築できるようにしたいのであれば、sealed型は暗黙の抽象型であるため使用できません。
  • もしそのクラスがライブラリにサブタイプを持っていないのであれば、sealedを使う意味はありません。

そうでない場合、もしクラスがいくつかのサブタイプを定義しているのであれば、sealedが望ましいでしょう。クラスがいくつかのサブタイプを持っていることをユーザーが知っている場合、それぞれのサブタイプをスイッチ・ケースとして個別に扱うことができ、コンパイラーに型全体がカバーされていることを認識させることができるのは便利だ。

sealedを使うということは、後でライブラリに別のサブタイプを追加した場合、APIが変更されるということを意味する。新しいサブタイプが登場すると、既存のスイッチは新しい型を扱えないため、すべて非網羅的なものになる。まさにenumに新しい値を追加するようなものだ。

これらの非網羅的なスイッチのコンパイル・エラーは、新しい型を処理する必要があるコードの場所にユーザーの注意を向けさせるので、ユーザーにとって有益です。

しかし、これは新しいサブタイプを追加するたびに、それが変更されることを意味する。もし壊れない方法で新しいサブタイプを自由に追加したいのであれば、sealedの代わりにfinalを使ってスーパータイプをマークするのがいいだろう。つまり、ユーザーがそのスーパータイプの値をオンにしたとき、たとえすべてのサブタイプのケースを持っていたとしても、コンパイラーは別のデフォルトのケースを追加するように強制する。そのデフォルトのケースは、後でサブタイプを追加したときに実行されるケースになる。

概要

API設計者として、これらの新しいモディファイアは、ユーザがあなたのコードをどのように扱うかをコントロールし、逆に、ユーザがあなたのコードを壊すことなく、あなたのコードをどのように進化させることができるかをコントロールすることができる。

しかし、これらのオプションは複雑さを伴います。また、これらの機能は新しいものなので、ベストプラクティスがどうなるかはまだわからない。言語ごとにエコシステムは異なり、ニーズも異なる。

幸い、一度にすべてを把握する必要はない。何もしなくても、あなたのクラスはほとんど3.0以前と同じアフォーダンスが得られるように、私たちは意図的にデフォルトを選びました。APIを以前のままにしておきたいだけなら、すでにそれをサポートしているクラスにmixinを追加すればいい。

時間が経つにつれて、より細かい制御が必要な部分がわかってきたら、他の修飾子の適用を検討すればいい:

  • ユーザーがクラスのコードを再利用できないようにするために interface を使います。
  • baseを使用すると、ユーザーにクラスのコードの再利用を要求し、クラスの型のすべてのインスタンスが実際のクラスまたはサブクラスのインスタンスであることを保証します。
  • クラスが拡張されるのを完全に防ぐにはfinalを使います。
  • sealedを使用すると、サブタイプのファミリーの網羅性チェックを行うことができます。

これらの修飾子はすべて、変更を壊すような制限を意味するからです。