メインコンテンツにスキップ

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つの設計上の質問を検討してください。

  • ユーザーに直接インスタンスを構築してほしいですか? 言い換えると、クラスは意図的にabstractではないのですか?

  • 宣言をmixinとして使用することを望みますか? 言い換えると、with句で使用できるようにしたいのですか?

両方の質問に「はい」と答える場合は、mixin classにしてください。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句で使用したり、mixinやmixin classをwith句で使用したりできます。しかし、クラスのライブラリ外からクラスやmixinをimplements句で使用することを禁止します。

これにより、あなたのクラスまたは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 classをfinalとマークできます。これにより、ライブラリ外の誰もそれにいかなる種類のサブタイプも作成できなくなります。implementsextendswith、またはon句での使用はすべて禁止されます。

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

sealed修飾子

#

最後の修飾子であるsealedは特別です。主に、パターンマッチングにおける網羅性チェックを可能にするために存在します。switch文に、sealedとマークされた型のすべての直接サブタイプのケースがある場合、コンパイラはその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型は、すべて同じライブラリで宣言されたその直接サブタイプでなければなりません。これにより、コンパイラはそれらすべてを見つけることができます。ケースのいずれにも一致しない、他の隠されたサブタイプが存在しないことを知っています。

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の比較

#

ユーザーが直接サブタイプ化できないクラスがある場合、いつsealedを使用し、いつfinalを使用すべきでしょうか?いくつかの簡単なルールがあります。

  • クラスのインスタンスをユーザーが直接構築できるようにしたい場合、sealedは使用できません。sealed型は暗黙的にabstractであるためです。

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

それ以外の場合、クラスに定義したサブタイプがある場合は、sealedが適切でしょう。ユーザーがクラスにいくつかのサブタイプがあることを確認した場合、switchケースとしてそれぞれを個別に処理でき、コンパイラが型全体がカバーされていることを知っていると便利です。

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

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

しかし、新しいサブタイプを追加するたびに破壊的変更となることを意味します。新しいサブタイプを破壊的ではない方法で追加する自由を得たい場合は、スーパークラスをsealedではなくfinalでマークする方が良いでしょう。これにより、ユーザーがそのスーパークラスの値でswitchを行う場合、すべてのサブタイプのケースがあっても、コンパイラは別のデフォルトケースを追加するように強制します。その後、後でより多くのサブタイプを追加した場合に実行されるのがそのデフォルトケースになります。

まとめ

#

APIデザイナーとして、これらの新しい修飾子は、ユーザーがコードをどのように操作するか、そして逆にあなたがコードを壊さずにどのように進化させることができるかを制御できるようにします。

しかし、これらのオプションは複雑さを伴います。APIデザイナーとしての選択肢が増えます。また、これらの機能は新しいため、最適なプラクティスがどうなるかはまだわかりません。各言語のエコシステムは異なり、異なるニーズがあります。

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

時間とともに、より細かい制御が必要な場所の感覚がつかめてきたら、他の修飾子のいくつかを適用することを検討できます。

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

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

  • finalを使用して、クラスが拡張されることを完全に防ぎます。

  • sealedを使用して、サブタイプのファミリに対する網羅性チェックをオプトインします。

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