C#の繰り返し文 while・for・foreach:.NET 10でILと速度を確認
C#のwhile・do-while・for・foreachを.NET 10で動かし直し、foreachが配列・List・Spanで何にコンパイルされるか、列挙中の変更で出る例外、forとforeachの速度を実測しました。
2026年11月改訂: 2021年に韓国語で書いた繰り返し文の講座を、日本語向けに書き直しました。元の例5つを .NET 10.0.14(C# 14)でコンパイルし直したところ、すべて警告なしで動きました。一方で本文には、for文を「真になるまで繰り返す」「条件を満たすと抜ける」と逆に説明していた箇所があったので直しています。さらに、foreach が配列・
List<T>・Span<T>で実際にどんなコードになるか(IL で確認)、列挙中にコレクションを変えたときの例外、for と foreach の速度の実測を加えました。
C#の繰り返し文を学び始めた人や、「foreach は遅いから for を使え」と言われてどちらを書くべきか迷っている人向けの記事です。先に結論を書くと、配列と List<T> では for と foreach の速度差は測定できませんでした。 選ぶ基準は速度ではなく、「インデックスが要るか」「回している途中でコレクションを変えるか」です。
while と do-while:2021年の例をそのまま実行
while は bool の条件が真のあいだ本体を繰り返します。2021年の例は namespace ConsoleApplication1 と static void Main を使う古い書き方ですが、dotnet new console で作った net10.0 のプロジェクトにそのまま貼っても警告ゼロでビルドできました。要点だけ抜き出すとこうです。
int num = 1;
while (num <= 10)
Console.WriteLine("num: {0}", num++); // 出力してから1増やす(後置インクリメント)
num: 1
num: 2
…
num: 10
元の出力の最後にあった「続行するには何かキーを押してください . . .」は、Visual Studio がコンソール画面を閉じないように足していた一文で、dotnet run では表示されません。
num++ を消すと num はずっと1のままなので、num: 1 が延々と出続けます。実際に動かして気づいた点として、macOS で出力を | head -3 に渡しても .NET のプロセスは止まらず、head が終わったあとも回り続けました。暴走したループは Ctrl+C で止めます。わざと無限ループを書く方法と、そこから抜ける文は break・continue・goto の記事で扱っています。
do-while は本体を先に実行してから条件を調べるので、本体は必ず1回は動きます。最後の while (条件) のあとのセミコロンは必須で、忘れると error CS1002: ; expected で止まります。
int num = 1;
do {
Console.WriteLine("num: {0}", num++);
} while (num <= 10);
1から始めると出力は while 版と同じです。違いが出るのは、最初から条件が偽のとき。両方を num = 100 から始めると、while は何も出力せず、do-while は do num: 100 を1回だけ出力しました。「入力を受け取って、正しいか確かめる」ループにちょうどいい性質です。
for:実行の順番と2021年版の訂正
for (初期化式; 条件式; 反復式) は、数を数える while の3つの部品を1行にまとめたものです。
for (int num = 1; num <= 10; num++)
Console.WriteLine("num: {0}", num);
順番は、初期化式を1回だけ実行し、そのあと 条件式 → 本体 → 反復式 → 条件式 → 本体 → … です。初期化式で宣言した num はループの外では使えません。
2021年版で直したのは次の2点です。
- 見出しが「for(真になるまで繰り返し)」でしたが、for も while と同じく条件が真のあいだ繰り返します。
- 「条件を満たすと for 文を抜ける」も逆で、条件が偽になったら抜けます。上の例なら
numが11になった時点で終わります。
for を二重にして九九を出す例もそのまま動きました。出力行を数えると、元の説明どおり2の段から9の段まででちょうど72行です。
for (int a = 2; a < 10; a++)
for (int b = 1; b < 10; b++)
Console.WriteLine("{0} * {1} = {2}", a, b, a * b);
2 * 1 = 2
…
9 * 8 = 72
9 * 9 = 81
二重ループを一気に抜けたいときの書き方(goto、フラグ、ローカル関数)は、速度も測った break・continue・goto の記事を参照してください。
foreach は何にコンパイルされるか:配列・List・Span
foreach (変数 in コレクション) は要素を先頭から1つずつ取り出します。2021年の配列の例もそのまま動きます。
int[] arr = { 1, 2, 5, 7, 4, 9, 8, 10, 4, 7, 11 };
foreach (int i in arr)
Console.WriteLine("i: {0}", i);
あまり知られていないのは、foreach がコレクションの静的な型によってまったく別のコードになることです。同じ合計関数を4つの型で書いて Release ビルドし、ILSpy のコマンドライン版(ilspycmd)で IL を開きました。
static int Arr(int[] a) { int s = 0; foreach (int x in a) s += x; return s; }
static int Lst(List<int> a) { int s = 0; foreach (int x in a) s += x; return s; }
static int Spn(Span<int> a) { int s = 0; foreach (int x in a) s += x; return s; }
static int Seq(IEnumerable<int> a) { int s = 0; foreach (int x in a) s += x; return s; }
- 配列:列挙子はありません。
ldelem.i4(添字で読む)とldlen(長さ)だけの普通のインデックスループで、手書きの for と同じです。 Span<int>:これもget_Item(int32)とget_Length()を使うインデックスループになります。C# 1 の文法で逆コンパイルするとfor (int i = 0; i < span.Length; i++)がそのまま出てきます。List<int>:List<T>.GetEnumerator()が返す構造体のList<T>.Enumeratorを使い、MoveNext()とCurrentを呼び、try/finallyでDispose()します。構造体なのでヒープ割り当てはありません。IEnumerable<int>:IEnumerator<int>インターフェイス経由の呼び出しです。本来は列挙子がヒープに作られ、呼び出しのたびに仮想呼び出しが入ります。
foreach はインターフェイスではなく GetEnumerator() というメソッドがあれば動く「パターンベース」の構文です。だから List<T> は構造体の列挙子を返して割り当てを避けられます。配列と Span そのものの使い方は C#の配列の記事にまとめました。
foreach 中にコレクションを変えると:実際の例外と対処
List<T> の列挙子は開始時にリストのバージョン番号を覚え、MoveNext() のたびに比べます。途中でリストを変えると、次の MoveNext() で例外になります。.NET 10 での実際の出力です。
var list = new List<int> { 1, 2, 3 };
foreach (int x in list)
if (x == 2) list.Remove(x);
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
at System.Collections.Generic.List`1.Enumerator.MoveNext()
at Program.<Main>$(String[] args) in …/Program.cs:line 53
どんな変更が検出されるのか、ひととおり試しました。
| foreach 中にしたこと | .NET 10 での結果 |
|---|---|
List の途中の要素を Remove |
InvalidOperationException |
List の最後から2番目を Remove |
InvalidOperationException |
List に Add |
InvalidOperationException |
list[0] = 100(インデクサーで値だけ交換) |
InvalidOperationException |
配列 arr[2] = 30 |
例外なし、値は変わる |
Dictionary から Remove |
例外なし(.NET Core 3.0 以降) |
Dictionary に新しいキーを追加 |
InvalidOperationException |
foreach (ref int x in arr.AsSpan()) x *= 10; |
例外なし、配列は 10,20,30 になる |
意外なのは2つです。要素数が変わらないインデクサーへの代入でも List では例外になり、逆に Dictionary の Remove は列挙中でも許されていました。反復変数そのものへの代入 foreach (int v in arr) v = 0; は、そもそもコンパイルが通りません。
error CS1656: Cannot assign to 'v' because it is a 'foreach iteration variable'
偶数を消す安全な書き方を3つ試し、どれも 1,3,5 になりました。
for (int i = l1.Count - 1; i >= 0; i--) // 1) 後ろから回す for
if (l1[i] % 2 == 0) l1.RemoveAt(i);
int removed = l2.RemoveAll(x => x % 2 == 0); // 2) RemoveAll:3個削除
foreach (int x in l3.ToList()) // 3) コピーを回す
if (x % 2 == 0) l3.Remove(x);
条件で消すなら、いちばん短くて1回の詰め直しで終わる RemoveAll がおすすめです。ラムダの書き方は ラムダ式の記事を見てください。
for と foreach の速度を実測
int を100万個入れた配列と List<int> を用意し、1回の計測で合計を200回(2億回の読み取り)求めました。各方式は [MethodImpl(NoInlining)] のメソッドに分け、ウォームアップのあと Stopwatch で3回、プロセスを3回起動して合計9回計測しています。Release ビルド、.NET 10.0.14、Apple Silicon Mac(M1 Pro)です。
| 方式 | 9回の中央値 | 範囲 |
|---|---|---|
配列 for (i < a.Length) |
130.3 ms | 125.8–133.2 |
配列 foreach |
130.2 ms | 125.8–134.4 |
List<int> for (i < l.Count) |
129.5 ms | 126.7–190.4 |
List<int> foreach |
129.2 ms | 126.5–148.6 |
CollectionsMarshal.AsSpan(list) を foreach |
129.5 ms | 126.2–134.1 |
IEnumerable<int> で受けて foreach |
140.9 ms | 126.5–228.4 |
同じコードを DOTNET_TieredPGO=0 で |
931.5 ms | 906.9–949.9 |
配列・List・Span では、for と foreach の差は1%以内で、計測のぶれと区別できません。ときどき出る外れ値(List の for の190.4ms など)は、プロセスごとにばらばらの方式に出ました。
面白いのは IEnumerable<int> です。既定の設定では他とほぼ同じ速さで、遅かったのは各プロセスの最初の計測回(223〜228ms)だけでした。GC.GetAllocatedBytesForCurrentThread() で測ると割り当ても0バイト。.NET の動的 PGO が「中身はいつも List」と見抜き、インターフェイス呼び出しを直接呼び出しに置き換え、列挙子をヒープに作らなくなったと考えられます。PGO を切る(DOTNET_TieredPGO=0)と約7倍遅い931.5msになり、ループ1回ごとに40バイト(ボックス化された列挙子)が割り当てられました。「foreach は遅い」という話は、foreach そのものではなく、インターフェイス経由で回して JIT が型を見抜けないときの話だということです。
どの繰り返し文をいつ使うか
| 状況 | おすすめ |
|---|---|
| 全要素を読むだけ | foreach(配列・List とも for と同じ速さ) |
| インデックスが要る、一部の範囲だけ | for |
| 条件に合う要素を削除 | RemoveAll、または後ろから回す for |
| 配列の要素をその場で書き換え | for、または foreach (ref var x in arr.AsSpan()) |
| 回数は不明で条件だけある | while |
| 本体を最低1回は実行 | do-while |
ホットなループに IEnumerable<T> が来る |
できれば具体的な型(List<T>、配列、Span)で受ける |
絞り込みや変換だけなら、繰り返し文より LINQ のほうが短く書けることも多いです。ただし LINQ も内部では上の列挙子を使うので、性能が大事な場面では上の表を先に思い出してください。
検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定)、macOS、Apple Silicon Mac(M1 Pro)。IL の確認には ilspycmd 11.2 を使用。
よくある質問
C#ではforとforeachのどちらが速い?
配列と List<T> では差がありませんでした。int 100万個を200回合計して、配列 for 130.3ms、配列 foreach 130.2ms、List for 129.5ms、List foreach 129.2ms(.NET 10、Release、Apple Silicon Mac、9回の中央値)。遅くなりうるのは IEnumerable<T> で受けて回す場合で、PGO を切ると931.5msまで伸びました。
foreachの中でListの要素を消すと例外になるのはなぜ?
List<T> の列挙子は開始時のバージョン番号を覚えていて、MoveNext のたびに比べます。Remove や Add だけでなく list[0] = 100 のようなインデクサーへの代入でもバージョンが上がるので、System.InvalidOperationException: Collection was modified; enumeration operation may not execute. になります。RemoveAll(条件) か、後ろから回す for に書き換えます。
do-whileはどんなときに使う?
本体を最低1回は実行したいときです。条件を本体のあとで調べるので、最初から条件が偽でも1回は回ります。num = 100 から始めると while は何も出力せず、do-while は1回だけ出力しました。入力を受け取ってから妥当か調べるループが典型です。