Структуры данных

Список List<T> в C#: полное руководство

Обновлено: · 7 мин чтения

List<T> - самая используемая коллекция в C#: динамический список, который сам растёт при добавлении элементов и даёт быстрый доступ по индексу. Ниже разобраны создание, добавление и удаление, поиск, сортировка, ёмкость Capacity и отличия от обычного массива.

Все примеры рассчитаны на .NET 8 и запускаются без дополнительных пакетов.

Список List<T> в C#: полное руководство
Содержание
  1. Что такое List<T> и когда он нужен
  2. Создание списка
  3. Добавление элементов
    1. Add и AddRange
    2. Insert и InsertRange
  4. Удаление элементов
  5. Поиск в списке
    1. Contains, IndexOf и LastIndexOf
    2. Find, FindAll, FindIndex и Exists
    3. BinarySearch
  6. Сортировка и разворот
  7. Count и Capacity
  8. Отличия List<T> от массива
  9. Перебор элементов
  10. Типичные ошибки при работе со списком
  11. Частые вопросы

Что такое List<T> и когда он нужен

Класс List<T> находится в пространстве имён System.Collections.Generic и хранит элементы одного типа в порядке добавления. Внутри лежит обычный массив, который список пересоздаёт большего размера, когда место заканчивается. Поэтому доступ по индексу выполняется за константное время, а добавление в конец в среднем тоже очень быстрое.

Список выбирают, когда количество элементов заранее неизвестно: строки из файла, результат запроса к базе, ввод пользователя. Если размер фиксирован и не меняется, проще и экономнее взять массив. Если нужен быстрый поиск по ключу, лучше подойдёт словарь, а для порядка "первым пришёл - первым ушёл" существует очередь.

Создание списка

Пустой список создаётся конструктором без параметров. Начальные значения можно передать сразу через инициализатор коллекции, а в C# 12 доступно ещё более короткое выражение коллекции в квадратных скобках.

var empty = new List<int>();
var names = new List<string> { "Анна", "Борис", "Виктор" };
List<int> numbers = [1, 2, 3, 4, 5];          // C# 12
var copy = new List<int>(numbers);             // копия другого списка
var fromArray = new List<string>(new[] { "a", "b" });
var withCapacity = new List<double>(1000);     // заранее заданная ёмкость

Console.WriteLine(names.Count);     // 3
Console.WriteLine(names[1]);        // Борис

Количество элементов хранится в свойстве Count, а не Length, как у массива. Обращение по индексу вне диапазона выбрасывает исключение, подробности есть в статье про IndexOutOfRangeException и ArgumentOutOfRangeException.

Добавление элементов

Добавлять значения можно в конец списка или в произвольную позицию, по одному или пачкой.

Add и AddRange

Метод Add кладёт один элемент в конец списка. Для добавления сразу нескольких значений служит AddRange, который принимает любую последовательность: массив, другой список, результат LINQ-запроса.

var list = new List<int>();
list.Add(10);
list.Add(20);
list.AddRange(new[] { 30, 40, 50 });
Console.WriteLine(string.Join(", ", list)); // 10, 20, 30, 40, 50

Insert и InsertRange

Insert вставляет элемент в нужную позицию и сдвигает все последующие вправо. Операция занимает время, пропорциональное числу сдвигаемых элементов, поэтому для списка из миллиона значений частая вставка в начало становится заметно медленной. Индекс допускается от 0 до Count включительно: вставка по индексу Count равносильна Add.

var list = new List<string> { "один", "три" };
list.Insert(1, "два");                 // один, два, три
list.InsertRange(0, new[] { "ноль" }); // ноль, один, два, три

Удаление элементов

Для удаления есть несколько методов, и у каждого своя область применения. Remove ищет первое совпадение и возвращает true, если элемент найден. RemoveAt убирает элемент по индексу. RemoveAll принимает условие и удаляет все подходящие значения за один проход, а RemoveRange вырезает диапазон. Clear полностью очищает список, при этом Capacity остаётся прежней.

var list = new List<int> { 5, 8, 3, 8, 12, 1 };

list.Remove(8);               // убрали первую 8: 5, 3, 8, 12, 1
list.RemoveAt(0);             // убрали индекс 0: 3, 8, 12, 1
int removed = list.RemoveAll(x => x > 5); // убрали 8 и 12
Console.WriteLine(removed);   // 2
list.RemoveRange(0, 1);       // убрали 3
list.Clear();
Console.WriteLine(list.Count); // 0

Нельзя удалять элементы из списка внутри цикла foreach по этому же списку: при изменении коллекции перечислитель выбросит InvalidOperationException. Либо используйте RemoveAll, либо обходите список с конца обычным циклом for. Подробнее про перебор написано в руководстве по foreach.

Поиск в списке

Класс предлагает три группы методов: поиск по значению, поиск по условию и бинарный поиск.

Contains, IndexOf и LastIndexOf

Contains отвечает на вопрос "есть ли такое значение", IndexOf возвращает позицию первого вхождения или -1, а LastIndexOf ищет с конца. Все три сравнивают элементы через Equals, поэтому для собственных классов стоит переопределить Equals или использовать записи (record), где сравнение по значению уже встроено.

var list = new List<string> { "красный", "зелёный", "синий", "зелёный" };
Console.WriteLine(list.Contains("синий"));       // True
Console.WriteLine(list.IndexOf("зелёный"));      // 1
Console.WriteLine(list.LastIndexOf("зелёный"));  // 3
Console.WriteLine(list.IndexOf("жёлтый"));       // -1

Find, FindAll, FindIndex и Exists

Методы с предикатом нужны, когда искать надо по условию, а не по готовому значению. Find возвращает первый подходящий элемент или значение по умолчанию (для классов это null). FindAll собирает все подходящие в новый список, FindIndex отдаёт индекс, а Exists и TrueForAll проверяют условие без возврата самих данных.

var products = new List<Product>
{
    new("Мышь", 1200), new("Клавиатура", 3500), new("Монитор", 15000)
};

Product? first = products.Find(p => p.Price > 3000);
List<Product> cheap = products.FindAll(p => p.Price < 4000);
int idx = products.FindIndex(p => p.Name == "Монитор");
bool any = products.Exists(p => p.Price > 100000);
Console.WriteLine($"{first?.Name}, {cheap.Count}, {idx}, {any}");
// Клавиатура, 2, 2, False

record Product(string Name, decimal Price);

Для сложных выборок с группировкой и проекцией удобнее LINQ, например Where, Select и FirstOrDefault. Если нужно найти элемент с наибольшим значением свойства, загляните в статью про минимум и максимум по свойству в LINQ.

BinarySearch

Бинарный поиск работает только на отсортированном списке и находит значение за логарифмическое время. Если элемента нет, метод возвращает отрицательное число, которое является побитовым дополнением позиции для вставки. Применять его на неотсортированных данных бессмысленно: результат будет непредсказуемым.

var sorted = new List<int> { 2, 5, 9, 14, 21 };
Console.WriteLine(sorted.BinarySearch(9));   // 2
int pos = sorted.BinarySearch(10);
if (pos < 0) pos = ~pos;                     // место для вставки
sorted.Insert(pos, 10);                      // 2, 5, 9, 10, 14, 21

Сортировка и разворот

Метод Sort сортирует список на месте, используя сравнение по умолчанию. Для собственного порядка передают лямбду-компаратор или объект IComparer. Reverse разворачивает порядок элементов. Обратите внимание, что Sort нестабилен: равные элементы могут поменяться местами. Если важна стабильность, используйте LINQ-метод OrderBy, он стабилен, но создаёт новую последовательность.

var nums = new List<int> { 7, 3, 9, 1, 5 };
nums.Sort();                           // 1, 3, 5, 7, 9
nums.Sort((a, b) => b.CompareTo(a));   // по убыванию: 9, 7, 5, 3, 1
nums.Reverse();                        // 1, 3, 5, 7, 9

var people = new List<(string Name, int Age)>
{
    ("Олег", 30), ("Ира", 25), ("Максим", 30)
};
people.Sort((a, b) => a.Age != b.Age
    ? a.Age.CompareTo(b.Age)
    : string.Compare(a.Name, b.Name, StringComparison.Ordinal));

Для строк с учётом русской культуры передавайте StringComparer.Create или CultureInfo, иначе порядковое сравнение поставит заглавные буквы раньше строчных.

Count и Capacity

Count показывает, сколько элементов в списке сейчас. Capacity говорит, на сколько элементов рассчитан внутренний массив без перевыделения памяти. Когда Count достигает Capacity, список создаёт новый массив вдвое большего размера и копирует в него данные. Стартовая ёмкость пустого списка равна нулю, после первого Add она становится 4, затем 8, 16 и так далее.

var list = new List<int>();
Console.WriteLine(list.Capacity);   // 0
list.Add(1);
Console.WriteLine(list.Capacity);   // 4
for (int i = 0; i < 10; i++) list.Add(i);
Console.WriteLine(list.Capacity);   // 16

var big = new List<int>(100_000);   // известно заранее - сразу выделяем
list.EnsureCapacity(500);
list.TrimExcess();                  // вернуть лишнюю память

Если вы знаете примерное число элементов, передайте его в конструктор: это убирает серию копирований массива и ускоряет заполнение больших списков. Свойство Capacity можно и присваивать, но значение не должно быть меньше Count.

Отличия List<T> от массива

Оба типа хранят элементы подряд в памяти и дают доступ по индексу, но различаются в гибкости и накладных расходах. Сводка по основным пунктам:

КритерийМассив T[]List<T>
РазмерФиксирован при созданииМеняется автоматически
Количество элементовСвойство LengthСвойство Count
Добавление и удалениеНет, нужен новый массивAdd, Insert, Remove, RemoveAt
Скорость доступа по индексуМаксимальнаяПочти такая же, есть проверка границ
МногомерностьЕсть, T[,] и T[][]Только вложенные списки
Готовые методы поиска и сортировкиЧерез статический класс ArrayВстроены в сам класс
ПамятьРовно по размеру данныхС запасом (Capacity)

Практическое правило: данные фиксированной длины, например буфер или таблица констант, хранят в массиве, а всё, что собирается динамически, в списке. Преобразования между ними делаются одной строкой: ToArray() превращает список в массив, а конструктор new List<T>(array) делает обратное.

Перебор элементов

Самый читаемый способ - цикл foreach, он не требует индексов. Когда индекс нужен или список изменяется, используют for. Метод ForEach принимает делегат и удобен для коротких действий, но в нём нельзя использовать break и continue.

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

foreach (var s in list) Console.Write(s + " ");
Console.WriteLine();

for (int i = 0; i < list.Count; i++)
    Console.WriteLine($"{i}: {list[i]}");

list.ForEach(s => Console.Write(s.ToUpper()));

// безопасное удаление при обходе - с конца
var nums = new List<int> { 1, 2, 3, 4, 5, 6 };
for (int i = nums.Count - 1; i >= 0; i--)
    if (nums[i] % 2 == 0) nums.RemoveAt(i);

Если нужно разрезать большой список на части, пригодится приём из статьи про разбиение списка на подсписки. А для получения среза в виде копии есть метод GetRange, который возвращает новый список с выбранным диапазоном.

Типичные ошибки при работе со списком

Первая ошибка - изменение коллекции во время перебора через foreach, о ней сказано выше. Вторая - обращение к элементу по индексу в пустом списке, например list[0] до первого Add. Перед такой операцией проверяйте Count или используйте FirstOrDefault. Третья - ожидание, что присваивание b = a копирует список: на самом деле обе переменные ссылаются на один объект, и для копии нужен new List<T>(a).

Ещё одна частая проблема связана с производительностью: Contains и IndexOf просматривают список целиком, поэтому на тысячах элементов многократный поиск становится узким местом. В таких случаях значения загружают в HashSet<T> или Dictionary<TKey, TValue>. Наконец, не храните в List<object> значения разных типов без необходимости: упаковка и приведение типов замедляют код и делают его менее безопасным.

Частые вопросы

Чем List отличается от ArrayList?

ArrayList хранит элементы типа object, поэтому значимые типы упаковываются, а при чтении нужно приведение. List<T> типизирован, быстрее и безопаснее, поэтому ArrayList в новом коде не используют.

Как получить последний элемент списка?

Обратитесь по индексу list[^1] или list[list.Count - 1]. Для пустого списка оба варианта вызовут исключение, поэтому сначала проверьте Count или примените LastOrDefault().

Как быстро проверить, пуст ли список?

Сравните Count с нулём: list.Count == 0. Это свойство, оно не перебирает элементы. Метод LINQ Any() тоже подходит, но для List<T> свойство Count проще и быстрее.

Как удалить дубликаты из списка?

Самый короткий способ - list = list.Distinct().ToList(). Он сохраняет порядок первых вхождений. Для объектов потребуется сравнение по значению: запись record или собственный IEqualityComparer.

Потокобезопасен ли List?

Нет. Одновременное чтение допустимо, но запись из нескольких потоков приведёт к повреждению данных. Либо блокируйте доступ через lock, либо берите ConcurrentBag, ConcurrentQueue или ImmutableList.

Как скопировать список, чтобы изменения не влияли на оригинал?

Создайте новый список конструктором new List<T>(original) или вызовите ToList(). Копия получится поверхностной: для ссылочных типов объекты останутся общими, и при необходимости клонировать нужно каждый элемент.