🎲

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

2024/03/07に公開
3

はじめに

Dartでは、enumとswitchを使って列挙型の網羅性を保証するコードを書くことができます。
以下のような例です。

enumとswitchを使った列挙型の網羅性のチェック
enum Drink { water, cola, coffee }

void main() {
  const drink = Drink.coffee;

  // ✅ OK
  // 結果: コーヒー
  switch (drink) {
    case Drink.water:
      print('水');
    case Drink.cola:
      print('コーラ');
    case Drink.coffee:
      print('コーヒー');
  }

  // ⛔ NG(ケースを網羅していないとコンパイルエラーを出してくれる)
  // The type 'Drink' is not exhaustively matched by the switch cases since it doesn't match 'Drink.cola'.
  // Try adding a default case or cases that match 'Drink.cola'.
  switch (drink) {
    case Drink.water:
      print('水');
    case Drink.coffee:
      print('コーヒー');
  }
}

そしてDart 3.0で追加されたsealedクラスなどの機能によって、列挙型だけでなく直和型の網羅性を保証するコードも書けるようになりました。

本記事では、sealed classを使う主なユースケースである直和型の実装とその網羅性のチェックについて解説します。

解説

まずは、sealedクラスを使って列挙型を実装してみます。
drinkがとりうる値の数とサブクラスの数は等しい(互いに3)ため、enumを使うのと変わりません。

sealed class Drink {}

class Water extends Drink {}
class Cola extends Drink {}
class Coffee extends Drink {}
final Drink drink = Coffee();

直和型にしてみる

上記に加えて、それぞれのサブクラスにインスタンス変数を持たせてみます。

sealed class Drink {}

class Water extends Drink {}

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

  final String size; // 💡
}

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

  final String size; // 💡
  final bool isHot; // 💡
}
final Drink drink = Coffee(size: 'M', isHot: true);

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

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

sealedクラスのサブクラスは、switchやifを使って分岐させることができます。
switchを使った例が以下です。

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

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

  // ⛔ NG(enumと同じく、ケースを網羅していないとコンパイルエラーを出してくれる)
  // The type 'Drink' is not exhaustively matched by the switch cases since it doesn't match 'Cola()'.
  // Try adding a default case or cases that match 'Cola()'.
  switch (drink) {
    case Water():
      print('水');
    case Coffee(:final size, :final isHot):
      print('コーヒー $sizeサイズ ${isHot ? 'ホット' : 'アイス'}');
  }
}

sealedクラスのサブクラスを網羅していない場合、コンパイルエラーを出してくれていることがわかります。
(enumと同様、defaultのケースを定義している場合はコンパイルエラーが出ないため注意が必要です。)

sealedクラスは暗黙的にabstractクラスとして定義されるため、インスタンスを生成できません。
またsealedクラスのサブクラスは、sealedクラスを定義したlibraryと同じlibrary内(≒同じファイル内)にしか定義できません。
つまり、sealedクラスを定義したlibrary内に全てのサブクラスが含まれることが確定します。
これらの仕組みによって、sealedクラスを定義したlibraryのimport先での網羅性が保証されます[1][2][3]

パターンマッチのバリエーション

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

パターンマッチの用法をいくつか紹介します。switchでもifでも同じパターンマッチが使えます。

if (drink case Cola(:final size)) print('コーラ $sizeサイズ');
if (drink case Cola(size: final s)) print('コーラ $sサイズ');
if (drink case final Cola cola) print('コーラ ${cola.size}サイズ');
if (drink case Cola()) print('コーラ');
if (drink case final Cola _) print('コーラ');
if (drink case Cola(:final size) when size == 'S') print('コーラ Sサイズ');

おわりに

Dart3.0以前も、abstractクラスを使って直和型を作ることができました。
しかしサブクラスの定義はlibrary内に限定されず、また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 ↩︎

株式会社Never

Discussion

Cat-sushiCat-sushi

大変参考になりました。

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

oshiooshio

ありがとうございます!
修正させていただきました🙏