目次

APIメンテナー向けのクラス修飾子

Dart 3.0では、クラスおよびmixin宣言に配置できる新しい修飾子がいくつか追加されました。ライブラリパッケージの作成者であれば、これらの修飾子を使用すると、パッケージがエクスポートする型に対してユーザーが何を実行できるかをより細かく制御できます。これにより、パッケージの進化が容易になり、コードの変更がユーザーに影響を与えるかどうかを把握しやすくなります。

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

このガイドでは、これらの変更点について説明します。これにより、新しい修飾子の使用方法と、それがライブラリのユーザーにどのような影響を与えるかを知ることができます。

クラスのmixin修飾子

#

最も重要な注意すべき修飾子はmixinです。Dart 3.0より前の言語バージョンでは、クラスが次の条件を満たさない限り、任意のクラスを別のクラスのwith句でmixinとして使用できました。

  • ファクトリーコンストラクタ以外のコンストラクタを宣言する。
  • Object以外のクラスを拡張する。

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

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

dart
mixin class Both {}

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

パッケージをDart 3.0に更新してもコードを変更しない場合、エラーは表示されない可能性があります。ただし、クラスをmixinとして使用していた場合は、パッケージのユーザーを不注意に壊してしまう可能性があります。

クラスをmixinとして移行

#

クラスにファクトリーコンストラクタ以外のコンストラクタ、extends句、またはwith句がある場合、すでにmixinとして使用することはできません。Dart 3.0でも動作は変わりません。心配する必要も、何もする必要もありません。

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

決定するのに役立ついくつかの質問を以下に示します。最初は実用的な質問です。

  • ユーザーを破壊するリスクを負いますか?答えが絶対に「いいえ」である場合は、mixinとして使用できるすべてのクラスの前にmixinを配置してください。これにより、APIの既存の動作が完全に維持されます。

一方、この機会にAPIが提供する機能を再考したい場合は、mixin classしないことをお勧めします。次の2つの設計に関する質問を考慮してください。

  • ユーザーがインスタンスを直接構築できるようにしますか?つまり、クラスは意図的に抽象的ではないですか?

  • 宣言をmixinとして使用できるようにしたいですか?つまり、with句で使用できるようにしたいですか?

両方の答えが「はい」の場合は、mixinクラスにします。2番目の答えが「いいえ」の場合は、クラスとして残します。最初の答えが「いいえ」で2番目の答えが「はい」の場合は、クラスからmixin宣言に変更します。

最後の2つのオプション、クラスとして残すか、純粋なmixinにするかは、APIの破壊的変更です。これを行う場合は、パッケージのメジャーバージョンを上げる必要があります。

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

#

クラスをmixinとして処理することは、Dart 3.0でパッケージのAPIに影響を与える唯一の重要な変更です。ここまで到達したら、パッケージでユーザーに許可する内容を変更したくない場合は、停止できます。

以下の修飾子のいずれかを引き続き使用する場合は、パッケージのAPIに対する破壊的変更となる可能性があり、メジャーバージョンのインクリメントが必要になることに注意してください。

interface修飾子

#

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

クラスにinterface修飾子を配置することで、それを明確にできます。これにより、クラスをimplements句で使用できますが、extends句で使用できなくなります。

クラスに非抽象メソッドがある場合でも、ユーザーがそれを拡張できないようにすることができます。継承はソフトウェアで最も強力な結合の一種であり、コードの再利用を可能にします。ただし、その結合は危険で脆弱でもあります。継承がパッケージの境界を越える場合、サブクラスを壊さずにスーパークラスを進化させるのが難しくなる可能性があります。

クラスをinterfaceとマークすると、ユーザーはそれを構築し(abstractともマークされていない限り)、クラスのインターフェースを実装できますが、そのコードを再利用することはできなくなります。

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

base修飾子

#

base 修飾子は、interface とある意味で反対のものです。extends 句でクラスを使用したり、with 句でmixinまたはmixinクラスを使用したりすることができます。ただし、クラスのライブラリ外のコードが implements 句でクラスまたはmixinを使用することは許可しません。

これにより、クラスまたはmixinのインターフェースのインスタンスであるすべてのオブジェクトが、実際の実装を継承することが保証されます。特に、すべてのインスタンスには、クラスまたはmixinが宣言するすべてのプライベートメンバーが含まれることになります。これは、発生する可能性のあるランタイムエラーを防ぐのに役立ちます。

このライブラリを考えてみましょう。

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

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

このコードはそれ自体では問題ないように見えますが、ユーザーが次のような別のライブラリを作成することを妨げるものはありません。

b.dart
dart
import 'a.dart';

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

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

クラスに base 修飾子を追加すると、これらのランタイムエラーを防ぐのに役立ちます。interface と同様に、base クラスまたはmixinが宣言されている同じライブラリ内では、この制限を無視できます。その後、同じライブラリ内のサブクラスは、プライベートメソッドを実装するように促されます。ただし、次のセクションは*適用される*ことに注意してください。

baseの推移性

#

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

型に base を適用するには、ある程度の注意が必要です。これは、ユーザーがクラスまたはmixinでできることだけでなく、*彼ら*のサブクラスが提供できる機能にも影響します。一度型に base を配置すると、その下の階層全体が実装されることが禁止されます。

これは強烈に聞こえるかもしれませんが、ほとんどの他のプログラミング言語が常にそうであった方法です。ほとんどの言語には暗黙的なインターフェースがまったく存在しないため、Java、C#、またはその他の言語でクラスを宣言する場合、事実上同じ制約があります。

final 修飾子

#

interfacebase の両方のすべての制限が必要な場合は、クラスまたはmixinクラスを final でマークできます。これにより、ライブラリ外の誰もが、implementsextendswith、または on 句でそれを使用することを含め、あらゆる種類のサブタイプを作成することができなくなります。

これは、クラスのユーザーにとって最も制限的なものです。彼らができることは、クラスを構築することだけです(abstract でマークされていない場合)。その見返りとして、クラスのメンテナーとしての制限が最も少なくなります。ダウンストリームのユーザーを壊すことを心配することなく、新しいメソッドを追加したり、コンストラクターをファクトリーコンストラクターに変えたりすることができます。

sealed 修飾子

#

最後の修飾子である sealed は特殊です。これは主に、パターンマッチングでの 網羅性チェック を有効にするために存在します。sealed でマークされた型のすべての直接的なサブタイプに対するケースがswitchにある場合、コンパイラーはswitchが網羅的であることを認識します。

amigos.dart
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',
    };

このswitchには、Amigo の各サブタイプに対するケースがあります。コンパイラーは、Amigo のすべてのインスタンスがそれらのサブタイプのいずれかのインスタンスである必要があることを認識しているため、switchは安全に網羅的であり、最終的なデフォルトケースを必要としないことを認識します。

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

  1. sealedクラス自体を直接構築することはできません。そうしないと、*いずれの*サブタイプのインスタンスでもない Amigo のインスタンスを持つ可能性があります。したがって、すべての sealed クラスも暗黙的に abstract です。

  2. sealed型のすべての直接的なサブタイプは、sealed型が宣言されている同じライブラリ内にある必要があります。これにより、コンパイラーはそれらをすべて見つけることができます。ケースのいずれにも一致しない隠れたサブタイプが他に存在しないことを認識しています。

2番目の制限は final に似ています。final と同様に、sealed でマークされたクラスは、宣言されているライブラリの外部で直接拡張、実装、またはmixinすることはできません。ただし、base および final とは異なり、*推移的な*制限はありません。

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

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

もちろん、sealed型のサブタイプも制限したい場合は、interfacebasefinal、または sealed を使用してマークすることで実現できます。

sealedfinal の比較

#

ユーザーに直接サブタイプ化させたくないクラスがある場合、sealedfinal のどちらを使用する必要がありますか?いくつかの簡単なルールがあります。

  • ユーザーにクラスのインスタンスを直接構築させたい場合は、sealed型は暗黙的に抽象であるため、sealed を使用*できません*。

  • クラスにライブラリ内にサブタイプがない場合、網羅性チェックのメリットが得られないため、sealed を使用する意味はありません。

それ以外の場合、クラスに定義するいくつかのサブタイプがある場合は、sealed が必要なものです。ユーザーがクラスにいくつかのサブタイプがあることがわかると、それらを個別にswitchケースとして処理し、コンパイラーに型全体がカバーされていることを認識させることができると便利です。

sealed を使用するということは、後で別のサブタイプをライブラリに追加した場合、それが破壊的なAPI変更になることを意味します。新しいサブタイプが表示されると、既存のすべてのswitchは新しい型を処理しないため、非網羅的になります。これは、enumに新しい値を追加するのとまったく同じです。

それらの非網羅的なswitchコンパイルエラーは、ユーザーがコード内の新しい型を処理する必要がある場所をユーザーに注意を引くため、*役立ちます*。

ただし、新しいサブタイプを追加するたびに、それは破壊的な変更であることを意味します。非破壊的な方法で新しいサブタイプを追加する自由が必要な場合は、sealed の代わりに final を使用してスーパークラスをマークすることをお勧めします。つまり、ユーザーがスーパークラスの値に対してswitchを使用する場合、すべてのサブタイプのケースがある場合でも、コンパイラーは別のデフォルトケースを追加するように強制します。そのデフォルトケースは、後でサブタイプを追加した場合に実行されるものです。

まとめ

#

API設計者として、これらの新しい修飾子を使用すると、ユーザーがコードを操作する方法を制御でき、逆に、ユーザーのコードを壊すことなくコードを進化させることができます。

ただし、これらのオプションには複雑さが伴います。API設計者として、より多くの選択肢があります。また、これらの機能は新しいものであるため、まだベストプラクティスがどうなるかわかりません。すべての言語のエコシステムは異なり、異なるニーズがあります。

幸いなことに、すべてを一度に理解する必要はありません。何も行わなくても、クラスはほとんどの場合、3.0以前と同じ機能を持つように、デフォルトを意図的に選択しました。APIを以前の状態に保ちたい場合は、すでにそれをサポートしているクラスに mixin を配置すれば完了です。

時間が経つにつれて、より詳細な制御が必要な場所がわかったら、他の修飾子を適用することを検討できます。

  • ユーザーがクラスのコードを再利用することを防ぎながら、インターフェースを再実装できるようにするには、interface を使用します。

  • ユーザーにクラスのコードを再利用させ、クラスの型のすべてのインスタンスが、実際のクラスまたはサブクラスのインスタンスであることを保証するには、base を使用します。

  • クラスが拡張されるのを完全に防ぐには、final を使用します。

  • サブタイプのファミリで網羅性チェックを有効にするには、sealed を使用します。

これらを実行する場合は、パッケージを公開するときにメジャーバージョンをインクリメントしてください。これらの修飾子はすべて、破壊的な変更となる制限を意味するためです。