【入門編】Dartのパターンマッチングで「再帰的データ構造」をエレガントに処理する – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは!FlutterやDartの開発現場で、日夜コードと格闘されていることと思います。

今回は、Dart 3で導入された「パターンマッチング」を使って、ツリー構造やネストしたJSONといった再帰的データ構造をエレガントに料理する方法についてお話ししますね。

「ツリー構造の探索って、なんだか複雑なループや条件分岐がネストしてコードが読みにくくなりそう……」
そんな風に身構えてしまうことはありませんか?

大丈夫です。Dart 3のパターンマッチングを武器にすれば、数学の漸化式(ぜんかしょく)を書くように、美しく簡潔にデータを処理できるようになりますよ。ここをクリアすれば、あなたのDartの表現力は一段と跳ね上がります。一緒にマスターしていきましょう!

—

1. 再帰的データ構造とは何か?

まず、「再帰的データ構造(Recursive Data Structure)」のイメージを共有しておきましょう。
身近な例で言うと、「フォルダの中に、またフォルダやファイルが入っているファイルシステム」や、「企業の組織図(上司の下に部下(リーダー)がいて、その下にも……)」などがこれに当たります。

[ルートフォルダ]
├── readme.txt (ファイル)
└── [srcフォルダ]
├── main.dart (ファイル)
└── [widgetフォルダ]
└── button.dart (ファイル)

これをプログラムで表現する場合、「自分自身と同じ構造のデータを子として内包する」という形をとります。
Dartのオブジェクト指向と組み合わせると、sealedクラス(または抽象クラス)を使ってこのように表現できます。

// ファイルシステムを模したデータ構造
sealed class FileSystemItem {}

class FileItem extends FileSystemItem {
final String name;
final int sizeBytes;
FileItem(this.name, this.sizeBytes);
}

class DirectoryItem extends FileSystemItem {
final String name;
final List children;
DirectoryItem(this.name, this.children);
}

`DirectoryItem` の中には、ファイルだけでなく、さらに別の `DirectoryItem`(子フォルダ)が含まれる可能性もあります。これが「再帰的」と呼ばれる所以です。

—

2. 従来の `is` チェックの苦悩

このツリー構造の合計ファイルサイズを計算したり、特定の名前のファイルを検索したりするプログラムを、Dart 3以前のやり方(あるいは他の言語の泥臭い書き方)で書こうとすると、どうなるでしょうか?

// 従来の書き方(イメージ)
int calculateTotalSize(FileSystemItem item) {
if (item is FileItem) {
return item.sizeBytes;
} else if (item is DirectoryItem) {
int total = 0;
for (var child in item.children) {
total += calculateTotalSize(child); // 再帰呼び出し
}
return total;
}
throw Exception(‘Unknown type’); // ボイラープレート(お決まりのコード)が必要
}

これでも動きますが、`is` で型チェックをするたびにダウンキャストの不安がつきまといますし、将来新しい種類のアイテム(例えばシンボリックリンクなど)を追加したときに、コンパイラが「処理の書き忘れ」を教えてくれません。

—

3. Dart 3 パターンマッチング + `switch` 式でエレガントに書き換える

ここで登場するのが、Dart 3の `switch` 式 と オブジェクトパターン です!

Dart 3では、`switch` を文(Statement)ではなく式(Expression)として使えます。さらに、パターンの網羅性チェック(Exhaustiveness checking)が効くため、`sealed` クラスのサブクラスをすべて処理し忘れていないか、コンパイル時に検知してくれます。

実際に、先ほどの合計サイズ計算をDart 3らしく書き直してみましょう。

sealed class FileSystemItem {}

class FileItem extends FileSystemItem {
final String name;
final int sizeBytes;
FileItem(this.name, this.sizeBytes);
}

class DirectoryItem extends FileSystemItem {
final String name;
final List children;
DirectoryItem(this.name, this.children);
}

/// 再帰的にファイルシステムの合計サイズを計算する関数
int calculateTotalSize(FileSystemItem item) {
return switch (item) {
// FileItemにマッチした場合:sizeBytesをそのまま返す
FileItem(sizeBytes: var bytes) => bytes,

// DirectoryItemにマッチした場合:子要素のサイズを合計して返す
DirectoryItem(children: var list) =>
list.fold(0, (sum, child) => sum + calculateTotalSize(child)),
};
}

このコードの何がスゴいのか?

1. ボイラープレートの消滅: `if-else` やキャスト(`as`)が完全に消え、宣言的に「このパターンのときは、この値」と記述できます。
2. 抽出とバインドの同時実行: `FileItem(sizeBytes: var bytes)` の部分で、オブジェクトのプロパティ(`sizeBytes`)へのアクセスと、ローカル変数(`bytes`)へのバインドを同時に行っています。
3. 安全性の担保(網羅性): もし将来 `LinkItem` という新しいサブクラスを追加し忘れた場合、コンパイラが「`switch` がすべてのケースを網羅していません」と赤く波線を引いて教えてくれます。

—

4. 実践:ネストしたJSONデータを安全にパースする

ツリー構造の最たる例が、APIから飛んでくるネストしたJSONです。
例えば、以下のような組織ツリーのJSONを考えてみましょう。

{
“name”: “CEO”,
“subordinates”: [
{
“name”: “CTO”,
“subordinates”: [
{ “name”: “Lead Engineer”, “subordinates”: [] }
]
},
{
“name”: “CFO”,
“subordinates”: []
}
]
}

これをDartのレコードやパターンを使って、直感的にデコード・探索してみます。

// 組織のメンバーを表すモデル
sealed class Employee {}

class Person extends Employee {
final String name;
final List subordinates;
Person(this.name, this.subordinates);
}

/// JSONのマップから再帰的にEmployeeツリーを構築する関数
Employee parseEmployee(Map json) {
final name = json[‘name’] as String;
final rawSubordinates = json[‘subordinates’] as List? ?? [];

final subordinates = rawSubordinates
.map((child) => parseEmployee(child as Map))
.toList();

return Person(name, subordinates);
}

/// 特定の名前を持つ社員を探す再帰的検索関数
bool findEmployee(Employee employee, String targetName) {
return switch (employee) {
// 自分の名前が一致するか、部下の誰かに含まれているか
Person(name: var n, subordinates: var subs) when n == targetName => true,
Person(subordinates: var subs) => subs.any((sub) => findEmployee(sub, targetName)),
};
}

void main() {
// データの構築(実際はJSONデコードから行う)
final orgTree = Person(‘CEO’, [
Person(‘CTO’, [
Person(‘Lead Engineer’, []),
]),
Person(‘CFO’, []),
]);

// 探索のテスト
print(findEmployee(orgTree, ‘Lead Engineer’)); // 结果: true
print(findEmployee(orgTree, ‘Designer’)); // 结果: false
}

ここで注目してほしいのは、`when n == targetName` というガード(Guard)条件です。
パターンマッチングに「追加の条件式」を挟むことで、「名前に一致した場合は即座に `true` を返し、そうでなければ部下を探索する」という複雑なロジックを美しく表現できています。

—

5. 陥りやすい文法エラーと注意点

パターンマッチングを使った再帰処理を書く際、初学者がハマりやすいポイントをいくつかピックアップしておきますね。

① `switch` は「式」なので `break` は不要!

従来の `switch` 文と違い、Dart 3の `switch` 式(アクト / アロー構文を使うもの)では、各ケースの終わりに `break` を書く必要はありません。むしろ書くと構文エラーになります。

② null安全との組み合わせでの型不一致

JSONなどを扱う際によくあるのが、`Map` から値を取り出すときの型キャスト忘れです。Dartは厳格なサウンド・null安全を採用しているため、パターンマッチングの変数型が一致しないとコンパイルエラーになります。しっかりとキャストを挟むか、パターン内で型チェック(例:`String name` など)を組み合わせましょう。

—

まとめ

今回は、Dart 3のパターンマッチングを用いた「再帰的データ構造の処理」について解説しました。

  • 再帰的データ構造 とは、自分自身を内包するデータ構造(ツリーやJSONなど)。
  • Dart 3の `switch` 式 と オブジェクトパターン を使うことで、従来の `is` チェックや複雑な `if-else` を排除できる。
  • コンパイラの 網羅性チェック が働くため、安全で保守性の高いコードが書ける。

ここをクリアできれば、複雑なデータ構造を扱うFlutterのWidgetツリーの解析や、独自のAST(抽象構文木)の処理なども怖くなくなりますよ。ぜひ、あなたのプロジェクトでも試してみてくださいね!

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