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 (var x in c) は c の静的な型で分かれる。int[] と Span<int> は列挙子オブジェクトのないインデックスループになる。List<int> は try/finally の中でバージョン検査をする構造体 List<T>.Enumerator になる。IEnumerable<int> は IEnumerator<T> のインターフェイス呼び出しになり、JIT が脱仮想化しなければ列挙子がボックス化される。
.NET 10 の Release ビルドの IL から読んだ、同じ foreach に対してコンパイラが出すコード。配列と Span<T> はただのインデックスループ、List<T> はバージョン検査つきの構造体列挙子、IEnumerable<T> はインターフェイス呼び出しになる。

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
ループ時間の棒グラフ:配列 for 130.3ms、配列 foreach 130.2ms、List for 129.5ms、List foreach 129.2ms、CollectionsMarshal.AsSpan 経由 129.5ms、IEnumerable<int> の foreach 140.9ms、PGO を切った同じ IEnumerable ループ 931.5ms。型が分かっているループどうしの差は約1%以内。
100万個の int を200回合計(2億回の読み取り)。ウォームアップ後の計測9回(3プロセス×3回)の中央値。.NET 10.0.14、Release ビルド、Apple Silicon Mac(M1 Pro)。最後の棒は同じ IEnumerable ループを DOTNET_TieredPGO=0 で動かしたもの。

配列・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回だけ出力しました。入力を受け取ってから妥当か調べるループが典型です。

  • #C#
  • #繰り返し文
  • #foreach
  • #for
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑