C#プログラミングにおいて、List<T> クラスは最も頻繁に利用されるデータ構造の一つです。

動的な配列として非常に便利な反面、要素の削除操作に関しては、対象の指定方法やパフォーマンス特性、さらには列挙中の操作制限など、注意すべき点が数多く存在します。

特に、大量のデータを扱うアプリケーションやリアルタイム性が求められるシステムでは、不適切な削除処理が原因でパフォーマンスの低下や実行時エラーを招くことも少なくありません。

本記事では、C#の List<T> における要素削除の手法を網羅的に解説します。

基本的なメソッドの使い分けから、ラムダ式を用いた条件削除、そして開発者が陥りやすい「ループ内での削除」に関するベストプラクティスまで、実務に直結する知識を深く掘り下げていきましょう。

Listの要素を削除する基本メソッド

C#の System.Collections.Generic.List<T> には、用途に応じて複数の削除メソッドが用意されています。

まずは、最も基本的かつ頻用される4つのメソッドについて、それぞれの動作と特徴を確認します。

Removeメソッド:値を指定して削除する

Remove メソッドは、引数に渡されたオブジェクトと一致する「最初の要素」をリストから削除します。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<string> fruits = new List<string> { "apple", "banana", "cherry", "apple" };

        // 最初に一致した "apple" だけが削除される
        bool isRemoved = fruits.Remove("apple");

        Console.WriteLine($"削除成功: {isRemoved}");
        foreach (var fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
実行結果
削除成功: True
banana
cherry
apple

このメソッドの重要なポイントは、一致する要素が複数ある場合、最初の1つだけが削除されるという点です。

また、戻り値として削除に成功したかどうかを示す bool 値を返します。

リスト内に該当する要素が存在しない場合、例外は発生せず単に false が返されるため、安全に使用できます。

RemoveAtメソッド:インデックスを指定して削除する

特定の場所にある要素を削除したい場合は、RemoveAt メソッドを使用します。

C#
List<int> numbers = new List<int> { 10, 20, 30, 40 };

// インデックス 2(3番目の要素「30」)を削除
numbers.RemoveAt(2);

RemoveAt は、削除したい要素のインデックスを直接指定するため、要素の検索コストがかからないという利点があります。

ただし、指定したインデックスがリストの範囲外(負の値、または Count 以上)である場合は ArgumentOutOfRangeException がスローされるため、事前に範囲チェックを行うか、インデックスの妥当性が保証されている状況で使用する必要があります。

RemoveRangeメソッド:範囲を指定してまとめて削除する

連続する複数の要素を一括で削除したい場合には、RemoveRange メソッドが効率的です。

C#
List<int> data = new List<int> { 0, 1, 2, 3, 4, 5, 6 };

// インデックス 2 から 3 つの要素を削除 (2, 3, 4 が消える)
data.RemoveRange(2, 3);

個別に RemoveAt を繰り返すよりも、内部的なメモリの詰め替え処理(データのシフト)が1回で済むため、大量のデータを操作する際のパフォーマンスに優れています。

Clearメソッド:すべての要素を削除する

リストを完全に空にするには Clear メソッドを使用します。

C#
List<string> logs = new List<string> { "Error1", "Error2" };
logs.Clear();
Console.WriteLine(logs.Count); // 出力: 0

Clear を呼び出すと、リストの Count は 0 になります。

ただし、内部的な配列の容量(Capacity)は維持されるため、メモリを完全に解放したい場合は必要に応じて TrimExcess メソッドを併用することを検討してください。

条件を指定した要素の削除:RemoveAll

実務において「特定の値と一致するもの」だけでなく、「特定の条件を満たすすべての要素」を削除したい場面は非常に多いでしょう。

そのような場合に最適なのが RemoveAll メソッドです。

RemoveAllの基本的な使い方

RemoveAll は、引数に Predicate<T>(条件を定義したデリゲートやラムダ式)を受け取り、その条件に合致するすべての要素を一度に削除します。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

        // 5より大きい数値をすべて削除
        int removedCount = numbers.RemoveAll(n => n > 5);

        Console.WriteLine($"{removedCount} 個の要素を削除しました。");
        Console.WriteLine("残った要素: " + string.Join(", ", numbers));
    }
}
実行結果
5 個の要素を削除しました。
残った要素: 1, 2, 3, 4, 5

RemoveAllのメリット

RemoveAll を使用する最大のメリットは、コードの簡潔さと圧倒的なパフォーマンスです。

後述するループ処理による削除では、要素を1つ消すごとに後続の要素を前方に詰める処理(コピー処理)が発生しますが、RemoveAll は内部的にリストを1回走査するだけで効率的に要素を再配置します。

大量のデータから特定の条件で要素を間引く場合は、自分でループを書かずに RemoveAll を使用するのが C# における鉄則と言えます。

ループ内での削除における注意点と解決策

C#の初心者から中級者までが最も陥りやすい罠が、foreach ループや通常の for ループの中でリストの要素を削除しようとすることです。

foreachループでの削除は「禁じ手」

foreach を使用してリストを反復処理している最中に、そのリスト自体の構成を変更(追加や削除)することは許可されていません。

C#
List<int> list = new List<int> { 1, 2, 3 };

foreach (var item in list)
{
    if (item == 2)
    {
        list.Remove(item); // ここで InvalidOperationException が発生!
    }
}

C#の列挙子(Enumerator)は、コレクションが変更されていないかを監視しており、反復中に変更が検知されると InvalidOperationException をスローします。

これはデータの整合性を守るための仕様です。

forループでの削除:前から削除する場合の問題

それならば、インデックスを回す for ループなら大丈夫だろうと考えがちですが、単純に前から削除すると「削除した次の要素をスキップしてしまう」という論理的なバグが発生します。

C#
List<int> nums = new List<int> { 1, 2, 2, 3 };

for (int i = 0; i < nums.Count; i++)
{
    if (nums[i] == 2)
    {
        nums.RemoveAt(i);
    }
}
// 結果: { 1, 2, 3 } となり、2番目の「2」が消えない

インデックス 1 の「2」を削除すると、リストの残りの要素が前方に詰められます。

すると、元々インデックス 2 にあった次の「2」がインデックス 1 に移動しますが、ループの次のステップでは i が 2 に進んでしまうため、移動してきた要素のチェックが漏れてしまうのです。

推奨される解決策1:逆順(後ろから)ループ

ループを使って要素を削除する場合の定番手法は、リストの末尾から先頭に向かって処理することです。

C#
List<int> nums = new List<int> { 1, 2, 2, 3 };

for (int i = nums.Count - 1; i >= 0; i--)
{
    if (nums[i] == 2)
    {
        nums.RemoveAt(i);
    }
}

後ろから削除すれば、要素を削除して後続が詰まったとしても、これから処理する「自分より前のインデックス」には影響を与えません。

これが最も古典的で確実な回避策です。

推奨される解決策2:ToList() によるコピーの列挙

foreach を使いたい場合は、ToList() メソッドを使用して「削除対象を走査するためのコピー」を作成する方法があります。

C#
List<int> nums = new List<int> { 1, 2, 2, 3 };

// 元のリストではなく、コピーされたリストを回す
foreach (var item in nums.ToList())
{
    if (item == 2)
    {
        nums.Remove(item);
    }
}

この方法はコードが読みやすくなりますが、新しいリストをメモリ上に作成するため、要素数が多い場合にはオーバーヘッドが発生する点に注意が必要です。

LINQを活用した要素の削除(フィルタリング)

厳密には「既存のリストから削除する」のではなく、「条件に合うものだけで新しいリストを作る」というアプローチも非常に一般的です。

これには LINQ の Where メソッドを利用します。

Whereによるフィルタリング

C#
using System.Linq;

List<int> original = new List<int> { 1, 2, 3, 4, 5 };

// 偶数を除去する(=奇数だけを抽出する)
List<int> filtered = original.Where(n => n % 2 != 0).ToList();

元のリストを書き換えたくない(非破壊的な操作をしたい)場合や、関数型プログラミングのスタイルを好む場合は、この LINQ を用いた手法が適しています。

ただし、元のインスタンスを更新したい場合は、先述の RemoveAll の方がメモリ効率と速度の両面で優れています。

各削除手法のパフォーマンス比較

リストの削除操作において、パフォーマンスに最も大きな影響を与えるのは「要素のシフト(詰め替え)」です。

List<T> は内部的には配列であるため、途中の要素を削除すると、それ以降のすべての要素を 1 つずつ前にずらす処理が発生します。

メソッド計算量 (平均)特徴
Remove(T)O(n)要素の検索に O(n)、削除後のシフトに O(n) かかる。
RemoveAt(i)O(n)インデックス指定は O(1) だが、その後のシフトに O(n) かかる。
RemoveAll(pred)O(n)リストを1回走査し、シフトも最小限で済むため非常に効率的。
Clear()O(n) / O(1)要素の参照をクリアするだけであれば O(n)。

特に 「ループ内で RemoveAt を繰り返す」操作は O(n^2) の計算量になる可能性があり、要素数が数万件を超えると目に見えて処理が遅くなります。

これに対し、RemoveAll は O(n) で完了するため、大量データに対する条件削除では迷わず RemoveAll を選択すべきです。

実践的な使い分けガイドライン

これまでの内容を踏まえ、状況に応じた最適なメソッドの選び方をまとめます。

Remove(value)

Remove(value) を使うと、指定した値に一致する最初の要素だけを削除します。

存在しない場合は何も起こりません。

RemoveAt(index)

RemoveAt(index) は位置(インデックス)で要素を削除します。

インデックスが範囲外だと例外になるため、事前に範囲チェックを行ってください。

RemoveAll(condition)

RemoveAll(condition) は条件(述語)に一致するすべての要素を一括で削除します。

条件に合う要素をまとめて消したい場合の第一選択です。

逆順の for ループ

ループ中に要素を削除してもインデックスのずれを避けられるよう、末尾から先頭へ向かう逆順の for ループを使います。

削除と走査を同時に行いたい複雑なロジックに有効です。

削除対象を別リストに一時保存してまとめて削除

条件判定などを行いながら削除対象を別のリスト(またはインデックスのリスト)に追加し、ループ後にまとめて削除します。

元の走査を乱さず安全に削除できます。

Clear()

Clear() を呼ぶとリスト内のすべての要素が削除され、リストを完全にリセットできます。

まとめ

C#の List<T> における要素削除は、一見単純に見えて奥が深いテーマです。

単一要素の削除であれば RemoveRemoveAt で十分ですが、条件に基づく一括削除やループ処理が絡むと、正しい手法を選ばなければバグやパフォーマンス劣化の要因となります。

特に、「foreach内での削除禁止」と「RemoveAllの効率性」の2点は、C#エンジニアとして必ず押さえておくべきポイントです。

また、現代のC#開発においては、破壊的な変更を行う RemoveAll と、非破壊的な LINQ の Where を適切に使い分ける設計センスも求められます。

本記事で紹介した手法を状況に応じて正しく使い分け、安全で高速なリスト操作を実現してください。