🎲

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

に公開
3

はじめに

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

switch文を使ったenumのケースの網羅性チェック
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クラスを使った直和型の実装と、その網羅性のチェックについて解説します。

解説

各ケースが値を持たない直和型

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(ケースを網羅していないとコンパイルエラーになる)
  // 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と同様、switch文にdefaultのケースを定義している場合はコンパイルエラーが出ないため、注意が必要です。)

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

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

switch文を使ったsealedクラスのサブクラスの分岐は、同じくDart 3.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サイズ');

おわりに

Dart 3.0以前も、abstractクラスを使って、各ケースが値を持つ直和型を作ることができました。
しかし、サブクラスの定義はsealedクラスのように同一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

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