Dartの非同期ループ制御:`for-in` と `forEach` のランタイム解剖学
Dart VMのコアコミッターとして日々コンパイラパイプラインとランタイムの最適化に挑んでいると、開発現場でいまだに根強く残る「誤ったイディオム」に遭遇する。その代表格が、非同期処理(`async/await`)を伴うループにおける `Iterable.forEach` の使用だ。
「見た目が関数型っぽくて綺麗だから」「モダンな書き方に見えるから」という理由で、非同期処理の中で `forEach` を採用しているコードベースを見るたびに、私はランタイムのイベントループ(Event Loop)が悲鳴を上げているのを感じる。
今回は、Dart 3のパターンマッチングや最新の言語機能を踏まえつつ、`for-in` と `forEach` がコンパイル後になぜ全く異なる挙動を示すのか、その低レイヤの真実を徹底的に解剖する。
—
1. 宣言的アトラクションの罠:`Iterable.forEach` の正体
まず、`Iterable
// dart:core 相当の抽象概念
void forEach(void f(T element)) {
for (final element in this) {
f(element);
}
}
極めてシンプルだ。問題はこの引数 `void f(T element)` にある。`f` は同期的なコールバック関数として設計されている。ここに `async` 関数を渡した瞬間、何が起きるか?
// 【アンチパターン】絶対にやってはならない実装
Future
items.forEach((item) async {
await Future.delayed(Duration(milliseconds: 100));
print(‘Processed: $item’);
});
print(‘All done?’);
}
このコードの実行結果を脳内トレースできるだろうか。
出力は以下のようになる。
All done?
Processed: 0
Processed: 1
Processed: 2
… (アイテム数に依存)
なぜ `All done?` が最初にログ出力されるのか?
それは、`forEach` が内部の同期ループを一瞬で駆け抜け、`async` コールバックが返す無数の `Future` を完全に無視(火に油を注ぐように放置)して制御を戻してしまうからだ。
Dart VMの視点から見れば、`forEach` は各要素に対して `f(element)` を呼び出すが、それが返す `Future` を `await` しない。単にマイクロタスクキュー(Microtask Queue)やイベントキュー(Event Queue)に非同期処理のタスクをエンキューし続けるだけで、ループ自体はブロックされることなく終端に到達する。
これは並行処理(Concurrency)ではなく、制御不能な非同期の放流(Fire-and-forget)であり、順序保証の完全な崩壊を意味する。
—
2. ランタイムの防壁:`for-in` ループと `StreamIterator` の同期メカニズム
対して、`for-in` ループは、Dartコンパイラ(CFE: Common Front End)によって全く異なるコードに脱糖化(Desugaring)される。
特に、非同期ストリームに対する `await for` や、通常の `Iterable` に対する `for-in` は、ランタイムの実行モデルと密接に結合している。
// 同期 Iterable に対する for-in
Future
for (final item in items) {
await Future.delayed(Duration(milliseconds: 100));
print(‘Processed: $item’);
}
print(‘All done safely.’);
}
コンパイラとVMの挙動:イテレータの逐次消費
C#やTypeScriptの `for-of` と同様に、Dartの `for-in` はシンタックスシュガーであり、背後では `Iterator` インターフェースが明示的に操作される。
コンパイル時、Cフェーズはこのループを以下のような等価な構造(概念的コード)に変換する。
// CFEによる脱糖化のイメージ
Future
final Iterator
while (iterator.moveNext()) {
final int item = iterator.current;
await Future.delayed(Duration(milliseconds: 100));
print(‘Processed: $item’);
}
print(‘All done safely.’);
}
この構造の美しさは、`await` がループのブロック内部に存在している点にある。
1. `iterator.moveNext()` で次の要素を取り出す。
2. `await` によって、その非同期処理が完了するまで Dart VMの実行コンテキスト(Isolate)の制御権が一時停止(Suspend) する。
3. 非同期処理が完了(Complete)して初めて、次の `moveNext()` へ進む。
これにより、完全な逐次実行(Sequential Execution)が保証される。メモリ効率の観点からも、不必要な `Future` オブジェクトの乱立を防ぎ、GC(ガベージコレクション)のプレッシャーを劇的に軽減する。
—
3. Dart 3 パターンマッチングと `for-in` の融合
Dart 3以降、パターンマッチングが導入されたことで、`for-in` ループの表現力は極限まで高まった。非同期処理と組み合わせることで、複雑なデータ構造のパースと並行制御を同時に安全に行うことができる。
以下のコードを見てほしい。レコードや構造化されたデータを `for-in` と `switch` 式(またはパターン)で安全に非同期処理する例だ。
// Dart 3のパターンマッチングを駆使した非同期バッチ処理
sealed class Payload {}
class DataPayload extends Payload {
final int id;
final String data;
DataPayload(this.id, this.data);
}
class ErrorPayload extends Payload {
final String message;
ErrorPayload(this.message);
}
Future
// for-in による安全な逐次非同期処理
for (final payload in payloads) {
// Dart 3 パターンマッチングによる型安全なディスパッチ
switch (payload) {
case DataPayload(id: final id, data: final data) when id > 0:
await _persistData(id, data);
break;
case ErrorPayload(message: final msg):
await _logError(msg);
break;
default:
// 無効なペイロードのスキップ
continue;
}
}
print(‘Batch processing completed.’);
}
Future
await Future.delayed(const Duration(milliseconds: 50));
// VM内部でのメモリ割り当てとI/Oモック
}
Future
await Future.delayed(const Duration(milliseconds: 10));
}
このコードでは、`for-in` が各反復の完了を確実に担保しつつ、Dart 3の網羅性チェック(Exhaustiveness checking)とパターンマッチングがコンパイル時に型安全性を担保している。`forEach` でこれを実現しようとすると、コールバック内部のネストと `Future.wait` の管理地獄に陥ることは火を見るより明らかだ。
—
4. パフォーマンスとメモリレイアウトの深層
最後に、エンジニアとして知っておくべき「メモリ最適化」の観点に踏い込もう。
Dart VMは、JITおよびAOTコンパイル時において、オブジェクトのアロケーションを最小限に抑えるための最適化(Escape Analysisなど)を行う。
- `forEach` + 閉包(Closure)のコスト:
`forEach` に非同期クロージャを渡すと、VMはそのクロージャの実行環境(Lexical Environment)を保持するために、ヒープ上にコンテキストオブジェクト(Context Object)をアロケートし続ける必要がある。ループが回るたびにクロージャインスタンスが生成されるリスクもあり、GCのマーク&スイープフェーズに無駄な負荷をかける。
- `for-in` + スタックフレームの効率性:
脱糖化された `for-in` ループは、通常のローカル変数とイテレータ参照のみで構成されるため、多くの場合、VMの最適化パスによってスタック上の割り当て(あるいはレジスタ割り当て)に最適化されやすい。ヒープアロケーションの数劇的に減少し、キャッシュヒット率が向上する。
—
結論:シニアエンジニアが守るべき鉄則
制御構文の選択は、単なる「スタイルの好み」ではない。それはランタイムのイベントループに対する直接的な命令であり、メモリ管理とスレッド(Isolate)の効率性を左右するアーキテクチャ上の決定である。
- 同期・非同期を問わず、ループ内で確実に処理を順次ウェイト(await)させたい場合:
迷わず `for-in`(または `await for`) を選択せよ。
- コレクションの各要素に対して副作用のない純粋な同期的関数をマッピング・走査したい場合:
(かつ非同期が絡まない場合に限り)`forEach` や `.map()` の使用を許容せよ。
コードの美しさは、表面的な糖衣構文の数ではなく、その背後にあるランタイムの挙動を完全に支配しているという「エンジニアの確信」から生まれる。Dartの重みを知る者よ、正しきイテレーションを実装せよ。