【実務・中級編】Dartの「for-in」と「forEach」の使い分け:非同期処理との相性を検証する – Dart コア文法・オブジェクト指向・Null安全解析バイブル

コードレビューの現場から:なぜその `forEach` はバグの温床になるのか? Dartの非同期ループを極める

テックリードの私だ。今日のコードレビューで、また若手がこんなコードを書いてきた。

// よくある「やってしまった」コード
Future processItems(List items) async {
items.forEach((item) async {
await fetchAndSave(item);
});
print(‘All items processed.’);
}

「おっ、綺麗に書けてますね!」なんて思ったそこのあなた。プロダクション環境でこのコードが走ったら、データ欠損やレースコンディションの地獄を見る事になる。一体なぜか?

今回は、Dartの `for-in` と `forEach` の決定的な違い、そして非同期処理(`async/await`)におけるイテレータの挙動を、Dart VMの内部メカニズムを踏まえて徹底的に解剖する。

—

1. そもそも `forEach` とは何者か

Dartの `Iterable.forEach` は、単なる高階関数(Higher-Order Function)にすぎない。そのシグネチャを思い出してほしい。

void forEach(void f(E element)) {
for (E element in this) {
f(element);
}
}

一見すると、内部でループを回しているから `for-in` と同じように見えるだろう。しかし、決定的な違いが1つある。`f` が返す `Future` を `forEach` は一切待機(await)しない という点だ。

冒頭のコードをもう一度見てほしい。
1. `forEach` は同期的にリストを走査し、コールバック関数(`async` 関数)を即座に同期的に呼び出す。
2. コールバックは最初の `await` に到達した時点で一旦中断し、制御を呼び出し元(`forEach`)に返す。
3. `forEach` はそれを無視して次の要素へ即座に進む。
4. 結果として、すべての非同期処理が並行(Concurrent)に発火し、ループ自体は一瞬で終了する。`print(‘All items processed.’)` は、中身の非同期処理が完了するよりはるかに前に実行される。

これは「直列処理(Sequential)」を意図した実装において、最悪のバグを引き起こす。APIのレートリミットに引っかかる、データベースのコネクションプールが枯渇する、あるいは順序が保証されないことによるデータの競合。これらはすべて、`forEach` に `async/await` を渡したことが原因だ。

—

2. `for-in` こが正義である理由:イテレータの同期・非同期制御

では、正しく順番を守って非同期処理を行いたい場合はどうするか。ここで `for-in` ループの登場だ。

Dartの `for-in` 構文は、シンタックスシュガー(構文上の糖衣)であり、裏側では `Iterator` インターフェースを直接叩いている。

// for-in がコンパイル時に展開されるイメージ
var iterator = items.iterator;
while (iterator.moveNext()) {
var item = iterator.current;
await fetchAndSave(item); // ここで確実に待機する
}

`for-in` ループの最大にして最高の特性は、明示的に `await` を記述できるため、前のイテレーションの完了を確実に待ってから次の `moveNext()` に進める点にある。これにより、完全な直列実行(Sequential Execution)が担保される。

Dart 3における進化:`await for` と非同期イテラブル

もし対象が `Future` のストリーム(`Stream`)であれば、`await for` を使うべきだ。

await for (final item in streamOfItems) {
await fetchAndSave(item);
}

これも内部的には `StreamIterator` を使い、イベントが流れてくるのを非同期に待ち合わせる。Dart VMはマイクロタスクキューとイベントループを適切に制御し、無駄なCPUサイクリングを発生させない。

—

3. 実践:プロダクションコードで使うべき設計パターン

実務の現場では、「直列ですべて処理したい場合」だけでなく、「並行処理しつつ、すべてが終わるのを待ちたい(Fan-out / Fan-in)」という要件もある。

それぞれのユースケースに合わせた、保守性の高い美しいパターンを提示しよう。

パターンA:完全な直列処理(順序保証・API負荷軽減)

APIの制限で同時にリクエストを叩けない場合などは、素直に `for-in` を使う。

/// ユーザーIDのリストを受け取り、順番にデータを同期する堅牢な実装
Future syncUsersSequentially(List userIds) async {
// ログ出力など初期化処理
print(‘=== 同期処理開始 ===’);

for (final userId in userIds) {
try {
// 前回の完了を確実に待機
await _fetchAndUpsertUser(userId);
} on NetworkException catch (e) {
// エラーハンドリングを各イテレーションで安全に捕捉可能
print(‘Failed to sync user $userId: $e’);
// 必要に応じてループを抜ける、あるいは続行する判断ができる
}
}

print(‘=== 同期処理完了 ===’);
}

Future _fetchAndUpsertUser(String userId) async {
// 実際の非同期処理
await Future.delayed(const Duration(milliseconds: 100));
}

パターンB:並行処理+全完了待機(パフォーマンス最優先)

「順序はどうでもいいから、すべての要素に対して並行にリクエストを飛ばし、全部終わったタイミングだけ知りたい」という場合は、`forEach` ではなく `Future.wait` とリスト内包表記(Map) を使うのがDartのイディオムだ。

/// 複数のアセットを並行してダウンロードし、全て完了するのを待つ
Future downloadAssetsConcurrently(List urls) async {
// 各要素の非同期処理(Future)を格納したリストを作る
// この瞬間、すべてのリクエストが並行して走り出す
final futures = urls.map((url) async {
final data = await _download(url);
await _cacheData(data);
});

// すべてのFutureが完了するのをアトミックに待機
await Future.wait(futures);

print(‘All assets downloaded and cached.’);
}

Future _download(String url) async => ”;
Future _cacheData(String data) async {}

このアプローチであれば、コードの意図が明確(「並行処理して全待ち」)であり、かつ `forEach` のように「コールバック地獄の中でエラーが握りつぶされる」ような事故を防ぐことができる。

—

4. まとめ:今日からコードレビューで指摘すべきポイント

ここまで読んだあなたなら、もう迷うことはないはずだ。チームメンバーのプルリクエストに以下の基準でレビューを入れよう。

1. `forEach` の中に `async/await` を書いていないか?

  • $\rightarrow$ 絶対にNG。並行処理の暴走、エラーハンドリングの崩壊を招く。直ちに差し戻せ。

2. 順番に処理したい(直列)のに `map` や `forEach` を使っていないか?

  • $\rightarrow$ `for-in` ループに変更し、明示的に `await` せよ。

3. 並行処理して待ちたいのか?

  • $\rightarrow$ `forEach` ではなく、`.map()` と `Future.wait()` を組み合わせた宣言的な記述を採用せよ。

Dartの強力な非同期モデルと、コンパイラやVMの裏側の挙動を理解していれば、バグの温床となるコードは自然と排除できる。
言語の仕様に踊らされるのではなく、言語を掌の上で転がすエンジニアであれ。健闘を祈る。

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