List является одной из самых популярных коллекций в C#. Давайте разберёмся в некоторых особенностях работы с ним и посмотрим на внутреннюю реализацию его отдельных частей. Данная статья будет посвящена полностью List<T> из пространства имён System.Collections.Generic, а если быть конкретнее, то его внутренней реализации и некоторым особенностям. Это самая часто используемая коллекция языка. И это не только моё мнение — так писали в своих книгах Эндрю Троелсен, Филипп Джепикс и Джон Скит. И это понятно – с List<T> легко работать. Он довольно гибкий и тем самым покрывает огромную часть повседневных задач программиста. С этим также помогает большое количество методов, идущих с ним в комплекте. А наличие LINQ ещё больше расширяет возможности данной коллекции. Исходный код класса List<T> доступен на GitHub. Это значит, что мы можем взглянуть на его реализацию. Пройдёмся по важным аспектам. Класс List<T> представляет последовательный список элементов с динамически изменяемым размером. Под капотом List<T> построен с использованием массива. Класс List<T> содержит 3 основных поля: T[] _items – внутренний массив, на основе которого строится список; int _size – хранит информацию о количестве элементов в списке; int _version – содержит версию коллекции. Как уже было сказано, размер списка динамически изменяется. Взглянем поподробнее, что же происходит при добавлении элемента в список. В первую очередь значение поля _version увеличивается на 1 (смысл данного действия мы разберём чуть позже). После этого происходит создание двух локальных переменных – массива array с элементами типа T и size типа int. Им присваиваются соответствующие поля. Далее если в массиве ещё есть место для одного элемента, то происходит изменение элемента массива по индексу size + 1. Если же размер массива не позволяет добавить ещё один элемент, то вызывается метод AddWithResize. Здесь вызывается метод Grow для увеличения текущего размера внутреннего массива. Далее производятся те же действия, что и в методе Add, для добавления при доступном месте. Рассмотрим метод Grow подробнее: Алгоритм работы метода Grow: если внутренний массив пуст, то ёмкость списка будет равна 4, иначе удвоенной длине массива; если новое значение ёмкости получается больше максимально возможной длины массива, то данная ёмкость станет равна Array.MaxLength; если новое значение ёмкости коллекции получилось меньше текущего, то новая ёмкость станет равна текущей; в конце newcapacity записывается в свойство Capacity. Но зачем же всё-таки нужно поле _version, значение которого менялось в методе Add? Как уже было написано ранее, это поле, которое позволяет отслеживать версию списка. Его значение проверяется при обходе списка. К примеру, рассмотрим метод ForEach: Перед началом обхода значение поля _version сохраняется в переменную. Если во время обхода список будет изменён, то обход прекращается и выбрасывается исключение типа System.InvalidOperationException. Похожим образом _version отслеживается и в List<T>.Enumerator. Поэтому изменение списка при его обходе в foreach также приведёт к выбрасыванию исключения. У List<T> есть конструктор, который первым аргументом принимает число – начальную ёмкость. Если разработчик заранее знает нужный размер списка, то он может задать его. Это избавляет от ненужных операций копирования и выделения памяти под новый массив при добавлении новых элементов. Кстати, размером внутреннего массива можно управлять, ещё и используя свойство Capacity: Рассмотрим код данного свойства: Аксессор get возвращает значение _items.Length, то есть длину внутреннего массива. Аксессор set действует по следующему алгоритму: если value меньше количества элементов в коллекции, то будет выброшено исключение; если value не равно длине внутреннего массива и value больше 0, то будет создан новый массив с ёмкостью, равной value; если количество элементов в списке больше 0, то будет выполнено копирование элементов из старого массива в новый; если value равно 0, то полю, которое представляет собой внутренний массив, будет присвоен пустой массив. Метод Insert позволяет вставить элемент в коллекцию только в рамках начала и конца этой коллекции. Если количество элементов в коллекции будет равно размерности внутреннего массива, то произойдёт увеличение ёмкости массива с помощью метода Grow(_size + 1). При попытке вставить элемент на индекс, который больше list.Count, будет выброшено исключение System.ArgumentOutOfRangeException. Подобное поведение останется даже при явном управлении размером внутреннего массива. Рассмотрим пример: В свойство Capacity присваивается 8, что приводит к изменению размера внутреннего массива. Однако это не даёт возможности вставить элемент на позицию, превышающую list.Count. Результатом выполнения приведённого кода будет выбрасывание исключения. Данный метод производит очистку коллекции. В результате этой операции свойство Count будет иметь значение 0. Элементы коллекции ссылочного типа получают значение по умолчанию. Если элементы коллекции являются структурами и имеют поля ссылочного типа, то данные поля тоже получат значение по умолчанию. Стоит заметить, что размер внутреннего массива остаётся неизменным. Если до вызова Clear свойство Capacity было равно 8, то и после Clear размер массива останется равным 8. Для освобождения памяти, выделяемой под сам массив, необходимо после Clear вызвать метод TrimExcess. Данный метод делает размер внутреннего массива равным количеству элементов в списке. Его стоит использовать, например, когда вы знаете, что в коллекцию больше не будут добавлены новые элементы. Между двумя этими методами есть несколько различий: метод Sort принадлежит классу List<T>, а метод OrderBy является методом расширения из LINQ; метод Sort модифицирует исходную коллекцию, а OrderBy возвращает отсортированную копию с типом IOrderedEnumerable<TSource>; метод OrderBy производит устойчивую сортировку, а Sort – нет. Если вы используете метод Sort, то эквивалентные элементы могут быть переупорядочены. List<T> является обобщённым, а это значит, что мы должны при создании списка указать, с объектами какого типа он работает. Джеффри Рихтер в своей книге "CLR via C#" приводит следующие преимущества обобщений: защита исходного кода; безопасность типов; более простой и понятный код; повышение производительности. В той же книге в начале 12-ой главы про обобщения имеется хороший пример сравнения List<T> и его необобщённого аналога ArrayList. Суть теста заключается в добавлении элемента в список и присваивании этого же элемента из списка в переменную 10 миллионов раз. Пример кода для тестирования ArrayList со значимым типом: Тестирование производилось с объектами значимых (Int32) и ссылочных (String) типов. Переписав приведённый в книге код и протестировав его с помощью BenchmarkDotNet, я получил следующие результаты: Из результатов видно, что c Int32 алгоритм List<T> работает гораздо быстрее, чем ArrayList. В целых 13 раз! Плюс с List<T> в 4 раза меньше выделяется память. Из-за того что при работе ArrayList производится множество операций упаковки, увеличивается и число сборок мусора. При этом получение элемента требует выполнения распаковки. Всё это приводит к снижению производительности. Разница при использовании ссылочных типов несущественная, так как нет операций упаковки и распаковки, которые являются очень тяжёлыми. Судя по коду, небольшая разница в скорости появляется из-за операции преобразования типов. Как уже было сказано ранее, если разработчик заранее знает размер списка, то он может указать его. Проведём небольшой тест. В данном случае происходит добавление в list 150 000 элементов. Для наглядности проведём эту операцию 1000 раз. И сравним производительность с таким же методом, но с указанным capacity, который равен количеству операций добавления. Из результатов видно, что затраченное время на выполнение метода без capacity в 2 раза больше, чем с заранее установленным. Также памяти выделяется почти в 4 раза больше. Подобные действия убирают 17 ненужных операций копирования на каждой итерации внешнего цикла. Возьмём три варианта определения того, что список непустой: использовать метод Count из LINQ и сравнить результат с 0; использовать свойство Count и сравнить результат с 0; использовать метод расширения Any из LINQ. Проведя тестирование, получаем следующие результаты для списка из 1 500 000 элементов: Самым быстрым оказался доступ к свойству Count, так как оно просто возвращает значение поля _size. Метод Count пытается преобразовать исходную коллекцию к ICollection. При успешном преобразовании метод вернёт значение свойства Count. В случае неудачи потребуется обойти всю коллекцию для высчитывания количества элементов. К счастью, List<T> реализует данный интерфейс. Метод Any при обнаружении хотя бы одного элемента в коллекции вернёт true. Можно сказать, что List<T> является более удобной для работы версией массива. Например, со списком удобнее работать, когда заранее неизвестно количество элементов последовательности. C# содержит ещё множество коллекций, которые помогают в работе разработчикам. Какие-то из них более специфичные, а какие-то менее. Надеюсь, что данная статья поможет вам в работе и сделает ваше понимание списков чуть лучше :). Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Artem Rovenskii. List in C#: implementation and features.