【C#】Listの使い方と落とし穴

に公開
使い方と落とし穴シリーズ一覧

一言

  • HashSet, Dictionary とかコレクション色々あってわからんわ~って方は
    この List から慣れた方がイイと思います(経験談)

List を使用するケース

  • 要素数が可変で順序を保ちたい
  • 読み取って使用する頻度が高い
    • ランダムアクセスがO(1)
  • 値型を大量保持する
    • 要素が連続したメモリ領域に配置され、キャッシュヒット率が高い

List の使い方

基本(生成, 追加, 削除, 要素数と容量)

var enemies = new List<Enemy>(100); //初期要素数を指定できる

//追加(リスト最後尾に追加される)
var slime  = new Enemy("Slime");
var ogre   = new Enemy("Ogre");
var dragon = new Enemy("Dragon");
enemies.Add(slime);                       //単発で追加
enemies.AddRange(new[] { ogre, dragon }); //まとめて追加

//削除
enemies.Remove(ogre);             //値が一致するものを削除
enemies.RemoveAt(0);              //インデックス指定で削除
enemies.RemoveAll(e => e.IsDead); //特定条件のものを全て削除
enemies.Clear();        //要素のみ消える(Capacity は残る)
enemies.TrimExcess();   //容量の余りを縮小し、ヒープ開放

//要素数と容量
enemies.Count;    //要素数
enemies.Capacity; //容量

任意位置へ挿入

var stages = new List<string>{"1-1", "1-3"};
stages.Insert(1, "1-2");

検索

if (inventory.Contains(keyItem)) {...}

//Rank は列挙体
var boss = enemies.Find(e => e.Rank == Rank.Boss);
int bossIndex = enemies.FindIndex(e => e.Rank == Rank.Boss);
  • BinarySearch
  • ソート済みの List で使用することができる
  • 戻り値について、
    • 正の数:見つかった要素のインデックス
    • 負の数:ビット反転の形式で挿入すべき位置を返す
    • 例:リスト[100,200,300] で-3が返る。これは挿入位置が2(300の前)のため、~2 == -3
var sortedScores = new List<int> { 100, 200, 300, 400, 500 };
int index = sortedScores.BinarySearch(300);
// index == 2  (要素 300 が見つかった位置)

int notFound = sortedScores.BinarySearch(250);
// 要素が見つからない場合、負の値を返す。 ~(挿入位置) の形式。

並べ替え

  • Sort() は安定ソートでない
var rankings = new List<int>{1200, 800, 3000};
rankings.Sort();     //昇順に整列
rankings.Reverse();  //降順に整列

その他

便利なものを抜粋

カスタムソート

  • Sort() は既定で要素の IComparable<T> 実装を使う
  • しかし、以下のように独自の並べ替え基準を指定することもできる
//クラスのメンバ変数を用いてソート(スコア昇順)
players.Sort((a, b) => a.Score.CompareTo(b.Score));

//文字列を長さ → アルファベット順でソート
class LengthThenAlpha : IComparer<string>
{
    public int Compare(string? x, string? y)
    {
        int len = x!.Length.CompareTo(y!.Length);
        return len == 0 ? string.CompareOrdinal(x, y) : len;
    }
}
words.Sort(new LengthThenAlpha());

ConvertAll

  • 型変換しながらコピーができる
var names = new List<string> { "100", "200", "300" };
List<int> numbers = names.ConvertAll(s => int.Parse(s));

AsReadOnly

  • 読み取り専用Listを提供
  • 元のリストが変更されると、それも反映される
var readOnly = items.AsReadOnly();

Exists

  • 条件に合う要素があるか判定
if (players.Exists(p => p.Score >= 1000)) {}

TrueForAll

  • 全ての要素が条件を満たすか判定
bool allDead = enemies.TrueForAll(e => e.IsDead);

オイラはこんな落とし穴に出会った

Capacity 無視で大量 Add

再確保が頻発し GC が起きやすい

  • さすがにこれだけでソフトが強制終了するような、ド派手な悪影響はそうないが…
var data = new List<int>();
for (int i = 0; i < 10000; i++)
{
    data.Add(i); //Addのたび、内部配列が再確保される
}

回避策

  • おおよそ必要な数がわかっていれば最初に確保
var data = new List<int>(10000); //最初に容量を確保
for (int i = 0; i < 10000; i++)
{
    data.Add(i);
}

foreach 中にコレクション変更

  • foreach は列挙子を用いて List を読み取る

foreach 中に要素が変更されると、列挙子の整合性が崩れる
そのため、実行時に InvalidOperationException が発生する

var list = new List<string> { "a", "b", "c" };

foreach (var item in list)
{
    if (item == "b")
    {
        list.Remove(item); //InvalidOperationException
    }
}

回避策

  • for ループで末尾から走査すれば、削除によるズレを防げる
for (int i = list.Count - 1; i >= 0; i--)
{
    if (list[i] == "b")
    {
        list.RemoveAt(i);
    }
}

Clear() でメモリ解放と勘違い

Clear() で要素は消えるが、容量(内部配列)は残る

var list = new List<byte>(1000);
list.AddRange(new byte[1000]);

//何らかの処理で要素数が500になる

list.Clear();
Console.WriteLine(list.Capacity); //1000 のまま

回避策

  • メモリを解放したい場合、TrimExcess() を用いる
  • TrimExcess() は要素数が Capacity の90%未満の場合 Capacity を Count に縮小する
var list = new List<byte>(1000);
list.AddRange(new byte[1000]);

//何らかの処理で要素数が500になる

list.TrimExcess();
Console.WriteLine(list.Capacity); //500

参考

Discussion