C#の配列:.NET 10で動かし直した基本とジャグ配列・Spanの実測

C#配列の宣言・初期化とSystem.Arrayの基本から、コレクション式[1, 2, 3]、^1と..の範囲、Span、ジャグ配列と2次元配列の走査速度、Array.Resizeのコストまで.NET 10で実測。

2026年11月改訂: この記事は2021年に韓国語で書いた配列の講座を、日本語向けに書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、2次元配列の例は変数を int[] で宣言していたためコンパイルできず(error CS0029、CS0022)、本文の student[2][1] や new データ型(行, 列) という書き方も2次元配列としては誤りでした。修正したコードと実際の出力に加え、2021年以降に入ったコレクション式・インデックスと範囲・Span<T>、そしてジャグ配列と2次元配列の走査速度の実測を足しています。

C#の配列を学び始めた人や、古い書き方しか知らず int[] a = [1, 2, 3]; や a[^1] を見て戸惑った人向けの記事です。先に結論を書くと、配列は同じ型の値を決まった長さで並べたオブジェクトで、長さは作ったあと変えられません。要素数が変わるなら List<T>、2次元のデータは書きやすい int[,] よりジャグ配列 int[][] か1次元配列の方が速い、というのが今回の実測の結果です。

配列が要る理由と宣言のしかた

たとえばクラス6人の今月の読書冊数を持つとき、変数を1つずつ作るとすぐに破綻します。

int reading_1 = 4;
int reading_2 = 9;
int reading_3 = 1;
// … 30人なら30行

配列なら名前1つとインデックス(添字)ですべての値を扱えます。インデックスは0から始まるので、長さ6の配列の最後のインデックスは5です。

宣言と初期化にはいくつか書き方があります。次の5行はどれも .NET 10 でコンパイルでき、同じ配列を作ります(最後の行は2021年以降の書き方)。

int[] a = new int[6] { 4, 9, 1, 0, 21, 12 };   // 長さを明示
int[] b = new int[] { 4, 9, 1, 0, 21, 12 };    // 長さは初期値の数から
int[] c = { 4, 9, 1, 0, 21, 12 };              // 宣言と同時の初期化に限る
var   d = new[] { 4, 9, 1, 0, 21, 12 };        // 型推論 → int[]
int[] e = [4, 9, 1, 0, 21, 12];                // C# 12 コレクション式

元の記事には new int[30] {4, 9, 1, 0, 21, 12, ...} という断片がありました。説明のための省略ですが、長さを書いた場合は初期値の数がぴったり一致しなければなりません。6個だけ書くとこうなります。

Program.cs(1,29): error CS0847: 長さが '30' の配列初期化子が必要です

new int[6] のように初期値なしで作ると、全要素が既定値(int は0、bool は false、string などの参照型は null)になります。範囲外のインデックスはコンパイル時ではなく実行時に例外になります。

Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.

2021年の例を .NET 10 で実行すると

for 文で全要素を表示する例はそのまま動きました。元の出力の最後にあった「続行するには何かキーを押してください」に当たる行は Visual Studio が付けていたもので、dotnet run では出ません。

int[] reading = new int[6] { 4, 9, 1, 0, 21, 12 };
for (int i = 0; i < reading.Length; i++)
    Console.WriteLine("reading[" + i + "]={0}", reading[i]);
reading[0]=4
reading[1]=9
reading[2]=1
reading[3]=0
reading[4]=21
reading[5]=12

System.Array のプロパティとメソッドを使う例も、結果は同じでした(ラベルだけ英語にして実行)。

Properties: Rank=1, Length=6
Clear: 0 0 0 0 0 12
Resize: 0 0 0 0 0 12 0 0 0
IndexOf: 5

元の表にあった indexOf は、正しくは Array.IndexOf(大文字の I)で、見つからなければ -1 を返します。まとめ直すとこうなります。

名前 役割 .NET 10 で確かめたこと
Rank 次元数 int[] は1、int[,] は2、int[][] は1
Length 全要素数 int[3, 6] は18
Array.Clear(arr, start, count) 既定値で消す 元の例では先頭5個だけ0に
Array.ForEach(arr, action) 要素ごとに処理 Array.ForEach(reading, v => Console.Write($"{v} ")); で十分
Array.Resize(ref arr, n) サイズ変更 新しい配列を作ってコピー(後述の実測)
Array.IndexOf(arr, value) 最初に見つかった位置 なければ -1

元のコードは Array.ForEach<int>(reading, new Action<int>(ShowValue)) とデリゲートを明示していましたが、今は型引数も new Action<int> も省略して、メソッド名やラムダを渡せば済みます。ラムダについては ラムダ式 の記事で扱っています。

2次元配列:元の例がコンパイルできなかった理由

元の2次元配列の例は、宣言が int[] reading = new int[3, 6]{…} でした。右辺は2次元なのに左辺の変数が1次元なので、エラーが2つ出ます。

Program.cs(8,29): error CS0029: 型 'int[*,*]' を 'int[]' に暗黙的に変換できません
Program.cs(13,78): error CS0022: 角かっこ [] 内のインデックス数が正しくありません。正しい数は 1 です

変数の型を int[,] にすれば動きます。

int[,] reading = new int[3, 6] {
    { 4, 9, 1, 0, 21, 12 }, { 1, 4, 6, 0, 9, 5 }, { 4, 4, 2, 4, 0, 0 }
};
for (int i = 0; i < reading.GetLength(0); i++)
    for (int j = 0; j < reading.GetLength(1); j++)
        Console.WriteLine("reading[" + i + ", " + j + "] = {0}", reading[i, j]);
reading[0, 0] = 4
reading[0, 1] = 9
…
reading[2, 4] = 0
reading[2, 5] = 0

本文の説明にも直すところが2つありました。

  • 宣言は new データ型(行, 列) ではなく new int[行, 列](角かっこ)です。
  • 2次元配列の要素は student[2][1] ではなく student[2, 1] で参照します。[2][1] と書くと error CS0022: 角かっこ [] 内のインデックス数が正しくありません。正しい数は 2 です になります。また最初のインデックスが行なので、[2, 1] は「3列2行」ではなく 3行目の2列目です。GetLength(0) が行数(3)、GetLength(1) が列数(6)です。

[i][j] と書くのはジャグ配列(配列の配列)int[][] の方です。両者はメモリ上の形が違います。

2段の図。上:int[,] grid = new int[3, 6] は1つのオブジェクトを指し、その中に18個のintが0行目・1行目・2行目の順にひと続きで並ぶ。下:int[][] jag は3つの参照を持つ外側の配列を指し、各参照は別々の配列につながる。1と2を持つ int[2]、3・4・5を持つ int[3]、空の int[0]。
多次元配列は1つのオブジェクトに要素がひと続きで入る。ジャグ配列は行ごとに別の配列を持つ配列で、行ごとに長さを変えられる。
int[,] grid = new int[3, 6];
int[][] jag = [[1, 2], [3, 4, 5], []];
Console.WriteLine($"grid Rank={grid.Rank} Length={grid.Length} GetLength(0)={grid.GetLength(0)} GetLength(1)={grid.GetLength(1)}");
Console.WriteLine($"jag Rank={jag.Rank} Length={jag.Length} jag[1].Length={jag[1].Length}");
grid Rank=2 Length=18 GetLength(0)=3 GetLength(1)=6
jag Rank=1 Length=3 jag[1].Length=3

ジャグ配列の Length は外側の配列の長さ(行数)にすぎない点に注意してください。

C# 12 以降:コレクション式、インデックスと範囲、Span

2021年(C# 9)にはなかった構文のうち、配列に直接関わるものを確かめました。

int[] reading = [4, 9, 1, 0, 21, 12];   // コレクション式 (C# 12)
int[] more = [..reading, 7, 8];         // スプレッド ..
Console.WriteLine(string.Join(" ", more));
Console.WriteLine($"last={reading[^1]} second-last={reading[^2]}");   // ^ は末尾から (C# 8)

int[] mid = reading[1..4];              // 範囲:新しい配列にコピー
mid[0] = 99;
Console.WriteLine($"after mid[0]=99 -> reading[1]={reading[1]}");

Span<int> view = reading.AsSpan(1, 3);  // Span:コピーせず元を指す
view[0] = 99;
Console.WriteLine($"after view[0]=99 -> reading[1]={reading[1]}");
4 9 1 0 21 12 7 8
last=12 second-last=21
after mid[0]=99 -> reading[1]=9
after view[0]=99 -> reading[1]=99

ポイントは、配列に範囲 [1..4] を使うとコピーができ、AsSpan は元の配列の一部をのぞく窓だという違いです。大きな配列の一部をメソッドに渡すなら Span<T>/ReadOnlySpan<T> にすればコピーは起きません。

逆にできないこともありました。

var x = [1, 2, 3];                // error CS9176: コレクション式のターゲット型がありません。
int[,] g = [[1, 2], [3, 4]];      // error CS9174: 型が構築可能でないため、コレクション式で型 'int[*,*]' を初期化できません。
var bad = grid[^1, 0];            // error CS0029: 型 'System.Index' を 'int' に暗黙的に変換できません

コレクション式、^、..、AsSpan は、実質的に1次元配列(とジャグ配列)向けです。多次元配列は従来の書き方のままです。

配列は参照型、Resize は新しい配列を作る

配列の変数を別の変数に代入しても要素はコピーされず、同じ配列を指します。元の記事は Array.Resize(ref reading, …) の ref を「配列のアドレス」と説明していましたが、正確には変数そのものを渡して、メソッドがその変数に新しい配列を代入できるようにするものです。

int[] before = reading;
Array.Resize(ref reading, reading.Length + 3);
Console.WriteLine($"same object? {ReferenceEquals(before, reading)}  before.Length={before.Length} reading.Length={reading.Length}");
same object? False  before.Length=6 reading.Length=9

Resize は長さ9の新しい配列を作って値をコピーし、reading だけを差し替えます。before は長さ6の古い配列のままです。つまり1要素足すたびに Resize(+1) を呼ぶと、毎回全体のコピーが起きます。実際に測りました(Release ビルド、Stopwatch、割り当ては GC.GetAllocatedBytesForCurrentThread()、3回実行)。

10万要素を詰める 1回目 2回目 3回目 割り当て
Array.Resize(ref arr, arr.Length + 1) 1,097.8 ms 1,178.2 ms 1,184.1 ms 18.63 GiB
List<int>.Add のあと ToArray() 0.17 ms 0.12 ms 0.40 ms 1.38 MB

10万要素で1秒以上、割り当ては約18.6GiBでした。長さ1、2、3、…、100,000 の配列を順に作るので、4バイト×(1+2+…+N) ≈ 20GB という計算と合います。要素数が事前に分からないなら、List<T> で集めて最後に ToArray() します。

ジャグ配列と2次元配列の走査速度(実測)

2次元データの持ち方3通り(int[,]、int[][]、1次元 int[] に i * C + j)に同じ値を入れ、全要素の合計にかかる時間を測りました。それぞれを [MethodImpl(NoInlining)] のメソッドにし、4,000×4,000(64MB)の行列を12回合計(1億9,200万回の読み取り)、ウォームアップ1回のあと3回計測しています。

static long SumMd(int[,] a) { long s = 0;
    for (int i = 0; i < a.GetLength(0); i++)
        for (int j = 0; j < a.GetLength(1); j++) s += a[i, j];
    return s; }
static long SumJag(int[][] a) { long s = 0;
    for (int i = 0; i < a.Length; i++) { var row = a[i];
        for (int j = 0; j < row.Length; j++) s += row[j]; }
    return s; }
static long SumFlat(int[] a) { long s = 0;
    for (int i = 0; i < a.Length; i++) s += a[i];
    return s; }
// 列優先:SumMd の i と j のループを入れ替えたもの。Span:foreach (var v in (ReadOnlySpan<int>)flat)
方式 1回目 2回目 3回目
int[,] 行優先 [i, j] 153 ms 153 ms 155 ms
int[,] 列優先 755 ms 743 ms 742 ms
int[][] ジャグ配列 126 ms 124 ms 124 ms
int[] 1次元 123 ms 123 ms 124 ms
ReadOnlySpan<int> foreach 124 ms 123 ms 124 ms
走査時間の棒グラフ:int[,] 行優先 154ms、int[,] 列優先 747ms、ジャグ配列 int[][] 125ms、1次元 int[] 123ms、ReadOnlySpan の foreach 124ms。2次元配列を列優先で回すと行優先の約5倍遅い。
4,000×4,000 の int 行列を12回合計(1億9,200万回の読み取り)した時間。3回の平均。.NET 10.0.14、Release ビルド、Apple Silicon Mac。短いほど速い。

ジャグ配列と1次元配列は int[,] より約2割速くなりました。int[,] のインデクサーは2つの次元それぞれの境界チェックが要るのに対し、ジャグ配列は行を row に取り出せば内側ループの境界チェックを JIT が外せるためです。それより大きかったのは走査の順番です。int[,] は行単位でメモリに並んでいるので、列優先で回るとキャッシュを外し続けて約5倍遅くなりました。同じ計測を 1,000×1,000(4MB)で行うと列優先 167〜201ms、行優先 155〜160ms と差は小さく、これは行列がほぼCPUキャッシュに収まるからです。小さな配列で測った結果を大きなデータにそのまま当てはめてはいけない、という例でもあります。

どれを使うか

状況 おすすめ
要素数が決まった値の並び int[] a = [ … ];(C# 12)
要素数が増減する List<T>、最後に ToArray()
大きな配列の一部を渡す AsSpan(start, length) / ReadOnlySpan<T>(コピーなし)
一部を切り出して保持 範囲 a[1..4](コピー)
行ごとに長さが違う表 int[][] ジャグ配列
サイズ固定の行列、読みやすさ優先 int[,]、必ず行優先で回す
サイズ固定の行列、速度優先 1次元 int[] + i * cols + j、または int[][]

配列を引数に渡したとき何がコピーされるのかは、メソッド の記事の ref・params の節で続けて扱います。配列の絞り込みや並べ替えは LINQの基本 を参照してください。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定値)、macOS(Apple Silicon Mac)。

よくある質問

int[,] と int[][] はどちらを使うべき?

行ごとに長さが違うなら int[][](ジャグ配列)しかありません。長さがそろっていても速度が大事なら、ジャグ配列か1次元配列(i*列数+j)が有利でした。4,000×4,000 の行列の合計で、int[,] の行優先が154ms、int[][] が125ms、1次元 int[] が123msでした(.NET 10、Release、Apple Silicon Mac)。int[,] は grid[i, j] と書ける読みやすさが利点です。

配列をあとから大きくするには Array.Resize を使えばいい?

Array.Resize は新しい配列を作って全要素をコピーします。1要素ごとに Resize(+1) すると10万要素で約1.1秒、割り当て18.6GiBでした。List<int>.Add のあと ToArray() なら0.4ms以下です。サイズが頻繁に変わるなら List<T> で集めて最後に配列にします。

var x = [1, 2, 3]; がコンパイルできないのはなぜ?

C# 12 のコレクション式はターゲット型が必要です。var で受けると error CS9176(コレクション式のターゲット型がありません)になります。int[] x = [1, 2, 3]; と型を書きます。int[,] のような多次元配列はコレクション式で初期化できません(error CS9174)。

  • #C#
  • #配列
  • #Array
  • #Span
  • #.NET

t.laboratory

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

運営者情報 · アプリ

↑