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

Содержание
Что такое 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(). Копия получится поверхностной: для ссылочных типов объекты останутся общими, и при необходимости клонировать нужно каждый элемент.




