【Dart】sealed classを使った直和型の実装と網羅性チェック
はじめに
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);
飲み物によって異なるドメインの情報であるsizeやisHotを、インスタンス変数として定義しています。
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の機能の組み合わせによって、はじめて直和型の網羅性を保証するコードが書けるようになったと言えます。
Discussion
大変参考になりました。
ちなみに、
const Water(): super();の:super()は不要と思います。ありがとうございます!
修正させていただきました🙏
👏👏👏👏👏👏