こんにちは!FlutterやDartを使った開発、日々のコーディングお疲れ様です。
今回は、Dart 3で導入された「パターンマッチング」と「sealed class(網羅的なクラス階層)」を組み合わせて、オブジェクト指向の定番である「コマンドパターン」を圧倒的に美しく、そして型安全に実装する方法についてお話ししていきますね。
「デザインパターンって難しそう…」「if-elseやswitch文が長くなってコードが汚れてしまう…」そんな悩みを抱えたことはありませんか?
ここをクリアすれば、あなたの書くDartコードは一気に宣言的で堅牢になり、他の開発者から「おっ!」と一目置かれること間違いなしです。一緒に本質をマスターしていきましょう!
—
1. 従来のコマンドパターンが抱えていた「もどかしさ」
コマンドパターンとは、「やりたいこと(処理の要求)」をオブジェクトとしてカプセル化する設計手法です。例えば、UIのボタンを押したときの処理や、エディタの「Undo / Redo(元に戻す・やり直し)」を実装するときによく使われますよね。
従来のオブジェクト指向(Dart 2時代など)では、これを実現するために以下のようなアプローチをとっていました。
// 従来の抽象コマンドクラス
abstract class Command {
void execute();
}
// 具体的なコマンド1
class PrintCommand implements Command {
final String message;
PrintCommand(this.message);
@override
void execute() => print(message);
}
// 具体的なコマンド2
class ClearCommand implements Command {
@override
void execute() => print(‘画面をクリアします’);
}
これでも動くのですが、いざ「コマンドの種類に応じたデータを取得したい」「処理結果を返したい」となった途端に、`as`キャストの嵐になったり、Visitorパターンという複雑な仕組みを持ち込む必要が出てきたりして、コードが急に重苦しくなってしまいますよね。
—
2. Dart 3の武器:`sealed class` と `switch式` の融合
Dart 3で導入された `sealed class` と `switch式(パターンマッチング)` は、この問題を鮮やかに解決してくれます。
- `sealed class`: 同一ファイル内でしかサブクラス化できないクラスです。これによって、コンパイラが「すべてのサブクラスが網羅されていること」を完全に保証してくれます。
- `switch式`: 値をパターンで分解しながら、網羅的な分岐を安全に行えます。
これらを組み合わせると、「データ構造の定義」と「処理(振る舞い)」を綺麗に分離しながら、型安全なコマンドパターンを構築できるようになります。
—
3. 実践:クリーンなコマンドパターンの実装
それでは、実際にコードを書いてみましょう。
今回は、ドキュメントエディタを模して、「文字入力」「削除」「保存」という3つのコマンドを処理するシステムを作ってみます。
// 1. コマンドの型階層を sealed class で定義する
// これにより、コンパイラがすべてのパターンを把握できるようになります。
sealed class EditorCommand {
const EditorCommand();
}
// 各コマンドのデータ構造(immutableなレコードやクラスとして定義)
class TypeText extends EditorCommand {
final String text;
const TypeText(this.text);
}
class DeleteText extends EditorCommand {
final int characterCount;
const DeleteText(this.characterCount);
}
class SaveDocument extends EditorCommand {
const SaveDocument();
}
// 2. コマンドを処理するエンジン(パーサー&実行部)
class EditorEngine {
// 現在のドキュメントの状態
String _content = ”;
String get content => _content;
// コマンドを受け取り、switch式でパターンマッチングして実行する
void execute(EditorCommand command) {
// Dart 3 の switch「式」!値を返すことも可能です。
// sealed classのおかげで、万が一コマンドの追加を忘れてもコンパイルエラーで教えてくれます。
switch (command) {
case TypeText(:final text):
_content += text;
print(‘[実行] テキスト追加: “$text”‘);
case DeleteText(:final characterCount):
if (_content.length >= characterCount) {
_content = _content.substring(0, _content.length – characterCount);
} else {
_content = ”;
}
print(‘[実行] 末尾から $characterCount 文字削除’);
case SaveDocument():
print(‘[保存] ドキュメントがストレージに保存されました。(文字数: ${_content.length})’);
}
}
}
// 3. 動作確認のためのmain関数
void main() {
final engine = EditorEngine();
// コマンドのリスト(キュー)を作成
final commands = [
const TypeText(‘Hello, ‘),
const TypeText(‘Dart 3!’),
const DeleteText(1), // ‘!’ を消す
const TypeText(‘🔥’),
const SaveDocument(),
];
// 順番にコマンドを実行
for (final cmd in commands) {
engine.execute(cmd);
}
print(‘\n最終結果: ${engine.content}’);
}
実行結果
[実行] テキスト追加: “Hello, ”
[実行] テキスト追加: “Dart 3!”
[実行末尾から 1 文字削除
[実行] テキスト追加: “🔥”
[保存] ドキュメントがストレージに保存されました。(文字数: 14)
最終結果: Hello, Dart
🔥
—
4. この実装が「クリーン」で強力な理由
ここで使われている Dart 3 の文法的なポイントを少し深掘りしてみましょう。
① オブジェクトの分解(Destructuring)がスマート
`case TypeText(:final text):` という記述に注目してください。
これは、`command` が `TypeText` 型であるかをチェックすると同時に、内部の `text` プロパティの値を直接変数 `text` に抽出(分解)しています。わざわざ `(command as TypeText).text` のような冗長なキャストを書く必要が一切ありません。
② コンパイラによる「網羅性チェック(Exhaustiveness checking)」
もし将来、新しいコマンドとして `class UndoCommand extends EditorCommand {}` を追加したとします。
その瞬間、`EditorEngine` 内の `switch` 式で `UndoCommand` の処理が書かれていないと、Dartのコンパイラが即座にエラーを出して教えてくれます。
「新しい機能を追加したのに、処理を書き忘れて実行時エラー(例外)になる」というバグを、コンパイル時(コーディング中)に完全防衛できるわけです。これこそがDartの型システムの強みですね。
—
5. 陥りやすい罠とアドバイス
最後に、初心者の開発者の方々がこの書き方をするときにハマりがちなポイントを一つシェアしておきます。
> 注意:switch文ではなく「switch式」を使おう
> Dart 3では、`switch (value) { … }` を「文(Statement)」だけでなく「式(Expression)」としても使えます。今回は副作用(ドキュメントの状態変更とログ出力)がメインだったため文として書いていますが、もしコマンドの結果として「処理成功フラグ(bool)」や「メッセージ」を返したい場合は、以下のように `switch` をreturnの右側に直接書くことができます。
>
>
> String getCommandDescription(EditorCommand command) => switch (command) {
> TypeText(:final text) => ‘文字入力: $text’,
> DeleteText(:final characterCount) => ‘$characterCount 文字削除’,
> SaveDocument() => ‘保存処理’,
> };
>
> この書き方をマスターすると、コードの行数が驚くほど減り、メンテナンス性が劇的に向上します。
—
まとめ
今回は、Dart 3の `sealed class` とパターンマッチングを駆使した、クリーンなコマンドパターンの実装方法をご紹介しました。
- コマンドの種類は `sealed class` で閉じ込めて安全性を高める。
- 処理の分岐とデータの取り出しは `switch式` とオブジェクト分解 でスマートに記述する。
- コンパイラの網羅性チェックにより、実装漏れをコンパイル時に検知する。
このアプローチは、Flutterの BLoCパターンや Riverpod の State 管理、複雑なフォームの状態管理など、あらゆる場面で応用できる強力な武器になります。
ここをクリアできれば、あなたのDartの引き出しは確実に一つ深くなっていますよ。ぜひ、日々の開発の引き出しに直感的なパターンマッチングを取り入れてみてくださいね!