🔤

【Dart】sealed classを使った直和型の実装と網羅性チェック

2024/03/07に公開1

はじめに

Dartでは、enumとswitchによって列挙型の網羅性を保証したコードを書くことができますが、Dart 3.0以降、直和型の網羅性を保証するコードも書けるようになりました。

他の言語の場合、例えばSwiftではenumのAssociated Valueとswitch、Kotlinではsealed classとwhenを使って実現できます。

Dart3.0以前は、freezedパッケージの機能を使うことで実現できましたが、Dart 3.0で導入されたsealedクラスとObjectによるパターンマッチ、Destructuringの仕組みによって、Dart標準の機能で実装することができるようになりました。

sealedクラス(列挙型)

まず、sealedクラスとそのサブクラスを使った、最もシンプルな実装が以下の通りです。
Drinkがとりうる値はsealedクラスのサブクラスの数と等しいため、enumと同じく列挙型になっています。

sealed class Drink {}

class Water extends Drink {}
class Cola extends Drink {}
class Coffee extends Drink {}

void main() {
  final Drink drink = Water();

  // 結果: 水
  switch (drink) {
    case Water():
      print('水');
    case Cola():
      print('コーラ');
    case Coffee():
      print('コーヒー');
  }
}

sealedクラス(直和型)

列挙型のsealedクラスに加えて、それぞれのサブクラスがインスタンス変数を持つことで、直和型のsealedクラスとなります。

sealedクラス

sealed修飾子をつけたクラスを定義します。
sealedクラスは暗黙的にabstractクラスとして定義されるため、インスタンスを生成できません[1]

sealed class Drink {
  const Drink();
}

sealedクラスのサブクラス

sealedクラスのサブクラスを定義します。
飲み物によって異なるドメインの情報を、インスタンス変数として定義しています。
Drinkがとりうる値は、それぞれのサブクラスがとりうる値の直和となるため、直和型としてのsealedクラスを定義することができました。

class Water extends Drink {
  const Water(): super();
}

class Cola extends Drink {
  const Cola({required this.size});

  final String size;
}

class Coffee extends Drink {
  const Coffee({required this.size, required this.isHot});

  final String size;
  final bool isHot;
}

switchによる分岐と網羅性チェック

switchで、Drinkのインスタンスを分岐します。

void main() {
  const Drink drink = Coffee(size: 'M', isHot: true);

  // ✅ OK
  // 結果: コーヒー Mサイズ ホット
  switch (drink) {
    case Water():
      print('水');
    case Cola(:String size):
      print('コーラ $sizeサイズ');
    case Coffee(:String size, :bool isHot):
      print('コーヒー $sizeサイズ ${isHot ? 'ホット' : 'アイス'}');
  }

  // ⛔ NG
  // The type 'Drink' is not exhaustively matched by the switch cases since it doesn't match 'Coffee()'.
  // Try adding a default case or cases that match 'Coffee()'.
  switch (drink) {
    case Water():
      print('水');
    case Cola(:String size):
      print('コーラ $sizeサイズ');
  }
}

コンパイラのチェックが働き、sealedクラスのサブクラスを網羅していないとコンパイルエラーになっていることがわかります。

sealedクラスのサブクラスは、sealedクラスと同じlibrary内にしか定義できません。
sealedクラスを定義したlibrary内に、全てのサブクラスが含まれることが保証されています。
この仕組みによって、sealedクラスを含むlibraryのimport先でswichを使った場合に、コンパイラのチェックによって網羅性が保証されることになります[2][3]

sealedクラスのサブクラスを新たに追加した場合に、swichを使って分岐しているすべての箇所がコンパイルエラーになるため、修正漏れによるバグの発生を無くすことができます(enum同様defaultのケースを定義している場合は網羅性チェックが働きません)。

ObjectによるパターンマッチとDestructuring

switchを使ったサブクラスの分岐は、同じくDart 3.0で導入されたObjectによるパターンマッチ[4]とDestructuring[5]の仕組みによって実現されています。

概要

次の例では、Fooのインスタンスが左辺とマッチし、インスタンス変数の値が変数textにDestructuringされています。

class Foo {
  const Foo(this.text);
  final String text;
}

void main() {
  const foo = Foo('こんにちは');
  final Foo(:String text) = foo;
  // 結果: こんにちは
  print(text);
}

同様に、Drinkのインスタンスがそれぞれのサブクラスとマッチし、さらにサブクラスのインスタンス変数がそれぞれの変数にDestructuringされ、各ブロック内で使用できていることがわかります。
また、Destructuringされた変数は、各ブロックを超えては使用できません。

void main() {
  const Drink drink = Coffee(size: 'M', isHot: true);

  switch (drink) {
    case Water():
      print('水');
    case Cola(:String size):
      print('コーラ $sizeサイズ');
    case Coffee(:String size, :bool isHot):
      print('コーヒー $sizeサイズ ${isHot ? 'ホット' : 'アイス'}');
  }
}

用法

分岐で使えるObjectによるパターンマッチとDestructuringの用法を、いくつか紹介します。

void main() {
  // 異なる変数名でDestructuringできます。
  switch (drink) {
    // ...
    case Cola(size: String colaSize):
      print('コーラ $colaSizeサイズ');
  }

  // 変数を省略し、クラスのみマッチさせられます。
  switch (drink) {
    // ...
    case Cola():
      print('コーラ');
  }

  // ifでも使えます。個別の処理を行いたい場合などに向いています。
  if (drink case Cola(:String size)) {
    print('コーラ $sizeサイズ');
  }

  // whenで条件を指定できます
  if (drink case Cola(:String size) when size == 'S') {
    print('コーラ Sサイズ');
  } else if (drink case Cola(:String size) when size == 'M') {
    print('コーラ Mサイズ');
  }
}

(freezedについて)

Dart 3.0以前は、freezedパッケージの機能を使うことで、直和型の網羅性を保証するコードを書くことができました。
しかし、Dart 3.0の登場でもはや不要であり長期的にこれに依存するのは避けるべきである旨が、ドキュメントに記載されています(2024/03/03現在)[6]
機能の廃止の目処はわかりませんが、使用しているプロジェクトでは移行していった方がよいかもしれません。

おわりに

Dart 3.0以前でも、abstractクラスを使って直和型を作ることができました。
しかし、ObjectによるパターンマッチやDestructuringの仕組みがなかったため、ifを使った型判定による分岐しかできず、安全なコードを書くことができませんでした。
sealedクラスとObjectによるパターンマッチ、Destructuringの仕組みの組み合わせによってはじめて、直和型の網羅性を保証するコードが書けるようになったと言えます。

脚注
  1. https://dart.dev/language/class-modifiers#sealed ↩︎

  2. https://dart.dev/language/class-modifiers-for-apis#the-sealed-modifier ↩︎

  3. https://dart.dev/language/branches#exhaustiveness-checking ↩︎

  4. https://dart.dev/language/pattern-types#object ↩︎

  5. https://dart.dev/language/patterns#destructuring-class-instances ↩︎

  6. https://pub.dev/packages/freezed#legacy-union-types-and-sealed-classes ↩︎

株式会社Never

Discussion

Cat-sushiCat-sushi

大変参考になりました。

ちなみに、const Water(): super();:super()は不要と思います。