C# のコレクション選び — 配列 / List / IEnumerable / IList の使い分け完全ガイド

みなさんこんにちは!ヒロポンです!!

今回は C# 業務SE 現場でガチで混乱しやすいやつ!!の話。

「メソッド作る時に 戻り値は List<T>IEnumerable<T>? 配列?」「引数の型は何にすればいい?」「IEnumerable<T>foreach で2回回したら SQL が2回発行された」みたいなコレクション型選びの事故って、業務SE で誰しも一回はやらかしますよね??

俺も2社目くらいの流通系SIer時代に、Dapper で取った IEnumerable<T> をリストっぽく持ち回して foreach を3箇所で呼んだら、同じ SELECT が3回発行されて1リクエスト3秒くらい遅くなった事件をやらかしました。ん?なんで遅いんだ??って詰まった夕方の運用報告から半日デバッガで追ってハマったやつ。原因は完全に遅延評価のまま持ち回しただけで、こんな感じで .ToList() を1行追加するだけで解決しました。

C# でデータの集まりを扱う型は 4系統:

  • 配列 T[](固定長・最速・API シグネチャで時々見る)
  • List<T>(可変長・最も多用・内部は配列ベース)
  • IEnumerable<T>(インターフェース・遅延評価・LINQ の基本型)
  • IList<T>(インデックスアクセス + 編集可・汎用的なメソッド引数型)

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 環境で、4種類の使い分け戻り値・引数の選び方マトリクス遅延評価で SQL 2回発行などの罠を、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • 戻り値 = IEnumerable<T>(公開 API)or List<T>(内部実装)
  • 引数 = IEnumerable<T>(読み取り専用)or IList<T>(編集も必要)
  • 固定数のデータ = 配列 T[] / 動的追加削除 = List<T> / LINQ 連鎖の中間 = IEnumerable<T>
目次

定石1: 配列 T[] — 固定長・最速だが API では出番が絞られる

C# の最も原始的なコレクション。固定長で初期化、サイズ変更不可:

// ✅ 定石1: 配列 T[] の基本
int[] daysInMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
string[] weekDays = new string[7] { "月", "火", "水", "木", "金", "土", "日" };

// インデックスアクセス
int firstMonth = daysInMonth[0];   // 31

// Length プロパティ(List<T> は Count、配列は Length)
for (int i = 0; i < daysInMonth.Length; i++) { /* ... */ }

// LINQ も使える
int totalDays = daysInMonth.Sum();   // 365

// ❌ 動的追加はできない(固定長)
// daysInMonth.Add(31);   // コンパイルエラー

ポイント:

  1. 固定長: 一度確保したらサイズ変更不可
  2. .Length プロパティ(List.Count で命名が違う)
  3. LINQ は使えるSum / Max / Where 等)
  4. 性能は最速(数値配列の単純ループで List より数 % 速い)

業務系の判断軸: 「曜日7」「月12」「ステータス5種類」のような固定数を意味的に表現する場面で配列を選ぶ。可変長操作が要るなら次の List<T> に切り替えます。

定石2: List<T> — 可変長・最も多用される実装型

業務系で最も使用頻度が高いのがこれ。可変長で Add / Remove / Insert ができる:

// ✅ 定石2: List<T> の基本
var customers = new List<CustomerVm>();

// 追加
customers.Add(new CustomerVm { Id = 1, Name = "サンプル商事" });
customers.Add(new CustomerVm { Id = 2, Name = "山田工業" });

// インデックスアクセス
var first = customers[0];

// 削除
customers.RemoveAt(0);
customers.RemoveAll(c => c.Name.StartsWith("廃止"));

// AddRange で配列・他コレクションから一括追加
var newOnes = LoadFromDb();
customers.AddRange(newOnes);

ポイント:

  1. 内部は配列ベース(容量が足りなくなると自動拡張・倍々で確保)
  2. Count プロパティ(配列の Length と違う名前)
  3. 可変長操作が全部使えるAdd / Insert / Remove / RemoveAt / Clear
  4. AddRange で他コレクションから一括取り込み

ただし公開メソッドの戻り値で List<T> を返すと、呼び出し側で勝手に Add されるカプセル化違反の罠があります。次の IEnumerable<T> で対処する話に繋がる。

定石3: IEnumerable<T> — 遅延評価・LINQ の基本型

IEnumerable<T>インターフェースで、「列挙可能なデータの集まり」を表す抽象型。LINQ の戻り値はほぼ全部これ:

// ✅ 定石3: IEnumerable<T> と遅延評価
public IEnumerable<CustomerVm> GetActiveCustomers()
{
    return _db.Customers
        .Where(c => c.Status == "active")
        .Select(c => new CustomerVm { Id = c.Id, Name = c.Name });
    // ↑ この時点では SQL はまだ発行されない(遅延評価)
}

// 呼び出し側
var customers = GetActiveCustomers();

// 1回目の foreach で初めて SQL 発行
foreach (var c in customers)
{
    Console.WriteLine(c.Name);
}

// ⚠️ 2回目の foreach で同じ SQL がもう一度発行される!
foreach (var c in customers)   // ← 2回目の SQL 発行
{
    Logger.Info(c.Name);
}

// ✅ 対策: ToList() で具体化して持ち回す
var customerList = GetActiveCustomers().ToList();   // この時点で SQL 1回
foreach (var c in customerList) { /* 1回目 */ }
foreach (var c in customerList) { /* 2回目(SQL 再発行なし) */ }

ポイント:

  1. 遅延評価(実際に foreach / ToList / ToArray が呼ばれた瞬間に評価)
  2. 複数回 foreach で SQL 再発行のトラップ
  3. 公開 API の戻り値型として優秀(呼び出し側を縛らない)
  4. インデックスアクセス不可enumerable[0] できない、First() / ElementAt(0) で代替)

業務系の判断軸: 遅延評価のメリットを活かす場面(LINQ 連鎖の中間結果・大量データのストリーム処理)で IEnumerable<T>複数回 foreach する確定なら .ToList() で具体化 が業務SE 鉄則っす。

定石4: IList<T> — メソッド引数の汎用型

IList<T>インデックスアクセス + 編集可能な抽象型。List<T> や配列も実装している:

// ✅ 定石4: IList<T> をメソッド引数で受ける
public void ProcessCustomers(IList<CustomerVm> customers)
{
    // インデックスアクセス
    var first = customers[0];

    // Count
    int total = customers.Count;

    // 追加・削除も可能
    customers.Add(new CustomerVm { Id = 999, Name = "新規" });
}

// 呼び出し側: List<T> でも配列でも渡せる
List<CustomerVm> list = LoadFromDb();
CustomerVm[] array = list.ToArray();

ProcessCustomers(list);    // OK
ProcessCustomers(array);   // OK(配列も IList<T> 実装)

// IEnumerable<T> だけ渡せない(編集できないので)
IEnumerable<CustomerVm> enumerable = LoadFromDb();
// ProcessCustomers(enumerable);   // コンパイルエラー

ポイント:

  1. インデックスアクセス + 編集可能なインターフェース
  2. List<T> も配列も IList<T> を実装している
  3. 呼び出し側の選択肢が広がる(具体型を縛らない)
  4. ICollection<T> / IReadOnlyList<T> などの派生もある(用途別に絞り込む)

業務系の判断軸: メソッドの引数で「編集も必要」な場合に IList<T> 寄せ読み取り専用なら IEnumerable<T>、編集したいなら IList<T>List<T> 直接指定は避ける が原則っす。

定石5: 共変・反変の罠 — IEnumerable<T> は共変・IList<T> は不変

これも業務系で地味にハマるところ。型パラメータの変位の話:

// ✅ 定石5: 共変(covariance)の例
class Animal { }
class Dog : Animal { }

// ✅ IEnumerable<Dog> は IEnumerable<Animal> に代入可能(共変・out T)
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs;   // OK
// 「Dog の列挙は Animal の列挙としても扱える」

// ❌ List<Dog> は List<Animal> に代入できない(不変)
List<Dog> dogList = new List<Dog>();
// List<Animal> animalList = dogList;   // コンパイルエラー
// 「List<Animal> に Cat を Add される可能性があるので不可」

// ❌ IList<Dog> も IList<Animal> に代入できない(不変)
IList<Dog> dogIList = new List<Dog>();
// IList<Animal> animalIList = dogIList;   // コンパイルエラー
// 「IList<Animal>.Add(new Cat()) ができてしまうので不可」

ポイント:

  • IEnumerable<out T>共変out キーワード = 派生型を基底型に変換可能)
  • List<T> / IList<T>不変(編集できる型は変位許可されない・型安全のため)
  • IReadOnlyList<out T> も共変

業務系で意外と踏みやすい罠で、「派生クラスのリストを基底クラスのリストとして渡したい」時に IEnumerable<T> 寄せにする判断軸の根拠になります。

使い分けマトリクス — 戻り値 × 引数 × 用途

業務系で迷う場面の使い分けマトリクスを表で整理:

場面 推奨型 理由
公開 API の戻り値(外向き) IEnumerable<T> 呼び出し側を縛らない・後から実装変えやすい
内部実装の中間変数 List<T> インデックスアクセス・Count・編集が要る
公開 API の引数(読み取り) IEnumerable<T> 呼び出し側の自由度を奪わない
公開 API の引数(編集もしたい) IList<T> List や配列を受け取れる柔軟性
固定数のデータ 配列 T[] 「曜日7」「月12」を意味的に表現
動的追加・削除が頻発 List<T> 可変長操作が一通り使える
LINQ 連鎖の中間結果 IEnumerable<T> 遅延評価で中間メモリ節約
インデックスアクセスとカウントが要る IList<T> or List<T> [0].Count の両立
派生型のリストを基底型として IEnumerable<T> 共変が効くのはこれだけ

ハマりポイント — 実体験ベースの本番事故3点

1. IEnumerable<T> の複数回 foreach で SQL 3回発行(半日デバッガで追ってハマった)

Dapper で取った結果を IEnumerable<T> のまま持ち回して、foreach を3箇所で呼んだら同じ SELECT が3回発行されて1リクエスト3秒遅くなる事件。夕方の運用報告で気づいて半日デバッガで追ってハマった末に、最初の取得時に .ToList() で具体化して解決。遅延評価系を持ち回す時は ToList() でメモリに確定 を業務系チーム規約に揃えた。

2. 公開メソッドで List<T> 返したら呼び出し側で勝手に Add された(30分溶かした)

公開クラスのプロパティを List<T> で公開していて、外部から customers.Add(...) で勝手に追加される事故。カプセル化違反で内部不整合が起きた事件。30分溶かした末に、戻り値を IEnumerable<T> に変更(or IReadOnlyList<T> を採用)して解決。公開 API は IEnumerable 寄せ をルール化した。

3. int[].LengthList<int>.Count を取り違えた(夕方の運用報告で気づいた)

ジェネリックメソッドで配列と List を両方扱う処理を書いていて、配列の .Length.Count と書いてコンパイルエラー、逆に List.Count.Length と書いてエラー、を繰り返した。夕方の運用報告で「ビルドエラー残ってる」って指摘で気づいたIList か IReadOnlyList で受けて .Count に統一 する形でリファクタしてから事故が消えた。

俺の現場メモ — 業務系チームでのコレクション規約

流通系SIer時代に過去コードを grep -rnE "List<|IList<|IEnumerable<" . で100箇所近くひっかけたら、公開メソッドで List<T> 返してる箇所・IEnumerable<T> を複数回 foreach してる箇所・配列と List の混在で .Length .Count が混乱してる箇所、全部入りだった。後輩と一緒に 3行ルール にまとめた:

  1. 公開 API の戻り値は IEnumerable<T>、内部実装は List<T>(カプセル化)
  2. IEnumerable<T> を複数回 foreach する確定なら .ToList() で具体化(SQL 再発行予防)
  3. メソッド引数は読み取り IEnumerable<T> / 編集 IList<T> / 固定数 配列(呼び出し側の自由度)

このルール化で、コレクション周りのカプセル化違反 / SQL 再発行 / .Length .Count 混乱が消えた。書き方を1パターンに揃えるだけで保守工数と事故率が両方下がるので、業務系チームにはおすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 のレガシー業務系って、コレクション API は10年以上変わってないのに、書き方が現場ごとにバラバラなコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。

まとめ

場面 推奨型
公開 API 戻り値 IEnumerable<T>
内部実装の中間変数 List<T>
公開 API 引数(読み取り) IEnumerable<T>
公開 API 引数(編集も) IList<T>
固定数のデータ 配列 T[]
動的追加削除頻発 List<T>
LINQ 連鎖の中間 IEnumerable<T>
複数回 foreach 確定 .ToList() で具体化
共変を使いたい(派生→基底) IEnumerable<T>(out T)

C# のコレクション選びは、「戻り値は外向き / 内向きで分ける」「複数回 foreach は ToList で具体化」「引数は呼び出し側を縛らない」 の3点で9割困らなくなります。List<T> を裸で公開メソッドに返すと、カプセル化違反 + 性能事故の両方を踏みやすい。業務系の判断軸として 「IEnumerable / List / IList / 配列 の使い分けを1パターンに揃える」 のが本命の対処です。

よくある質問

Q1. メソッドの戻り値は List<T>IEnumerable<T>、どっちにすべき?

A. 公開 API は IEnumerable<T>(呼び出し側を縛らない・後から実装を変えやすい)、内部実装は List<T>(インデックスアクセス・Count が要るな処理向き)が業務SE 鉄則です。List<T> を返すと呼び出し側で勝手に Add される事故が起きるので、外向きは IEnumerable<T> でカプセル化するのが安全。逆に内部処理で List<T> 同士を渡し合うのは可読性のために OK です。

Q2. IEnumerable<T> を foreach で複数回回したらどうなる?

A. 遅延評価のクエリだと、回すたびに SQL が発行される事故が起きます。EF6 / Dapper の IQueryable<T> や、File.ReadLines のような遅延評価系を IEnumerable<T> として持ち回した時、foreach の2回目に同じ SQL を再実行する。対策は最初に .ToList() で具体化する。1回しか回さない場合は遅延評価のままで OK、複数回回す確定なら .ToList() してから持ち回すのが業務SE 鉄則です。

Q3. メソッドの引数は IList<T>List<T>、どっちにすべき?

A. 読み取り専用なら IEnumerable<T>、編集もしたいなら IList<T>List<T> 直接指定は呼び出し側を縛るので原則避けます。IList<T> はインデックスアクセス(list[0])と Count と Add/Remove が使えるインターフェースなので、List<T> や配列など複数の実装を受け取れる柔軟性がある。引数の型は呼び出し側の自由度を奪わない選び方が原則です。

Q4. 配列 T[]List<T> の性能差はどれくらい?

A. 10万件規模の単純ループでは配列が List<T> より数 % 速い程度で、業務系の現実では誤差レベルです。配列を選ぶ理由は性能じゃなく、固定長を意味的に表現したい場合(曜日 7、月 12、ステータスコード 5種類など)。可変長操作(Add / Remove)が頻発するなら List<T>、固定数なら配列、と用途で選ぶのが業務SE 鉄則です。

Q5. null と 空コレクションの判定はどう書く?

A. list?.Any() == true が現代的な書き方です。list == null だけだと空コレクションを取りこぼし、list.Count == 0 だけだと null で NullReferenceException が飛ぶ。?.Any() で null 安全に判定しつつ要素ありをチェックできる。LINQ の Any() は遅延評価でも最初の1要素で判定するので性能的にも問題ありません。

ここまでで C# コレクション4種類の使い分け・遅延評価の罠・共変反変は押さえた。Linq / DataTable の隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次