演算子

【C#】new Listの基本構文と初期化・追加・容量設定をやさしく解説

C#で可変長コレクションが必要なときは、new List<T>()でジェネリックリストを生成すると追加・削除・検索が手軽に行えます。

コンストラクターに初期容量や既存コレクションを渡してメモリや初期値を調整でき、LINQと組み合わせれば柔軟なデータ操作が可能です。

目次から探す
  1. List<T>とは何か
  2. List<T>を生成する
  3. 初期化パターン
  4. 要素の追加操作
  5. 要素の取得とアクセス
  6. 要素の更新と置換
  7. 要素の削除操作
  8. 反復処理
  9. 並べ替えと検索
  10. キャパシティ管理
  11. 変換とコピー
  12. スレッドセーフティ
  13. イミュータブルに扱うテクニック
  14. ネストされたList
  15. カスタムクラスのList
  16. よくある落とし穴
  17. パフォーマンス最適化ポイント
  18. C#バージョン別の進化
  19. サンプルコード全体像
  20. まとめ

List<T>とは何か

C#のList<T>は、ジェネリックコレクションの一つで、動的にサイズが変化する配列のようなデータ構造です。

ここでは、List<T>の位置付けや特徴、配列との違い、そして基本的な用途についてわかりやすく解説します。

List<T>の位置付けと特徴

List<T>は、System.Collections.Generic名前空間に属するクラスで、任意の型Tの要素を格納できる可変長のコレクションです。

ジェネリックであるため、型安全に要素を管理でき、実行時の型チェックやキャストの必要がありません。

主な特徴は以下の通りです。

  • 動的なサイズ変更

内部的には配列を使っていますが、要素の追加や削除に応じて自動的に容量を拡張・縮小します。

これにより、サイズを気にせずに要素を追加できます。

  • 高速なインデックスアクセス

配列と同様にインデクサ[]を使って高速に要素へアクセスできます。

ランダムアクセスが多い場合に適しています。

  • 豊富なメソッド群

要素の追加Add、削除Remove、検索Contains、並べ替えSortなど、多彩な操作が用意されています。

  • 型安全

ジェネリック型なので、コンパイル時に型が決定し、異なる型の要素が混入することを防げます。

  • LINQとの親和性

LINQ(Language Integrated Query)を使うことで、条件検索や並べ替え、集計などの高度な操作も簡単に行えます。

このように、List<T>はC#で最もよく使われるコレクションの一つであり、柔軟かつ効率的にデータを扱うことができます。

配列との違い

C#には配列T[]もありますが、List<T>とはいくつかの重要な違いがあります。

特徴配列(T[])List<T>
サイズ変更固定サイズ。作成後は変更不可。動的にサイズが変化。追加・削除可能です。
初期化サイズを指定して作成。空のリストや初期値付きで作成可能です。
要素の追加・削除できない(新しい配列を作る必要あり)AddRemoveで簡単に操作可能です。
型安全型安全(固定型)ジェネリックで型安全。
メソッドの豊富さほとんどなし多彩なメソッドが用意されています。
パフォーマンスサイズ固定なので高速アクセス可能サイズ変更時に内部配列の再割当てが発生。

配列はサイズが固定であるため、要素数が変わらない場合やパフォーマンスを最優先したい場合に適しています。

一方、List<T>はサイズが変わる可能性がある場合や、要素の追加・削除を頻繁に行う場合に便利です。

基本的な用途

List<T>は、以下のようなシナリオでよく使われます。

  • 動的に要素数が変わるデータの管理

ユーザーの入力や外部データの読み込みなどで、要素数が事前にわからない場合に適しています。

  • 要素の追加・削除が頻繁にある場合

配列では要素の追加や削除が面倒ですが、List<T>なら簡単に操作できます。

  • 検索や並べ替えが必要な場合

List<T>ContainsSortなどのメソッドが用意されているため、データの検索や並べ替えが簡単に行えます。

  • LINQを使った高度なクエリ操作

LINQと組み合わせて、条件に合う要素の抽出や集計などを効率的に行えます。

  • 複数の型のオブジェクトを管理する場合

ジェネリックなので、任意の型のオブジェクトを安全に管理できます。

例えば、List<Person>のようにカスタムクラスのリストも簡単に作成できます。

以下は、List<T>の基本的な使い方の例です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // int型のListを作成し、初期値を追加
        List<int> numbers = new List<int> { 1, 2, 3 };
        numbers.Add(4); // 要素を追加
        // 要素の表示
        foreach (int num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4

このように、List<T>は柔軟で使いやすいコレクションとして、C#のプログラミングで非常に役立ちます。

List<T>を生成する

new List<T>()コンストラクターの種類

List<T>を生成する際には、用途に応じて複数のコンストラクターが用意されています。

ここでは代表的な3種類のコンストラクターについて説明します。

既定コンストラクター

引数なしで呼び出す既定コンストラクターは、空のリストを作成します。

内部の容量は初期状態で小さな配列が割り当てられ、要素が追加されると自動的に容量が拡張されます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 空のList<int>を作成
        List<int> numbers = new List<int>();
        // 要素を追加
        numbers.Add(10);
        numbers.Add(20);
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
10
20

このコンストラクターは、要素数が不明であったり、最初は空のリストを用意したい場合に使います。

容量指定コンストラクター

初期容量を指定してリストを作成するコンストラクターです。

容量とは内部で確保される配列のサイズで、要素数が増えてもこの容量までは再割り当てが発生しません。

大量の要素を追加する場合にパフォーマンス向上が期待できます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 初期容量100のList<string>を作成
        List<string> fruits = new List<string>(100);
        fruits.Add("apple");
        fruits.Add("banana");
        Console.WriteLine($"Count: {fruits.Count}");
        Console.WriteLine($"Capacity: {fruits.Capacity}");
    }
}
Count: 2
Capacity: 100

容量を指定することで、要素追加時の内部配列の再割り当て回数を減らし、パフォーマンスを改善できます。

コレクション受け取りコンストラクター

既存のコレクション(IEnumerable<T>を実装したもの)を引数に取り、その要素をコピーして新しいリストを作成します。

配列や他のリストから簡単にList<T>を生成したい場合に便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 配列からListを作成
        int[] array = { 1, 2, 3, 4 };
        List<int> numbers = new List<int>(array);
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4

このコンストラクターは、既存のコレクションを元に新しいリストを作りたいときに使います。

ジェネリック型パラメーターTの選び方

List<T>Tは格納する要素の型を指定します。

基本的には扱いたいデータの型を指定すればよいですが、いくつかポイントがあります。

  • プリミティブ型や組み込み型

intstringdoubleなど、よく使う型はそのまま指定します。

例えばList<int>は整数のリストです。

  • カスタムクラスや構造体

独自に定義したクラスや構造体も指定可能です。

例えばList<Person>のように、Personクラスのオブジェクトを格納できます。

  • インターフェース型

複数の異なる型を同じリストで扱いたい場合は、共通のインターフェースを指定することもあります。

例えばList<IShape>など。

  • Nullable型

値型でnullを許容したい場合はList<int?>のようにNullable型を指定します。

  • 参照型と値型の違い

参照型はオブジェクトの参照を格納し、値型は値そのものを格納します。

パフォーマンスやメモリの観点で意識することがあります。

型を選ぶ際は、格納したいデータの性質や操作内容に合わせて適切な型を指定してください。

生成時の型推論とvar利用

C#では、new演算子の右辺で型を省略しても、左辺の型から自動的に推論される機能があります。

これを利用するとコードがすっきりします。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 型を明示的に指定
        List<string> fruits1 = new List<string>();
        // varを使い型推論
        var fruits2 = new List<string>();
        fruits2.Add("apple");
        fruits2.Add("banana");
        foreach (var fruit in fruits2)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana

varを使うと、右辺のnew List<string>()から型が推論されるため、左辺の型を繰り返し書く必要がありません。

特に型名が長い場合や複雑な場合に便利です。

また、C# 9.0以降では、ターゲット型が明確な場合にnew()だけでインスタンス化できる構文もあります。

List<int> numbers = new(); // List<int>型と推論される

このように、型推論を活用するとコードが簡潔になり、可読性が向上します。

初期化パターン

コレクション初期化子

C#では、List<T>を生成するときにコレクション初期化子を使うことで、インスタンス作成と同時に要素を簡潔に追加できます。

波括弧 {} の中に要素を列挙するだけで初期化できるため、コードが読みやすくなります。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // コレクション初期化子を使ってList<int>を作成
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4
5

この方法は、要素が既に決まっている場合やテストデータを用意するときに便利です。

List<string>やカスタムクラスのリストでも同様に使えます。

List<string> fruits = new List<string> { "apple", "banana", "cherry" };
List<Person> people = new List<Person>
{
    new Person { Name = "Alice", Age = 30 },
    new Person { Name = "Bob", Age = 25 }
};

配列からの変換

既に配列がある場合は、その配列を引数にしてList<T>を生成することができます。

配列の要素がすべてコピーされ、新しいリストが作られます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        int[] array = { 10, 20, 30, 40 };
        // 配列からList<int>を作成
        List<int> numbers = new List<int>(array);
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
10
20
30
40

この方法は、配列のデータをリストに変換して、リスト特有の操作(追加や削除など)を行いたいときに使います。

既存Listのコピー

既存のList<T>から新しいリストを作成することも可能です。

コピーコンストラクターを使うと、元のリストの要素が新しいリストに複製されます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> original = new List<string> { "red", "green", "blue" };
        // 既存のListから新しいListを作成
        List<string> copy = new List<string>(original);
        copy.Add("yellow");
        Console.WriteLine("Original list:");
        foreach (var color in original)
        {
            Console.WriteLine(color);
        }
        Console.WriteLine("Copied list:");
        foreach (var color in copy)
        {
            Console.WriteLine(color);
        }
    }
}
Original list:
red
green
blue
Copied list:
red
green
blue
yellow

元のリストとコピーしたリストは別のインスタンスなので、コピー側に要素を追加しても元のリストには影響しません。

可変長引数メソッドと組み合わせる

メソッドの引数に可変長引数paramsを使うと、複数の要素を簡単に受け取り、それをList<T>に変換することができます。

これにより、呼び出し側は複数の値をカンマ区切りで渡すだけで済みます。

using System;
using System.Collections.Generic;
class Program
{
    static void PrintNumbers(params int[] numbers)
    {
        // 配列をListに変換
        List<int> numberList = new List<int>(numbers);
        foreach (var num in numberList)
        {
            Console.WriteLine(num);
        }
    }
    static void Main()
    {
        PrintNumbers(5, 10, 15, 20);
    }
}
5
10
15
20

このパターンは、メソッドの引数として複数の値を受け取りつつ、内部でList<T>として扱いたい場合に便利です。

paramsを使うことで呼び出し側のコードもシンプルになります。

要素の追加操作

Add

Addメソッドは、List<T>の末尾に単一の要素を追加するときに使います。

非常にシンプルでよく使われるメソッドです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string>();
        fruits.Add("apple");  // 要素を追加
        fruits.Add("banana");
        fruits.Add("cherry");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana
cherry

Addは内部の容量が足りない場合、自動的に容量を拡張してから要素を追加します。

単純な要素追加に最適です。

AddRange

AddRangeは、複数の要素を一度にリストの末尾に追加するときに使います。

引数にはIEnumerable<T>を受け取るため、配列や他のリストなども渡せます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3 };
        int[] moreNumbers = { 4, 5, 6 };
        numbers.AddRange(moreNumbers);  // 配列の要素を一括追加
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4
5
6

AddRangeは複数の要素をまとめて追加するため、Addを複数回呼ぶより効率的です。

Insert

Insertは、指定したインデックス位置に単一の要素を挿入します。

挿入位置以降の要素は一つずつ後ろにずれます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> colors = new List<string> { "red", "blue", "green" };
        colors.Insert(1, "yellow");  // インデックス1に"yellow"を挿入
        foreach (var color in colors)
        {
            Console.WriteLine(color);
        }
    }
}
red
yellow
blue
green

インデックスは0から始まるため、Insert(1, ...)は2番目の位置に挿入します。

範囲外のインデックスを指定すると例外が発生するので注意してください。

InsertRange

InsertRangeは、指定したインデックス位置に複数の要素を一括で挿入します。

引数にはIEnumerable<T>を受け取ります。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> list = new List<int> { 1, 4, 5 };
        List<int> toInsert = new List<int> { 2, 3 };
        list.InsertRange(1, toInsert);  // インデックス1に複数要素を挿入
        foreach (var num in list)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4
5

InsertRangeは複数の要素をまとめて挿入できるため、Insertを繰り返すより効率的です。

Capacity拡張の仕組み

List<T>は内部的に配列を使って要素を管理しています。

Capacityはその配列のサイズを表し、Countは実際に格納されている要素数です。

要素を追加するとき、CountCapacityに達している場合は、内部配列の容量を自動的に拡張します。

拡張の仕組みは以下のようになっています。

  • 新しい容量は通常、現在の容量の約2倍に設定されます。これにより、頻繁な再割り当てを防ぎ、パフォーマンスを向上させます
  • 新しい配列が確保され、既存の要素がコピーされます
  • 追加の要素は新しい配列に格納されます

この動作は自動で行われるため、開発者が意識する必要はほとんどありませんが、大量の要素を追加する場合は、あらかじめCapacityを設定しておくと効率的です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>(2);  // 初期容量2
        Console.WriteLine($"Initial Capacity: {numbers.Capacity}");
        numbers.Add(1);
        numbers.Add(2);
        Console.WriteLine($"Capacity after adding 2 elements: {numbers.Capacity}");
        numbers.Add(3);  // ここで容量拡張が発生
        Console.WriteLine($"Capacity after adding 3rd element: {numbers.Capacity}");
    }
}
Initial Capacity: 2
Capacity after adding 2 elements: 2
Capacity after adding 3rd element: 4

この例では、初期容量2のリストに3つ目の要素を追加した際に容量が4に拡張されていることがわかります。

容量拡張はコストがかかるため、パフォーマンスを意識する場合は事前に容量を設定することを検討してください。

要素の取得とアクセス

インデクサとIndexOf

List<T>では、インデクサ[]を使って特定のインデックスにある要素を簡単に取得・設定できます。

インデクサは配列と同じように使えるため、直感的に操作可能です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        // インデクサで要素を取得
        string firstFruit = fruits[0];
        Console.WriteLine($"1番目の果物: {firstFruit}");
        // インデクサで要素を更新
        fruits[1] = "blueberry";
        Console.WriteLine("更新後のリスト:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
1番目の果物: apple
更新後のリスト:
apple
blueberry
cherry

また、IndexOfメソッドは指定した要素がリスト内のどの位置にあるかを返します。

見つからない場合は-1を返します。

int index = fruits.IndexOf("cherry");
Console.WriteLine($"cherryのインデックス: {index}");
cherryのインデックス: 2

IndexOfは最初に見つかった要素の位置を返すため、重複がある場合は最初の位置が対象です。

Find系メソッド

List<T>には条件に合う要素を検索するための便利なFind系メソッドが用意されています。

代表的なものを紹介します。

  • Find(Predicate<T> match)

条件に合う最初の要素を返します。

見つからなければdefault(T)(参照型ならnull)を返します。

  • FindAll(Predicate<T> match)

条件に合うすべての要素をリストで返します。

  • FindIndex(Predicate<T> match)

条件に合う最初の要素のインデックスを返します。

見つからなければ-1

  • FindLast(Predicate<T> match)

条件に合う最後の要素を返します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 10, 15, 20, 25, 30 };
        // 20以上の最初の要素を取得
        int firstOver20 = numbers.Find(n => n >= 20);
        Console.WriteLine($"20以上の最初の数: {firstOver20}");
        // 20以上のすべての要素を取得
        List<int> allOver20 = numbers.FindAll(n => n >= 20);
        Console.WriteLine("20以上のすべての数:");
        foreach (var num in allOver20)
        {
            Console.WriteLine(num);
        }
        // 20以上の最初の要素のインデックス
        int index = numbers.FindIndex(n => n >= 20);
        Console.WriteLine($"20以上の最初の数のインデックス: {index}");
    }
}
20以上の最初の数: 20
20以上のすべての数:
20
25
30
20以上の最初の数のインデックス: 2

このように、Find系メソッドは条件に合う要素を効率的に取得できます。

LINQによる抽出

LINQ(Language Integrated Query)を使うと、List<T>から条件に合う要素を柔軟に抽出できます。

Whereメソッドで条件を指定し、ToListで結果をリストに変換するのが一般的です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 5, 10, 15, 20, 25, 30 };
        // 15以上の要素を抽出
        var filtered = numbers.Where(n => n >= 15).ToList();
        Console.WriteLine("15以上の数:");
        foreach (var num in filtered)
        {
            Console.WriteLine(num);
        }
    }
}
15以上の数:
15
20
25
30

LINQはWhere以外にもFirstFirstOrDefaultSingleOrderByなど多彩なメソッドがあり、複雑な条件検索や並べ替えも簡単に行えます。

// 最初の20以上の要素を取得(存在しなければ例外)
int first20 = numbers.First(n => n >= 20);
// 最初の20以上の要素を取得(存在しなければ0)
int firstOrDefault20 = numbers.FirstOrDefault(n => n >= 20);

LINQを使うことで、コードがより宣言的で読みやすくなり、複雑な検索処理も簡潔に書けます。

要素の更新と置換

インデクサによる直接代入

List<T>の要素はインデクサ[]を使って直接アクセスできるため、特定の位置にある要素を簡単に更新できます。

インデクサは配列と同様に使えるため、直感的に操作可能です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        // インデクサを使って2番目の要素を更新
        fruits[1] = "blueberry";
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
blueberry
cherry

このように、インデクサを使うと特定のインデックスの要素を簡単に置き換えられます。

範囲外のインデックスを指定すると例外が発生するため、アクセス前にCountを確認するか、例外処理を行うことが望ましいです。

ConvertAllによる一括変換

ConvertAllメソッドは、List<T>のすべての要素に対して指定した変換処理を適用し、新しい型のリストを返します。

元のリストは変更されず、新しいリストが生成される点に注意してください。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
        // 各要素を文字列に変換して新しいList<string>を作成
        List<string> stringNumbers = numbers.ConvertAll(n => $"Number: {n}");
        foreach (var str in stringNumbers)
        {
            Console.WriteLine(str);
        }
    }
}
Number: 1
Number: 2
Number: 3
Number: 4
Number: 5

ConvertAllは、要素の型を変換したい場合や、要素の内容を一括で加工したい場合に便利です。

元のリストはそのまま残るため、必要に応じて置き換えたり、新しいリストとして使い分けたりできます。

もし元のリストの要素を直接更新したい場合は、forループやforeachでインデクサを使って個別に代入する方法が一般的です。

for (int i = 0; i < numbers.Count; i++)
{
    numbers[i] = numbers[i] * 2;  // 各要素を2倍に更新
}

このように、ConvertAllは新しいリストを作る変換処理に適し、インデクサは既存のリストの要素を直接更新するのに適しています。

用途に応じて使い分けてください。

要素の削除操作

Remove

Removeメソッドは、リスト内で最初に見つかった指定した要素を削除します。

削除に成功するとtrueを返し、要素が見つからなければfalseを返します。

参照型の場合はEqualsメソッドで比較されます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry", "banana" };
        bool removed = fruits.Remove("banana");  // 最初の"banana"を削除
        Console.WriteLine($"削除成功: {removed}");
        Console.WriteLine("現在のリスト:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
削除成功: True
現在のリスト:
apple
cherry
banana

複数同じ要素がある場合は最初に見つかった1つだけが削除されます。

すべて削除したい場合はループやRemoveAllを使います。

RemoveAt

RemoveAtは、指定したインデックスの要素を削除します。

削除後は後ろの要素が前に詰められ、リストのサイズが1つ減ります。

インデックスが範囲外の場合は例外が発生します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 10, 20, 30, 40 };
        numbers.RemoveAt(2);  // インデックス2の要素(30)を削除
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
10
20
40

インデックス指定で確実に特定の位置の要素を削除したいときに使います。

RemoveRange

RemoveRangeは、指定した開始インデックスから指定した数の要素を一括で削除します。

大量の連続した要素をまとめて削除したい場合に便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> letters = new List<string> { "a", "b", "c", "d", "e", "f" };
        letters.RemoveRange(2, 3);  // インデックス2から3つの要素("c", "d", "e")を削除
        foreach (var letter in letters)
        {
            Console.WriteLine(letter);
        }
    }
}
a
b
f

範囲外のインデックスや数を指定すると例外が発生するため、事前にCountを確認してください。

Clear

Clearメソッドは、リスト内のすべての要素を削除し、Countを0にします。

内部の容量Capacityは変わらず保持されるため、再利用時のパフォーマンスに影響します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
        numbers.Clear();  // すべての要素を削除
        Console.WriteLine($"要素数: {numbers.Count}");
    }
}
要素数: 0

リストを空にして再利用したい場合に使います。

TrimExcessでメモリを解放

TrimExcessメソッドは、リストの内部配列の容量Capacityを現在の要素数Countに近いサイズに縮小します。

これにより、不要なメモリを解放してメモリ使用量を削減できます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>(1000);  // 大きな容量で作成
        for (int i = 0; i < 10; i++)
        {
            numbers.Add(i);
        }
        Console.WriteLine($"容量(容量拡張後): {numbers.Capacity}");
        Console.WriteLine($"要素数: {numbers.Count}");
        numbers.TrimExcess();  // 容量を要素数に近づける
        Console.WriteLine($"TrimExcess後の容量: {numbers.Capacity}");
    }
}
容量(容量拡張後): 1000
要素数: 10
TrimExcess後の容量: 10

TrimExcessは大量の要素を削除した後や、リストのサイズが大幅に減ったときに使うと効果的です。

ただし、頻繁に呼ぶと逆にパフォーマンスが悪化するため、必要なタイミングで使うのが望ましいです。

反復処理

foreachとforの使い分け

List<T>の要素を順番に処理するとき、主にforeachループとforループの2つの方法があります。

それぞれの特徴と使い分けのポイントを解説します。

foreachループ

foreachはコレクションの全要素を簡潔に繰り返し処理できる構文です。

コードが読みやすく、要素の追加や削除を行わない場合に適しています。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana
cherry
  • メリット
    • コードがシンプルで読みやすい
    • インデックスを意識せずに済む
    • コレクションの全要素を安全に処理できる
  • 注意点
    • ループ中にリストの要素を追加・削除すると例外が発生する
    • インデックスが必要な場合は使いにくい

forループ

forループはインデックスを使って要素にアクセスします。

要素の更新や削除、特定の範囲だけ処理したい場合に便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 10, 20, 30, 40 };
        for (int i = 0; i < numbers.Count; i++)
        {
            Console.WriteLine($"Index {i}: {numbers[i]}");
        }
    }
}
Index 0: 10
Index 1: 20
Index 2: 30
Index 3: 40
  • メリット
    • インデックスを使って要素の位置を把握できる
    • ループ中に要素の更新や削除がしやすい(ただし削除は注意が必要)
    • 範囲を限定して処理できる
  • 注意点
    • コードがやや冗長になることがある
    • インデックス管理を誤ると例外が発生しやすい

使い分けのポイント

シナリオ推奨されるループ
単純に全要素を読み取り処理するforeach
インデックスが必要な処理for
ループ中に要素の追加・削除を行うfor(慎重に実装)
パフォーマンスを最大化したいfor(インデックスアクセスが高速)

Enumeratorの動作

foreachループの内部では、List<T>Enumerator(列挙子)が使われています。

Enumeratorはコレクションの要素を順に走査するためのオブジェクトで、IEnumerator<T>インターフェースを実装しています。

Enumeratorの基本的な動作

  • MoveNext()メソッドで次の要素に進む
  • Currentプロパティで現在の要素を取得
  • コレクションの終端に達するとMoveNext()falseを返す
  • Dispose()でリソースを解放(foreachで自動的に呼ばれる)

Enumeratorの利用例(foreachの展開)

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        var enumerator = fruits.GetEnumerator();
        while (enumerator.MoveNext())
        {
            string fruit = enumerator.Current;
            Console.WriteLine(fruit);
        }
        enumerator.Dispose();
    }
}
apple
banana
cherry

このコードはforeachの動作と同じですが、明示的にEnumeratorを操作しています。

Enumeratorの注意点

  • コレクションが変更されるとEnumeratorは無効になり、InvalidOperationExceptionが発生する
  • foreachはこの例外を防ぐために安全に使えるよう設計されている
  • Enumeratorは一方向の読み取り専用で、要素の追加や削除はできない

Enumeratorの仕組みを理解すると、foreachの動作やコレクションの安全な反復処理の仕組みがよくわかります。

通常はforeachを使い、特殊なケースで直接Enumeratorを操作することが多いです。

並べ替えと検索

Sort

List<T>Sortメソッドは、リストの要素を昇順に並べ替えます。

並べ替えの方法は複数あり、用途に応じて使い分けられます。

Comparisonデリゲート利用

SortメソッドにComparison<T>デリゲートを渡すことで、独自の比較ロジックを指定できます。

Comparison<T>は2つの要素を比較し、大小関係を示す整数を返すメソッドです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "banana", "apple", "cherry" };
        // 文字列の長さでソート(短い順)
        fruits.Sort((x, y) => x.Length.CompareTo(y.Length));
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana
cherry

この例では、Sortにラムダ式を渡して文字列の長さで比較しています。

CompareToは標準的な比較メソッドで、負の値は左が小さい、0は等しい、正の値は左が大きいことを示します。

IComparer実装利用

IComparer<T>インターフェースを実装したクラスを作成し、Sortに渡す方法もあります。

複雑な比較ロジックや再利用性を考慮するときに便利です。

using System;
using System.Collections.Generic;
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class AgeComparer : IComparer<Person>
{
    public int Compare(Person x, Person y)
    {
        return x.Age.CompareTo(y.Age);
    }
}
class Program
{
    static void Main()
    {
        List<Person> people = new List<Person>
        {
            new Person { Name = "Alice", Age = 30 },
            new Person { Name = "Bob", Age = 25 },
            new Person { Name = "Charlie", Age = 35 }
        };
        people.Sort(new AgeComparer());
        foreach (var person in people)
        {
            Console.WriteLine($"{person.Name}: {person.Age}");
        }
    }
}
Bob: 25
Alice: 30
Charlie: 35

IComparer<T>を使うと、比較ロジックをクラスとして分離でき、複数のソート基準を切り替えやすくなります。

BinarySearch

BinarySearchメソッドは、ソート済みのリストに対して高速に要素を検索します。

見つかった場合はその要素のインデックスを返し、見つからなければ負の値を返します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 10, 20, 30, 40, 50 };
        numbers.Sort();  // 事前にソートが必要
        int index = numbers.BinarySearch(30);
        Console.WriteLine($"30のインデックス: {index}");
        int notFoundIndex = numbers.BinarySearch(35);
        Console.WriteLine($"35の検索結果: {notFoundIndex}");
    }
}
30のインデックス: 2
35の検索結果: -4

負の値は、挿入すべき位置のビット反転~indexを示します。

例えば-4なら~(-4) = 3で、インデックス3に挿入すれば順序が保たれます。

Reverse

Reverseメソッドは、リストの要素の順序を反転させます。

昇順にソートしたリストを降順にしたい場合などに使います。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        fruits.Reverse();
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
cherry
banana
apple

Reverseはリスト全体の順序を反転させるほか、引数で範囲を指定して部分的に反転させることも可能です。

fruits.Reverse(0, 2);  // インデックス0から2つの要素を反転

このように、SortBinarySearchReverseを組み合わせることで、リストの並べ替えや検索を効率的に行えます。

キャパシティ管理

CapacityとCountの違い

List<T>にはCapacityCountという2つの重要なプロパティがありますが、それぞれ役割が異なります。

  • Count

リストに現在格納されている要素の数を表します。

実際にアクセス可能な要素数であり、foreachやインデクサでアクセスできる範囲です。

  • Capacity

リストが内部的に確保している配列のサイズを示します。

CapacityCount以上の値を持ち、要素の追加に備えて余裕を持った領域が確保されています。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>(10);  // 初期容量10
        Console.WriteLine($"初期 Capacity: {numbers.Capacity}");  // 10
        Console.WriteLine($"初期 Count: {numbers.Count}");        // 0
        numbers.Add(1);
        numbers.Add(2);
        Console.WriteLine($"追加後 Capacity: {numbers.Capacity}"); // 10
        Console.WriteLine($"追加後 Count: {numbers.Count}");       // 2
    }
}
初期 Capacity: 10
初期 Count: 0
追加後 Capacity: 10
追加後 Count: 2

Capacityは内部配列のサイズであり、Countは実際の要素数です。

CountCapacityに達すると、List<T>は自動的に容量を拡張します。

明示的な容量設定

大量の要素を追加する場合、あらかじめCapacityを設定しておくと、内部配列の再割り当て(コピー)を減らせるためパフォーマンスが向上します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>();
        numbers.Capacity = 1000;  // 容量を明示的に設定
        for (int i = 0; i < 1000; i++)
        {
            numbers.Add(i);
        }
        Console.WriteLine($"Capacity: {numbers.Capacity}");
        Console.WriteLine($"Count: {numbers.Count}");
    }
}
Capacity: 1000
Count: 1000

容量を指定しない場合、要素追加時に容量が足りなくなるたびに内部配列が倍増し、コピーコストが発生します。

事前に容量を設定することでこれを回避できます。

EnsureCapacity

EnsureCapacityメソッドは、指定した容量以上を確保するように内部配列のサイズを調整します。

現在の容量が指定値以上であれば何もしません。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>();
        Console.WriteLine($"初期 Capacity: {numbers.Capacity}");
        numbers.EnsureCapacity(500);
        Console.WriteLine($"EnsureCapacity(500)後 Capacity: {numbers.Capacity}");
    }
}
初期 Capacity: 0
EnsureCapacity(500)後 Capacity: 500

EnsureCapacityは大量データの追加前に呼び出すことで、容量不足による再割り当てを防ぎ、パフォーマンスを安定させるのに役立ちます。

TrimExcess

TrimExcessメソッドは、CapacityCountに近いサイズに縮小し、不要なメモリを解放します。

大量の要素を削除した後などに使うと効果的です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int>(1000);
        for (int i = 0; i < 10; i++)
        {
            numbers.Add(i);
        }
        Console.WriteLine($"容量(追加後): {numbers.Capacity}");
        Console.WriteLine($"要素数: {numbers.Count}");
        numbers.TrimExcess();
        Console.WriteLine($"TrimExcess後の容量: {numbers.Capacity}");
    }
}
容量(追加後): 1000
要素数: 10
TrimExcess後の容量: 10

ただし、頻繁にTrimExcessを呼ぶと逆にパフォーマンスが悪化するため、必要なタイミングでのみ使うのが望ましいです。

大量データでのベンチマーク考察

大量のデータを扱う場合、容量管理がパフォーマンスに大きく影響します。

以下のポイントに注意してください。

  • 容量不足による再割り当てのコスト

List<T>は容量が足りなくなると内部配列を倍増させてコピーします。

このコピー処理はコストが高いため、頻繁に発生するとパフォーマンスが低下します。

  • 事前に容量を設定するメリット

大量の要素を追加する前にCapacityを設定したり、EnsureCapacityを呼ぶことで再割り当て回数を減らせます。

  • メモリ使用量のバランス

大きな容量を確保しすぎるとメモリを無駄に消費します。

逆に小さすぎると頻繁に拡張が発生します。

適切な容量設定が重要です。

  • TrimExcessの活用

大量の要素を削除した後はTrimExcessで容量を縮小し、メモリを節約できます。

実際のベンチマークでは、容量を適切に管理した場合としなかった場合で、要素追加の速度に数倍の差が出ることもあります。

大量データを扱うアプリケーションでは、容量管理を意識した実装がパフォーマンス向上に直結します。

変換とコピー

ToArray

ToArrayメソッドは、List<T>の要素を新しい配列にコピーして返します。

リストの内容を固定長の配列として扱いたい場合に便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        string[] fruitArray = fruits.ToArray();
        foreach (var fruit in fruitArray)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana
cherry

配列はサイズが固定なので、ToArrayで変換後は要素の追加や削除はできません。

読み取り専用や外部APIとの連携に使われることが多いです。

AsReadOnly

AsReadOnlyメソッドは、List<T>の読み取り専用ラッパーを返します。

元のリストの内容は変更可能ですが、ラッパーを通じては変更できません。

安全に外部にリストを公開したい場合に使います。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3 };
        var readOnlyNumbers = numbers.AsReadOnly();
        Console.WriteLine("読み取り専用リストの要素:");
        foreach (var num in readOnlyNumbers)
        {
            Console.WriteLine(num);
        }
        // readOnlyNumbers.Add(4); // コンパイルエラー: 変更不可
        numbers.Add(4);  // 元のリストは変更可能
        Console.WriteLine("元のリスト変更後の読み取り専用リスト:");
        foreach (var num in readOnlyNumbers)
        {
            Console.WriteLine(num);
        }
    }
}
読み取り専用リストの要素:
1
2
3
元のリスト変更後の読み取り専用リスト:
1
2
3
4

AsReadOnlyは元のリストの変更を反映しますが、読み取り専用のインターフェースを提供するため、誤って変更されるリスクを減らせます。

GetRange

GetRangeメソッドは、指定した開始インデックスから指定した数の要素を抜き出して新しいList<T>として返します。

部分的なコピーを作成したいときに便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<string> letters = new List<string> { "a", "b", "c", "d", "e" };
        List<string> subList = letters.GetRange(1, 3);  // インデックス1から3つの要素を取得
        foreach (var letter in subList)
        {
            Console.WriteLine(letter);
        }
    }
}
b
c
d

元のリストとは別のインスタンスなので、subListの変更は元のリストに影響しません。

Selectによる射影

LINQのSelectメソッドを使うと、List<T>の各要素を別の型や形に変換(射影)して新しいコレクションを作成できます。

Selectは遅延実行されるため、必要に応じてToListなどで評価します。

using System;
using System.Collections.Generic;
using System.Linq;
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        List<Person> people = new List<Person>
        {
            new Person { Name = "Alice", Age = 30 },
            new Person { Name = "Bob", Age = 25 }
        };
        // 名前だけのリストを作成
        List<string> names = people.Select(p => p.Name).ToList();
        foreach (var name in names)
        {
            Console.WriteLine(name);
        }
    }
}
Alice
Bob

Selectは要素の一部だけを取り出したり、別の型に変換したりするのに非常に便利です。

複雑な変換もラムダ式で簡潔に記述できます。

スレッドセーフティ

同期アクセスの課題

List<T>はスレッドセーフではありません。

複数のスレッドから同時に読み書き操作を行うと、データの競合や不整合、例外の発生などの問題が起こります。

例えば、あるスレッドがリストに要素を追加している最中に、別のスレッドが同じリストを読み取ろうとすると、内部状態が不安定になり、InvalidOperationExceptionやデータ破損が発生する可能性があります。

このため、マルチスレッド環境でList<T>を安全に使うには、アクセスを同期化する必要があります。

同期化を怠ると、予期しない動作やクラッシュの原因となるため注意が必要です。

CopyOnWriteパターン

スレッドセーフなコレクションの設計の一つに「CopyOnWrite(コピー・オン・ライト)」パターンがあります。

これは、読み取り操作はロックなしで高速に行い、書き込み操作が発生した際に内部データのコピーを作成して変更を加える方法です。

このパターンの特徴は以下の通りです。

  • 読み取りはロック不要

読み取りは常に安定した状態のデータを参照するため、ロックを使わず高速にアクセスできます。

  • 書き込み時にコピーを作成

書き込みが発生すると、内部データのコピーを作成し、そのコピーに変更を加えます。

これにより、他のスレッドの読み取りに影響を与えない。

  • メモリコストが増加

書き込み時にコピーを作成するため、メモリ使用量が増え、書き込み頻度が高い場合はパフォーマンスに影響が出ります。

.NETではSystem.Collections.Immutable名前空間のイミュータブルコレクションがこのパターンを採用しており、スレッドセーフなコレクションとして利用可能です。

lockとConcurrentBagとの比較

マルチスレッド環境でList<T>を使う場合、最も単純な同期方法はlockを使ってアクセスを排他制御することです。

private readonly object _lock = new object();
private List<int> _list = new List<int>();
public void AddItem(int item)
{
    lock (_lock)
    {
        _list.Add(item);
    }
}
public int GetItem(int index)
{
    lock (_lock)
    {
        return _list[index];
    }
}
  • メリット
    • 実装が簡単で既存のList<T>をそのまま使える
    • 複雑な同期処理を自分で制御可能
  • デメリット
    • ロックの競合が発生するとスレッドが待機し、パフォーマンスが低下する
    • デッドロックやロックの粒度管理に注意が必要

一方、ConcurrentBag<T>は.NET標準のスレッドセーフなコレクションで、内部で高度な同期機構を持ち、複数スレッドからの同時アクセスに強い設計です。

  • メリット
    • ロックフリーまたは低ロックで高い並列性能を発揮
    • スレッド間で安全に要素の追加・取得が可能
  • デメリット
    • 順序保証がないため、順序が重要な場合には不向き
    • List<T>のようなインデックスアクセスはできない

まとめると、順序やインデックスアクセスが必要であればList<T>lockをかけて使うのが一般的です。

順序を気にせず高速な並列処理が必要な場合はConcurrentBag<T>などの専用コレクションを使うとよいでしょう。

用途に応じて適切な同期方法やコレクションを選択してください。

イミュータブルに扱うテクニック

ReadOnlyCollection

ReadOnlyCollection<T>は、既存のList<T>などのコレクションを読み取り専用としてラップするクラスです。

これにより、外部からの変更を防ぎつつ、元のコレクションの内容を安全に公開できます。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        // Listを読み取り専用コレクションに変換
        ReadOnlyCollection<string> readOnlyFruits = new ReadOnlyCollection<string>(fruits);
        Console.WriteLine("読み取り専用コレクションの要素:");
        foreach (var fruit in readOnlyFruits)
        {
            Console.WriteLine(fruit);
        }
        // readOnlyFruits.Add("date"); // コンパイルエラー: 変更不可
        // 元のリストは変更可能
        fruits.Add("date");
        Console.WriteLine("元のリスト変更後の読み取り専用コレクション:");
        foreach (var fruit in readOnlyFruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
読み取り専用コレクションの要素:
apple
banana
cherry
元のリスト変更後の読み取り専用コレクション:
apple
banana
cherry
date

ReadOnlyCollection<T>は元のコレクションのラッパーであり、元のコレクションが変更されると読み取り専用コレクションにも反映されます。

完全なイミュータブルではありませんが、外部からの不意な変更を防ぐのに役立ちます。

System.Collections.Immutable

.NETのSystem.Collections.Immutable名前空間には、真のイミュータブルコレクションが提供されています。

これらは一度作成すると変更できず、変更操作は新しいコレクションを返す形で行われます。

代表的なイミュータブルコレクションにはImmutableList<T>があります。

using System;
using System.Collections.Immutable;
class Program
{
    static void Main()
    {
        // 空のイミュータブルリストを作成
        ImmutableList<string> fruits = ImmutableList<string>.Empty;
        // 要素を追加すると新しいリストが返る
        fruits = fruits.Add("apple");
        fruits = fruits.Add("banana");
        fruits = fruits.Add("cherry");
        Console.WriteLine("イミュータブルリストの要素:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
        // 元のリストは変更されない
        var newFruits = fruits.Add("date");
        Console.WriteLine("元のリスト:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
        Console.WriteLine("新しいリスト:");
        foreach (var fruit in newFruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
イミュータブルリストの要素:
apple
banana
cherry
元のリスト:
apple
banana
cherry
新しいリスト:
apple
banana
cherry
date

イミュータブルコレクションはスレッドセーフであり、複数スレッドからの同時アクセスでも安全です。

また、状態の変更が新しいインスタンスとして表現されるため、副作用を避けたい設計に適しています。

ただし、変更操作ごとに新しいインスタンスが生成されるため、頻繁な変更がある場合はパフォーマンスやメモリ使用量に注意が必要です。

System.Collections.ImmutableはNuGetパッケージとして提供されているため、利用する際はプロジェクトに追加してください。

これらのテクニックを使い分けることで、List<T>をイミュータブルに近い形で扱ったり、真のイミュータブルコレクションを利用したりできます。

用途や設計方針に応じて適切な方法を選択してください。

ネストされたList

2次元リストの作成

C#のList<T>はジェネリックコレクションなので、List<List<T>>のようにリストの中にリストを格納することで多次元リストを表現できます。

特に2次元リストは、行と列のような構造を扱う際に便利です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 3x3の2次元リストを作成
        List<List<int>> matrix = new List<List<int>>();
        for (int i = 0; i < 3; i++)
        {
            List<int> row = new List<int>();
            for (int j = 0; j < 3; j++)
            {
                row.Add(i * 3 + j);
            }
            matrix.Add(row);
        }
        // 2次元リストの要素を表示
        for (int i = 0; i < matrix.Count; i++)
        {
            for (int j = 0; j < matrix[i].Count; j++)
            {
                Console.Write(matrix[i][j] + " ");
            }
            Console.WriteLine();
        }
    }
}
0 1 2
3 4 5
6 7 8

この例では、3行3列の整数の2次元リストを作成し、各要素に連番を設定しています。

matrix[i][j]で行i、列jの要素にアクセスできます。

初期化とアクセスの注意点

2次元リストを扱う際には、以下の点に注意が必要です。

  • 各行のリストは独立している

List<List<T>>はリストのリストなので、各行は独立したList<T>のインスタンスです。

行ごとに異なる長さのリストを持つことも可能です(ジャグ配列のような構造)。

  • 初期化時に各行のリストを必ず作成する

外側のリストだけ作っても内側のリストがnullの場合があるため、必ず内側のリストも初期化してください。

  • アクセス時の範囲チェック

行数や列数が異なる場合、アクセス時にIndexOutOfRangeExceptionが発生しやすいので、Countを使って範囲を確認することが重要です。

  • 多次元配列との違い

List<List<T>>は多次元配列T[,]とは異なり、各行の長さが異なっても問題ありません。

柔軟性がありますが、パフォーマンス面では多次元配列の方が優れる場合があります。

// 例: ジャグ配列のように行ごとに異なる長さのリストを作成
List<List<int>> jaggedList = new List<List<int>>
{
    new List<int> { 1, 2, 3 },
    new List<int> { 4, 5 },
    new List<int> { 6, 7, 8, 9 }
};
  • 要素の追加・削除

内側のリストに対してAddRemoveを行うことで、行ごとに要素数を動的に変更できます。

  • パフォーマンス

ネストされたリストは柔軟ですが、アクセス時に二重のインデクサ呼び出しが発生するため、多数の要素を高速に処理したい場合は多次元配列や他のデータ構造を検討してください。

これらのポイントを踏まえて、2次元リストを適切に初期化・操作することで、柔軟かつ安全に多次元データを扱えます。

カスタムクラスのList

EqualsとGetHashCodeの実装

List<T>でカスタムクラスの要素を扱う際、RemoveContainsIndexOfなどのメソッドは要素の等価性を判断するためにEqualsメソッドを使います。

また、ハッシュベースのコレクションと連携する場合はGetHashCodeも重要です。

デフォルトでは、クラスのEqualsは参照の同一性を比較するため、内容が同じでも異なるインスタンスは等しくないと判断されます。

これを防ぐために、カスタムクラスではEqualsGetHashCodeをオーバーライドして、論理的な等価性を定義することが推奨されます。

using System;
using System.Collections.Generic;
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public override bool Equals(object obj)
    {
        if (obj is Person other)
        {
            return Name == other.Name && Age == other.Age;
        }
        return false;
    }
    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Age);
    }
}
class Program
{
    static void Main()
    {
        List<Person> people = new List<Person>
        {
            new Person { Name = "Alice", Age = 30 },
            new Person { Name = "Bob", Age = 25 }
        };
        var target = new Person { Name = "Alice", Age = 30 };
        Console.WriteLine(people.Contains(target));  // true
        people.Remove(target);
        Console.WriteLine(people.Count);              // 1
    }
}
True
1

このようにEqualsGetHashCodeを適切に実装すると、内容が同じ別インスタンスでもリストの操作が正しく動作します。

IComparableでSortを有効にする

List<T>Sortメソッドは、要素の比較にIComparable<T>インターフェースを利用します。

カスタムクラスでソートを行いたい場合は、このインターフェースを実装して比較ロジックを定義すると便利です。

using System;
using System.Collections.Generic;
class Person : IComparable<Person>
{
    public string Name { get; set; }
    public int Age { get; set; }
    public int CompareTo(Person other)
    {
        if (other == null) return 1;
        return Age.CompareTo(other.Age);  // 年齢で昇順ソート
    }
}
class Program
{
    static void Main()
    {
        List<Person> people = new List<Person>
        {
            new Person { Name = "Charlie", Age = 35 },
            new Person { Name = "Alice", Age = 30 },
            new Person { Name = "Bob", Age = 25 }
        };
        people.Sort();
        foreach (var person in people)
        {
            Console.WriteLine($"{person.Name}: {person.Age}");
        }
    }
}
Bob: 25
Alice: 30
Charlie: 35

IComparable<T>を実装すると、Sortを呼ぶだけで自然な順序付けが可能になります。

複数の基準でソートしたい場合はIComparer<T>を別途実装する方法もあります。

レコード型との相性

C# 9.0以降で導入されたレコード型は、値の等価性を自動的に実装し、イミュータブルなデータ構造を簡単に作成できます。

List<T>でカスタムクラスの代わりにレコード型を使うと、EqualsGetHashCodeの実装を自分で書く必要がなくなり、コードがシンプルになります。

using System;
using System.Collections.Generic;
record Person(string Name, int Age);
class Program
{
    static void Main()
    {
        List<Person> people = new List<Person>
        {
            new Person("Alice", 30),
            new Person("Bob", 25)
        };
        var target = new Person("Alice", 30);
        Console.WriteLine(people.Contains(target));  // true
        people.Remove(target);
        Console.WriteLine(people.Count);              // 1
    }
}
True
1

レコード型はIComparableは自動実装しないため、ソートには別途IComparableIComparerの実装が必要ですが、等価性の扱いが簡単になるため、List<T>での利用に非常に適しています。

まとめると、カスタムクラスのList<T>を扱う際は、等価性の定義やソートのための比較ロジックを適切に実装することが重要です。

レコード型を活用すると、これらの作業が大幅に簡素化されます。

よくある落とし穴

foreach中のRemove

foreachループの中でList<T>の要素をRemoveしようとすると、InvalidOperationExceptionが発生します。

これは、foreachが内部でEnumeratorを使っているため、コレクションの構造が変更されると列挙が無効になるためです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
        try
        {
            foreach (var num in numbers)
            {
                if (num % 2 == 0)
                {
                    numbers.Remove(num);  // 例外発生
                }
            }
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("例外発生: " + ex.Message);
        }
    }
}
例外発生: Collection was modified; enumeration operation may not execute.

対策

  • 逆方向のforループを使う

インデックスを使い、後ろから前に向かって要素を削除すると安全です。

for (int i = numbers.Count - 1; i >= 0; i--)
{
    if (numbers[i] % 2 == 0)
    {
        numbers.RemoveAt(i);
    }
}
  • 削除対象を別リストにためてから一括削除

まず削除対象を収集し、ループ後にまとめて削除します。

  • RemoveAllメソッドを使う

条件に合う要素を一括削除できるので簡潔です。

numbers.RemoveAll(n => n % 2 == 0);

キャパシティ不足の頻発拡張

List<T>は内部配列の容量Capacityが足りなくなると、自動的に容量を拡張しますが、この拡張はコストが高い処理です。

頻繁に容量不足が起こると、パフォーマンスが大幅に低下します。

List<int> numbers = new List<int>();
for (int i = 0; i < 10000; i++)
{
    numbers.Add(i);  // 容量不足で何度も拡張が発生
}

対策

  • 初期容量を指定する

予想される要素数に合わせてList<T>のコンストラクターやCapacityプロパティで初期容量を設定します。

List<int> numbers = new List<int>(10000);
  • EnsureCapacityを使う

追加前に十分な容量を確保しておくことも可能です。

numbers.EnsureCapacity(10000);

これにより、容量拡張の回数を減らし、パフォーマンスを向上させられます。

リファレンス共有による副作用

List<T>が参照型のオブジェクトを格納している場合、同じオブジェクトの参照を複数のリストで共有すると、片方のリストでオブジェクトの状態を変更すると、もう片方にも影響が及びます。

これが意図しない副作用の原因になることがあります。

using System;
using System.Collections.Generic;
class Person
{
    public string Name { get; set; }
}
class Program
{
    static void Main()
    {
        Person p = new Person { Name = "Alice" };
        List<Person> list1 = new List<Person> { p };
        List<Person> list2 = new List<Person> { p };
        list1[0].Name = "Bob";
        Console.WriteLine(list2[0].Name);  // "Bob"と表示される
    }
}
Bob

対策

  • ディープコピーを行う

オブジェクトのコピーコンストラクターやクローンメソッドを使い、独立したオブジェクトを作成してリストに格納します。

  • イミュータブルなオブジェクトを使う

状態変更ができないオブジェクトを使うことで副作用を防げます。

  • 設計段階で参照共有を意識する

共有が必要な場合は明示的に管理し、不要な共有は避けるようにします。

これらの落とし穴を理解し、適切な対策を取ることで、List<T>を安全かつ効率的に利用できます。

パフォーマンス最適化ポイント

ボックス化の回避

C#のジェネリックコレクションであるList<T>は、値型structを扱う際にボックス化(boxing)が発生しないよう設計されています。

ボックス化とは、値型を参照型として扱うためにヒープ上にラップする処理で、パフォーマンス低下やメモリ消費増加の原因となります。

しかし、以下のようなケースでは意図せずボックス化が発生することがあります。

  • 非ジェネリックなインターフェースを通じた操作

例えば、IListICollectionなど非ジェネリックインターフェースを使うと、値型がオブジェクトに変換されてボックス化が起こります。

using System;
using System.Collections;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3 };
        // 非ジェネリックIListにキャストするとボックス化が発生
        IList list = numbers;
        list.Add(4);  // ここでintがobjectにボックス化される
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3
4
  • ジェネリック型パラメーターがobjectやインターフェース型の場合

値型をobjectやインターフェース型の変数に代入するとボックス化が発生します。

ボックス化を回避する方法

  • ジェネリック型を使う

可能な限りList<T>のようなジェネリックコレクションを使い、非ジェネリックインターフェースは避けます。

  • 値型を直接扱う

値型をobjectやインターフェース型にキャストしない。

  • 構造体にインターフェースを実装する場合の注意

インターフェース呼び出し時にボックス化が発生することがあるため、パフォーマンスが重要な場合は設計を見直します。

ボックス化は見えにくいパフォーマンス低下の原因となるため、特に大量データを扱う場合は注意が必要です。

Span<T>とメモリープール

Span<T>はC# 7.2以降で導入された構造体で、連続したメモリ領域を安全かつ効率的に扱うための型です。

Span<T>はヒープ割り当てを伴わず、スタック上のデータや配列の一部を参照できるため、パフォーマンス最適化に非常に有効です。

Span<T>の特徴

  • ヒープ割り当てなし

値型であるため、ガベージコレクションの負荷を減らせます。

  • 部分的な配列操作が可能

配列の一部を切り出して操作できるため、コピーを減らせます。

  • 安全なメモリアクセス

範囲外アクセスを防ぐ仕組みがあります。

List<T>との組み合わせ

List<T>の内部配列をSpan<T>で参照することで、コピーを伴わない高速な処理が可能です。

ただし、List<T>の内部配列は直接アクセスできないため、ToArrayで配列を取得してからSpan<T>を作成することが多いです。

using System;
class Program
{
    static void Main()
    {
        int[] array = { 1, 2, 3, 4, 5 };
        Span<int> span = new Span<int>(array, 1, 3);  // 配列の一部を参照
        for (int i = 0; i < span.Length; i++)
        {
            span[i] *= 2;  // 値を2倍に変更
        }
        foreach (var num in array)
        {
            Console.WriteLine(num);
        }
    }
}
1
4
6
8
5

メモリープールの活用

大量の配列やバッファを頻繁に生成・破棄すると、GC負荷が高まります。

ArrayPool<T>は使い回し可能な配列のプールを提供し、メモリ割り当てを削減します。

using System;
using System.Buffers;
class Program
{
    static void Main()
    {
        var pool = ArrayPool<int>.Shared;
        int[] buffer = pool.Rent(1000);  // 1000要素以上の配列を借りる
        // バッファを使用
        for (int i = 0; i < 1000; i++)
        {
            buffer[i] = i;
        }
        // 使用後は返却
        pool.Return(buffer);
        Console.WriteLine("ArrayPoolを使ったバッファ処理完了");
    }
}

ArrayPool<T>Span<T>を組み合わせることで、ヒープ割り当てを抑えつつ高速なメモリ操作が可能になります。

特に高頻度のバッファ操作やリアルタイム処理で効果的です。

これらのテクニックを活用することで、List<T>を含むコレクション操作のパフォーマンスを大幅に向上させることができます。

特に値型のボックス化回避やメモリ効率の良いSpan<T>、メモリープールの利用は、パフォーマンスクリティカルなアプリケーションで重要なポイントです。

C#バージョン別の進化

C# 3.0 コレクション初期化子

C# 3.0(2007年リリース)では、コレクション初期化子が導入され、List<T>などのコレクションを簡潔に初期化できるようになりました。

これにより、従来のAddメソッドを複数回呼び出すコードを、波括弧 {} を使ったリテラル風の記述に置き換えられます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // C# 2.0以前の初期化方法
        List<int> numbers1 = new List<int>();
        numbers1.Add(1);
        numbers1.Add(2);
        numbers1.Add(3);
        // C# 3.0以降のコレクション初期化子
        List<int> numbers2 = new List<int> { 1, 2, 3 };
        foreach (var num in numbers2)
        {
            Console.WriteLine(num);
        }
    }
}
1
2
3

コレクション初期化子は、コードの可読性を大幅に向上させ、初期値の設定を直感的に行えるようにしました。

特にテストデータや定数リストの作成で重宝されています。

C# 9.0 target-typed new

C# 9.0(2020年リリース)では、target-typed newという機能が追加され、変数の型が明確な場合にnew演算子の型指定を省略できるようになりました。

これにより、List<T>のインスタンス生成もより簡潔に書けます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 従来の書き方
        List<string> fruits1 = new List<string>();
        // C# 9.0以降のtarget-typed new
        List<string> fruits2 = new();
        fruits2.Add("apple");
        fruits2.Add("banana");
        foreach (var fruit in fruits2)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana

この機能は特に型名が長い場合や、ジェネリック型のインスタンス化でコードの冗長さを減らすのに役立ちます。

varと組み合わせることで、よりシンプルなコードが書けます。

今後の展望

C#は継続的に進化しており、コレクション操作に関してもさらなる改善が期待されています。

今後の展望としては以下のようなポイントが挙げられます。

  • パターンマッチングの強化

より柔軟で表現力豊かなパターンマッチングが追加され、コレクションの条件分岐や抽出が簡潔に書けるようになる可能性があります。

  • レコード型の拡張

イミュータブルなデータ構造としてのレコード型がさらに強化され、コレクションとの親和性が高まることで、安全で効率的なデータ操作が促進されます。

  • パフォーマンス最適化の継続

Span<T>Memory<T>のようなメモリ効率の良い型の活用が進み、コレクションの内部実装も高速化・低メモリ化が進むでしょう。

  • 新しいコレクション型の追加

より特化した用途に対応する新しいコレクション型や、並列処理に最適化されたコレクションの拡充が期待されます。

  • ソースジェネレーターの活用

コード生成によるボイラープレート削減や、型安全なコレクション操作の自動化が進む可能性があります。

これらの進化により、C#のコレクション操作はますます使いやすく、高性能で安全なものになっていくでしょう。

最新の言語機能やライブラリの動向を追いながら、適切な技術を取り入れていくことが重要です。

サンプルコード全体像

コンパクトなデモ

ここでは、List<T>の基本的な操作をまとめたシンプルなデモコードを示します。

要素の追加、取得、更新、削除、並べ替えまで一通りの流れをコンパクトに体験できます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // Listの作成と初期化
        List<string> fruits = new List<string> { "apple", "banana", "cherry" };
        // 要素の追加
        fruits.Add("date");
        // 要素の取得と表示
        Console.WriteLine("現在のフルーツリスト:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
        // 要素の更新
        fruits[1] = "blueberry";
        // 要素の削除
        fruits.Remove("cherry");
        // 並べ替え
        fruits.Sort();
        Console.WriteLine("\n更新後のフルーツリスト:");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
現在のフルーツリスト:
apple
banana
cherry
date
更新後のフルーツリスト:
apple
blueberry
date

このデモでは、List<T>の基本操作を短いコードで確認できます。

初期化子での作成、Addでの追加、インデクサでの更新、Removeでの削除、Sortでの並べ替えを順に実行しています。

実用的なシナリオ

次に、実際のアプリケーションでよくあるシナリオを想定したサンプルコードを紹介します。

ここでは、カスタムクラスのリストを使い、検索やフィルタリング、並べ替えを行う例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        // Personオブジェクトのリストを作成
        List<Person> people = new List<Person>
        {
            new Person { Name = "Alice", Age = 30 },
            new Person { Name = "Bob", Age = 25 },
            new Person { Name = "Charlie", Age = 35 },
            new Person { Name = "Diana", Age = 28 }
        };
        // 30歳以上の人を抽出
        var adults = people.Where(p => p.Age >= 30).ToList();
        // 年齢順に並べ替え
        adults.Sort((x, y) => x.Age.CompareTo(y.Age));
        Console.WriteLine("30歳以上の人(年齢順):");
        foreach (var person in adults)
        {
            Console.WriteLine($"{person.Name} ({person.Age}歳)");
        }
        // 新しい人を追加
        people.Add(new Person { Name = "Eve", Age = 22 });
        // 名前で検索
        var searchName = "Bob";
        var foundPerson = people.Find(p => p.Name == searchName);
        Console.WriteLine($"\n名前が {searchName} の人:");
        if (foundPerson != null)
        {
            Console.WriteLine($"{foundPerson.Name}{foundPerson.Age} 歳です。");
        }
        else
        {
            Console.WriteLine("見つかりませんでした。");
        }
    }
}
30歳以上の人(年齢順):
Alice (30歳)
Charlie (35歳)

名前が Bob の人:
Bob は 25 歳です。

このサンプルでは、以下のポイントを実践しています。

  • カスタムクラスPersonのリストを作成
  • LINQのWhereで条件に合う要素を抽出
  • Sortにラムダ式を渡して並べ替え
  • Addで新しい要素を追加
  • Findで特定の条件に合う要素を検索

実用的なシナリオでは、これらの操作を組み合わせて柔軟にデータを扱うことが多いです。

List<T>の豊富なメソッドとLINQの強力な機能を活用することで、効率的かつ読みやすいコードが書けます。

まとめ

この記事では、C#のList<T>の基本構文から初期化、要素の追加・取得・更新・削除、並べ替えや検索、容量管理、スレッドセーフティ、イミュータブルな扱い方まで幅広く解説しました。

ジェネリックの利点やパフォーマンス最適化、C#のバージョンごとの進化も理解でき、実用的なサンプルコードで具体的な使い方を学べます。

これにより、List<T>を効果的かつ安全に活用するスキルが身につきます。

関連記事

Back to top button
目次へ