C#のList<T>の使い方

C#では、配列的なデータ構造に対し途中でデータを追加・削除する際にしたい場合に使用する、List<T>(=リストクラス)の使い方を紹介したいと思います。

基本的な使い方

使用する際は以下を宣言します。

using System.Linq;
using System.Collections.Generic;

宣言と初期化

Listの宣言と初期値を指定して宣言する方法は以下の通り。

List(T) の T にリストで扱いたい型を指定する事で、指定型専用の入れ物として機能します。この指定方法はC#のジェネリックという機能で、以下例はint型専用のリストとしています。

補足:

Listに似たクラスでSystem.Collections.ArrayListというクラスがあります。これは、C#に「ジェネリック」という機能が登場前の時代(今から10年以上前)に使われていたクラスす。ジェネリックの登場により、今は滅多に使われないのでご注意ください。

// int型を格納するリストを宣言、<int>の任意の型が指定可能
var list = new List<int>();

// 初期化した時に最初から値を指定する場合以下のように書く
var list2 = new List<int>()
{
    1, 2, 3, 4 // listには最初から1,2,3,4の4つの要素が含まれている
};

// Tにstring型を指定するとstring型を格納するリストになる
var stringList = new List<string>()
{
    "ika", "tako", "hotate" // 3つの文字列で宣言と同時に初期化する
};

// 自分で作成したオブジェクトも指定できる
var personList = new List<Person>()
{
    new Person() { Name = "ika" }, // 自作の型で宣言と同時に初期化
    new Person() { Name = "tako" },
    new Person() { Name = "hotate" },
};

追加・挿入・削除

リストの追加と削除の基本的な操作イメージは以下の通り。

  • 要素はリストの末尾に追加される
  • 削除はリストのインデックスや条件を指定する

以下、コード例。

// 要素の追加
list.add(5); // 末尾に5を追加
list.add(6); // 末尾に6を追加
personList.Add(new Person() { Name = "kaki" }) // 自作のオブジェクトを末尾に追加

// 要素の挿入
list.Insert(0, 100); // 先頭(0番目)に100を挿入
list.Insert(3, 120); // 3番目に120を挿入
personList.Insert(2, new Person() { Name = "saba" }) // 自作のオブジェクトを2番目に挿入

// 他のリストを一気に追加
var list_a = new List<int>() { 1, 2, 3 };
var list_b = new List<int>() { 9, 8, 7 };
list_a.AddRange(list_b); // 1, 2, 3, 9, 8, 7

// 要素の削除:1件削除
list.Remove(5);   // 最初に見つかった5を削除
list.RemoveAt(2); // 2番目の要素を削除

// 要素の削除:複数件削除
list.RemoveRange(0, 3);      // 0番目の要素から3件削除
list.RemoveAll(p => p == 2); // ラムダ式で指定した条件に一致するものを全て削除

// 全部削除
list.Clear();

【補足】Clearメソッドを呼び出すと中身は全部消えますが、リスト自体の大きさ(=キャパシティ)が増えたままです。TrimExcess()メソッドを呼び出してリストの大きさを小さくするまで増えたままです。すなわち、1万件追加した後にクリアすると1万件分容量のある空のリストが存在することになります。

値の取り出し

Listのアクセス方法と要素の取り出し方は以下イメージです。

  • リストは配列のように添え字でアクセスできる
  • foreachで全部列挙することができる

コード例は以下の通り。

// 配列のように添え字でアクセスできる
int item = list[2];

// foreachで最初から全部列挙する
foreach(int num in list)
{
    Console.WriteLine(num);
}

// foreachで最後から先頭へ逆順に全部列挙する
foreach(int num in list.Reverse())
{
    Console.WriteLine(num); 
}

ソート

リストの内容をソートするにはSort()メソッドを使います。整列は「デフォルトの整列規則」(=文字コード順)で整列されます。「デフォルトの整列規則」以外を使いたい場合、Sortメソッドに整列条件がラムダ式で渡せます。

// 辞書順にソート
var list = new List<int>() { 5, 2, 6, 3 };
list.Sort();
 // → 2, 3, 5, 6

// 自分のルールでソートする(逆順にソート
list.Sort((a, b) => b - a); // ラムダでオリジナルの条件を記述する
// → 6, 5, 3, 2

存在確認・検索

指定した要素がリスト内に存在するかを確認する方法と、その値を取得する方法です。

// 指定した値があるかどうか確認できる
if(list.Contains(3))
{
    // 存在する場合の処理
}

// ラムダ式で条件を指定して一致するものがあるか確認できる
if(list.Exists(p => p == 3/*3に一致するものがある?*/))
{
    // 存在する場合の処理
}

// 指定した値が最初に見つかった要素のインデックスを取得できる
int index = list.IndexOf(1/*1がリストの中に存在するか問い合わせ*/);
if(index != -1) // 戻り値のindexが-1だと存在しない
{
    int item = list[index];
}

// ラムダ式で条件を指定し一致する最初の要素のインデックスを取得できる
int index = list.FindIndex(p => p == 1);
if (index != -1) // こっちも戻り値のindexが-1だと存在しない
{
    int item = list[index];
}

// 補足:
// Findメソッドは(未記載だが)指定条件がが見つからない場合<T>の既定値(intの場合0)を応答する
// なので<int>等の組み込み型の場合、ヒットしない場合の見分けがつかない

内容を検索する場合、リストの先頭から検索を中身で行います。存在しない場合最後まで検索しきってから答えが出るのでリストの件数が多い場合、割と時間がかかります。つまりO(N)の計算量となるため、度々検索すると動作速度が問題になる可能性があります。

【余談】引数に値を取るものとラムダを取るものの違い

ちょっと話が長くなるので、面倒なら読み飛ばして結構です。

先述の「Find」や「Exists」に、ほぼ同じ機能たが「値が指定できるもの」と「ラムダ式を指定できるもの」の2つの似たメソッドが実装されています。

何故そのような操作があるのかというと「値が指定できるもの」は、intやdouble等の組み込み型に対して便利で、「ラムダ式が指定できるもの」はオブジェクト型に対して便利だからです。

例えば、以下のようにPersonクラスを作成して、Listに入れたとします。

// リストに入れるクラス
public class Person
{
    public string Name { get; set; }
    public int ID { get; set; }
}

// Person型を格納するリストに3件データを指定して初期化
var list = new List<Person>()
{
    new Person() { Name = "tako", ID = 0 },
    new Person() { Name = "ika", ID = 1 },
    new Person() { Name = "hotate", ID = 2 },
};

で、以下のようにContainsすると結果がfalseになります。

bool result/*flase*/ = list.Contains(new Person() { Name = "tako", ID = 0});

これは、同じオブジェクト(同じ参照を持つオブジェクト)でないとContaisnできないためです。この場合引数でオブジェクトを「新しく作っている」ため、たとえ内容が同じでも、参照が異なるため、trueにはなりません。

なのでこの場合は、Existsを使って以下のように検索をします。

bool result/*true*/ = list.Contains(p => p.Name == "tako" && p.ID == 0);

このように条件を指定することで元のオブジェクト参照が無くてもリストの中身を検索することができます。FindメソッドやRemoveAllメソッドも同じような考え方の操作になります。(もちろん演算子の==をオーバーロードして内容を比較すればContainsでも実現できますが、C#で演算子のオーバーロードはあまり利用されていないです。

Listが継承するインターフェースの説明

Listクラスは、IEnumerable、ICollection, IListという複数のインターフェースを継承しています。メソッドのパラメータ等を公開する場面では、なるべくインターフェースで受け渡した方が拡張性が上がります。ListをSortedListやLinkedList(T)に変更した場合でもシグネチャ変更が発生しません。

public ICollection<int> GetList() { // インターフェースで受け渡す

使い方によって、どのインターフェースを使えばいいかはざっくりと以下の通り。

interface 状況
IEnumerable(T) foreachで使える。シーケンスは途中で変更を想定しない。イテレータで遅延評価、無限長の可能性を持つ
ICollection(T) 追加、削除あり。インデクサでアクセスできないので純粋なリスト構造を表す
IList(T) 追加、削除あり。インデクサでアクセスできる。配列みたいに使える。有限長である。

冒頭に触れた通り、(T)が付いていないIList, ICollection, IEnumerableは基本使いません。

プロパティの場合、set操作はprivateにしておいた方が安全です。ReadOnlyCollection(T)などに外から変更されて、クラス内で操作して例外が出るなどは想定外かと思います。

public ICollection<int> List { get; private set; } // setはprivateにしておく

アンチパターン

結構やりがちですが、以下のようなコードは基本的にお勧めできません。

var list = new List<List<int>>(); // <T>の中にジェネリックを宣言しない。

所謂多重リスト形式です。何個でも入れ子にでますが、外部へ公開すると利用者は理解しづらい、内容へのアクセスを制御できないなど弊害があるためこのような実装は非推奨です。このようなデータ形式がある場合、アクセス用のクラスを作ってメソッド経由で操作させましょう。

あと、Listを直接継承するのはやめましょう。実装量が多くなり全てをカバーするのはなかなか難しいため思わぬ不具合を生む可能性があります。

Listの類似クラス

クラス名 説明
System.Collections.ObjectModel.ReadOnlyCollection 読み取り専用のリスト。Listをラップする形で使用する。変更するとNotSupportedExceptionが発生する
System.Collections.Generic.SynchronizedCollection スレッド セーフのList。複数のTaskで結果をマージする時などに使う
SynchronizedReadOnlyCollection.SynchronizedCollection 上記Listを読み取り専用にするときに使う。滅多に使わない

ReadOnlyCollectionは結構使いたいと思う局面が多いと思います。クラス外からは変更してほしくない場合、以下のよう書くと中からは変更可能、外からは変更不可能になります。

public class ReadOnly
{
    // 外部に公開する読み取り専用のプロパティ(Listをラップしている
    public ReadOnlyCollection<string> List => new ReadOnlyCollection<string>(this.sourceList);

    // 中で使用するリスト
    private readonly List<string> sourceList = new List<string>() { "123", "456", "789", "abc" };

    // 文字列を追加
    public void Foo(string str) => this.sourceList.Add(str);
}

使用方法は以下の通り。中からは操作できて外からは変更不可能なListの公開ができる。

static void Main(string[] args)
{
    var r = new ReadOnly();
    foreach (string item in r.List)
    {
        Console.Write(item + ", ");
        
    }
    // > 123, 456, 789, abc,

    r.Foo("xxxx");
    foreach (string item in r.List)
    {
        Console.Write(item + ", ");
    }
    // > 123, 456, 789, abc, xxxx,
    // 中からの変更は反映される

    //r.List[0] = 100;
    //// ↑
    //// これはできない
    //// > エラー CS0200 プロパティまたはインデクサー 'ReadOnlyCollection<string>.this[int]' 
    //// > は読み取り専用であるため、割り当てることはできません

    ((IList<string>)r.List).Add("asdf");
    // 無理やりキャストして実行すると例外になる
    // System.NotSupportedException: 'コレクションは読み取り専用です。'
}

以上です。