【入門編】Dartの「Never」型とパターンマッチングを組み合わせた到達不能コードの保証 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは、Dartの深淵なる世界へようこそ!
Dartコアコミッター、そしてFlutterフレームワークのチーフアーキテクトを務める私が、今回は皆さんがDartを「掌握」するための一歩として、非常に強力ながらも初学者には少し難解に感じられがちな`Never`型とパターンマッチングの組み合わせについて、その極限の知見を魂を込めて解説していきます。

「到達不能コードの保証」――なんだか難しそうに聞こえますよね?でも大丈夫です。
ここをクリアすれば、Dartの基本はバッチリマスターできますよ。一緒に、Dartの型システムの奥深さを探っていきましょう!

—

なぜ「到達不能コードの保証」が大切なのか?

皆さんは普段、プログラムを書くときに、予期せぬ状態になったらどうなるだろう?と心配になりませんか?特に、`switch`文で複数のケースを扱うとき、「もし将来、新しいケースが追加されたら、この`switch`文はちゃんと対応できるだろうか?」という不安を感じることがあるかもしれません。

Dart 3から導入されたパターンマッチングは、この不安を解消するための強力なツールを提供してくれました。そして、その中でも`Never`型と組み合わせることで、「このコードパスは絶対に実行されないはずだ!」というプログラマの強い意図をコンパイラに伝え、将来起こりうるミスを未然に防ぐことができるようになります。

これはまさに、あなたの書いたコードの堅牢性をコンパイル時(つまり、プログラムを実行する前)に保証してくれる、魔法のようなテクニックなんですよ。

Dart 3のパターンマッチングと網羅性チェックの基本

まずは、Dart 3で大きく進化した`switch`式の基本からおさらいしましょう。
特に`enum`(列挙型)や`sealed class`(シールクラス)と組み合わせた場合、Dartの`switch`式は非常に賢く、網羅性チェックを行ってくれます。

`enum`や`sealed class`における`switch`式の網羅性

例えば、以下のような`enum`があるとします。

/// 交通信号の状態を表す列挙型
enum TrafficLight {
red, // 赤
yellow, // 黄
green, // 緑
}

この`enum`の値を`switch`式で処理する場合、Dartコンパイラはすべてのケースが網羅されているかをチェックしてくれます。

String getAction(TrafficLight light) {
// Dart 3のswitch式 (switch expression) は、値を返します
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
TrafficLight.green => ‘進め’,
};
return action;
}

void main() {
print(getAction(TrafficLight.red)); // 出力: 止まれ
print(getAction(TrafficLight.yellow)); // 出力: 注意
print(getAction(TrafficLight.green)); // 出力: 進め
}

このコードは問題なく動作しますよね。ここで素晴らしいのは、もし`TrafficLight.green`のケースを書き忘れたらどうなるか、ということです。

String getActionMissingCase(TrafficLight light) {
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
// TrafficLight.green のケースが抜けている!
}; // ここでコンパイルエラー!
return action;
}

なんと、コンパイルエラーが発生します!
エラーメッセージは「`The type ‘TrafficLight’ is not exhaustively matched by the switch cases. Add a case for ‘TrafficLight.green’ or add a default case.`」といった内容になるでしょう。

これは、Dartコンパイラが「`TrafficLight`のすべてのケースが処理されていないよ!」と教えてくれているのです。開発者が新しい`enum`の値を追加した際に、既存の`switch`文の修正漏れを防いでくれる、非常に強力な機能ですよね。

`Never`型とは何か? – 決して値を返さない型

さて、いよいよ本日の主役の一人、`Never`型について見ていきましょう。
`Never`型はDartの型システムの中で、とても特殊な位置づけにある型です。その名の通り、「決して値を返さない」ことを表します。

もう少し具体的に言うと、以下のような状況で使われることを想定しています。

1. 例外をスローする関数:関数が常に`throw`文で終了し、正常な値を返さない場合。

Never fail(String message) {
throw ArgumentError(message); // 例外をスローするので、正常には値を返さない
}

2. 無限ループに陥る関数:関数が無限ループに入り、永遠に処理が戻ってこない場合。

Never loopForever() {
while (true) {
// 何か処理…
}
}

`Never`型は、Dartの型ヒエラルキーにおいて最下位(ボトム型)に位置します。これは、`Never`型がすべての他の型のサブタイプである、ということを意味します。
例えば、`String`型の変数を期待している場所に`Never`型の値を代入しようとすると、それは理論上可能です(ただし、`Never`型の値は存在しないため、実際に代入されることはありません)。

重要なのは、`Never`型は「このコードパスは絶対に正常に終了しない」ということをコンパイラに強く伝えるための型だ、ということです。

`Never`型とパターンマッチングの融合:到達不能コードの保証

さあ、いよいよ本題です。Dart 3のパターンマッチングにおける網羅性チェックの厳しさと、`Never`型が持つ「正常に終了しない」という性質を組み合わせることで、私たちは「到達不能コードの保証」という、非常に強力なテクニックを手に入れることができます。

具体的なコード例でステップバイステップ解説

先ほどの`TrafficLight`の例をもう一度見てみましょう。

/// 交通信号の状態を表す列挙型
enum TrafficLight {
red, // 赤
yellow, // 黄
green, // 緑
}

String getAction(TrafficLight light) {
// 全てのケースを網羅しているswitch式
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
TrafficLight.green => ‘進め’,
};
return action;
}

この`switch`式は完璧に網羅されていますよね。もしここに、あえてデフォルトケース(`_`)を追加してみたらどうなるでしょう?

String getActionWithDefault(TrafficLight light) {
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
TrafficLight.green => ‘進め’,
// ここで、あえてデフォルトケースを追加してみましょう
_ => throw ArgumentError(‘Unknown light: $light’), // ここがNever型を返す!
};
return action;
}

このコードを実行すると、Dartコンパイラは次のような警告を出します。

lib/main.dart:XX:XX: Warning: The default case for a switch expression is unreachable because the switch expression is already exhaustive.
_ => throw ArgumentError(‘Unknown light: $light’),
^^^

この警告こそが、まさに「到達不能コードの保証」をコンパイラがしてくれている証拠なんです!
コンパイラは「`TrafficLight`のすべてのケースは既に上の3つのパターンで処理されているから、デフォルトケースの`_`は絶対に実行されないよ!」と教えてくれているのです。

そして、このデフォルトケースが`throw ArgumentError(…)`という、`Never`型を返す処理になっていることで、プログラマの「このパスは絶対に到達しないはずだ」という意図が、より明確にコンパイラに伝わります。

肝心な点:もし`enum`に新しい値が追加されたらどうなる?

このテクニックの真価は、将来の変更に対する堅牢性にあります。
想像してみてください。もし将来、`TrafficLight`に新しい状態が追加されたらどうなるでしょう?

例えば、`TrafficLight`に`flashingYellow`(点滅黄)という状態が追加されたとします。

enum TrafficLight {
red,
yellow,
green,
flashingYellow, // 新しい状態が追加された!
}

この状態で、先ほどの`getActionWithDefault`関数はどうなるでしょうか?

String getActionWithDefault(TrafficLight light) {
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
TrafficLight.green => ‘進め’,
// _ => throw ArgumentError(‘Unknown light: $light’), // このデフォルトケースが重要!
};
return action;
}

なんと、今度はコンパイルエラーが発生します!

エラーメッセージは「`The type ‘TrafficLight’ is not exhaustively matched by the switch cases. Add a case for ‘TrafficLight.flashingYellow’ or add a default case.`」といった内容になります。

「あれ?でもさっきは`_`ケースがあったら警告が出てたのに、今回はエラーになるの?」と思った方もいるかもしれませんね。

実は、`getActionWithDefault`関数では、`_`ケースをコメントアウトしていました。
もし`_`ケースをそのまま残していたら、以下のようになります。

String getActionRobust(TrafficLight light) {
final action = switch (light) {
TrafficLight.red => ‘止まれ’,
TrafficLight.yellow => ‘注意’,
TrafficLight.green => ‘進め’,
// 新しい `flashingYellow` が追加されたが、ここでは処理されていない
_ => throw ArgumentError(‘Unknown light: $light’), // このブランチはNever型を返す
};
return action;
}

この場合、`TrafficLight.flashingYellow`が追加されたとしても、このコードはコンパイルエラーにはなりません。
なぜなら、`_`ケースが`flashingYellow`を含む「すべての未処理ケース」を捕捉してしまうからです。

しかし、`getActionRobust`関数が`String`を返すと宣言しているのに対し、`switch`式の`_`ケースが`throw`(`Never`型を返す)しているため、`action`変数の型推論が複雑になります。
より具体的なケースがないと`String`型に解決できないため、もし`flashingYellow`が渡された場合、`throw`が実行され、関数は`String`を返さずに終了します。

真の「到達不能コードの保証」を活かすパターン

ここで、`Never`型とパターンマッチングの真価を最大限に引き出す、もう一歩進んだ考え方を紹介します。
それは、`sealed class`と組み合わせることで、網羅性チェックをさらに強化し、将来的な拡張による未対応ケースをコンパイル時に確実に検知するパターンです。

/// アプリケーションの状態を表すsealed class
sealed class AppState {}

class LoadingState extends AppState {}
class DataState extends AppState {
final String data;
DataState(this.data);
}
class ErrorState extends AppState {
final String message;
ErrorState(this.message);
}

// ユーザーのUI表示を更新する関数
String getUiMessage(AppState state) {
final message = switch (state) {
LoadingState() => ‘データを読み込み中…’,
DataState(data: String data) => ‘データを受信しました: $data’,
ErrorState(message: String errorMsg) => ‘エラーが発生しました: $errorMsg’,
// ここに _ => throw UnimplementedError() を追加すると?
// _ => throw ArgumentError(‘Unknown AppState: $state’),
};
return message;
}

この`getUiMessage`関数は`sealed class`である`AppState`のすべてのサブタイプを網羅していますよね。
この状態で、もし`_`(デフォルトケース)を追加せずにビルドすると、コンパイラは「この`switch`式は網羅的である」と判断し、問題なくコンパイルされます。

しかし、もし将来、`AppState`に新しいサブタイプが追加されたらどうなるでしょう?

// 新しい状態を追加!
class InitialState extends AppState {} // 例: アプリ起動直後の初期状態

String getUiMessageUpdated(AppState state) {
final message = switch (state) {
LoadingState() => ‘データを読み込み中…’,
DataState(data: String data) => ‘データを受信しました: $data’,
ErrorState(message: String errorMsg) => ‘エラーが発生しました: $errorMsg’,
// InitialState のケースが抜けている!
// もし _ ケースがなければ、ここでコンパイルエラーが発生し、未対応ケースに気づける!
// _ => throw ArgumentError(‘Unknown AppState: $state’), // Never型を返す
};
return message;
}

この場合、`InitialState`が追加されたにもかかわらず、`switch`式に`InitialState()`のケースがないため、コンパイラは「`AppState`のすべてのサブタイプが網羅されていない!」と判断し、コンパイルエラーを出してくれます。
これこそが、Dart 3のパターンマッチングが提供する「堅牢な網羅性チェック」の恩恵です。

では、`Never`型をデフォルトケースに配置する「到達不能コードの保証」は、この文脈でどう活用するのでしょうか?

それは、「この`switch`式は絶対に網羅的であるべきだ」という強い確信をコンパイラに伝えたい場合に用います。
そして、もし何らかの理由でその網羅性が将来的に破られた場合(例えば、`sealed class`に新しいサブタイプが追加されたが、`switch`式を更新し忘れた場合)、その未対応ケースがコンパイル時にエラーとして表面化するように仕向ける、というテクニックです。

// 新しい状態を追加!
class InitialState extends AppState {} // アプリ起動直後の初期状態

String getUiMessageGuaranteed(AppState state) {
final message = switch (state) {
LoadingState() => ‘データを読み込み中…’,
DataState(data: String data) => ‘データを受信しました: $data’,
ErrorState(message: String errorMsg) => ‘エラーが発生しました: $errorMsg’,
InitialState() => ‘アプリが起動しました’, // 新しいケースをちゃんと処理!
// ここで、このswitch式は完全に網羅的であると確信している。
// その上で、あえてデフォルトケースをNever型で記述する。
_ => (throw UnimplementedError(‘到達不能なAppState: $state’) as Never),
// またはもっとシンプルに
// _ => throw UnimplementedError(‘到達不能なAppState: $state’),
};
return message;
}

このコードでは、`AppState`の全てのケースを明示的に処理しています。
したがって、`_`ケースは論理的に到達不可能です。
Dartコンパイラは、この`_`ケースが到達不可能であると判断し、先ほどと同様の警告を出します。

この警告こそが、私たち開発者にとっての「到達不能コードの保証」なんです。
「この`switch`式は完璧に網羅されている。この`_`ケースが実行されることは絶対にない」ということを、コンパイラが証明してくれているわけですね。

なぜこれが「到達不能コードの保証」なのか?

1. コンパイラによる健全性チェック: `enum`や`sealed class`の全ケースが網羅されていると、コンパイラは残りの`_`ケースを「到達不能」と判断し、警告を出します。この警告は、「あなたのコードは完璧だ!」というコンパイラからの太鼓判のようなものです。
2. 将来の変更に対するガード: もし将来、`enum`や`sealed class`に新しい値が追加され、それを`switch`式で処理し忘れた場合、`_`ケースが「到達可能」になります。
もし、`_`ケースが`throw`(`Never`型を返す)と明示されていれば、`switch`式全体の型が不一致となることでコンパイルエラーが発生し、未対応のケースを早期に、確実に検知できるようになります。これは、プログラムの実行時にバグとして表面化する前に、開発段階で問題を潰せることを意味します。

`Never`型は、コンパイル時にプログラムの健全性を保証するための、Dartの型システムが提供する強力な味方なんですね。

陥りやすい落とし穴と注意点

`Never`型を使うべきでない場面

`Never`型は非常に強力ですが、乱用は禁物です。
例えば、単に「この関数は値を返さない(副作用だけを持つ)」という意図で`Never`を使うべきではありません。そのような場合は`void`型を使います。

// 良くない例: 単に値を返さない関数なのにNeverを使う
Never printMessage(String msg) {
print(msg);
// ここでreturnがないため、コンパイラエラーになるか、無限ループにする必要がある
// -> Never型であるという宣言に反する
}

// 良い例: 副作用だけを持つ関数はvoid型
void printMessageCorrectly(String msg) {
print(msg);
}

`Never`型は、「絶対に制御が呼び出し元に戻らない」という極端な状況でのみ使用するべきです。

安易な`_`ケースの弊害

`switch`式の網羅性チェックは非常に便利ですが、安易に`_`(デフォルトケース)を追加してしまうと、その恩恵を失ってしまうことがあります。

enum ProductStatus { available, outOfStock, discontinued }

String getStatusMessage(ProductStatus status) {
final message = switch (status) {
ProductStatus.available => ‘購入可能です’,
// ProductStatus.outOfStock のケースを書き忘れた!
_ => ‘不明なステータス’, // これがあると、コンパイラは警告を出さない!
};
return message;
}

このコードはコンパイルエラーにはなりません。しかし、`ProductStatus.outOfStock`が渡されたら、「不明なステータス」という誤ったメッセージが表示されてしまいます。
これでは、せっかくの網羅性チェックが台無しですよね。

`_`ケースを使う場合は、それが本当に到達不能であると確信している場合か、あるいは予期せぬケースを捕捉して例外をスローするなど、明確な目的がある場合に限定しましょう。そして、後者の場合は`Never`型と組み合わせることで、もしもの時に早期検知するガードを設けることが重要になります。

まとめ:Dartの型システムを味方につける

Dartの`Never`型とパターンマッチングを組み合わせた「到達不能コードの保証」は、初学者の方には少し高度なテクニックに感じるかもしれません。しかし、これはDartの型システムが、開発者の意図を深く理解し、プログラムの堅牢性をコンパイル時に保証するための強力なメカニズムを提供していることを示しています。

  • `Never`型:関数が正常に終了せず、呼び出し元に制御が戻らないことを示す特別な型。例外スローや無限ループの戻り値として使われます。
  • パターンマッチングの網羅性チェック:`enum`や`sealed class`の`switch`式で、すべてのケースが処理されているかをコンパイラが自動でチェックしてくれます。
  • 到達不能コードの保証:網羅的な`switch`式に`_ => throw …` (`Never`型を返す) を追加することで、コンパイラは「この`_`ケースは実行されない」という警告を出します。この警告こそが、コードが完全に網羅されていることの保証であり、将来的な変更による未対応ケースをコンパイル時に検知する強力なガードとなります。

このテクニックをマスターすることで、皆さんのDartコードはより安全で、保守しやすく、そしてバグの少ないものになるでしょう。Dartの型システムを味方につけ、自信を持って素晴らしいアプリケーションを開発してください!

—

お疲れ様でした!今回は少し踏み込んだ内容でしたが、これで皆さんのDartに対する理解が一段と深まったことと思います。
これからもDartの奥深い世界を一緒に探求していきましょう!

タイトルとURLをコピーしました