Особенности реализации List в C#

от автора

List является одной из самых популярных коллекций в C#. Давайте разберёмся в некоторых особенностях работы с ним и посмотрим на внутреннюю реализацию его отдельных частей.

Введение

Данная статья будет посвящена полностью List<T> из пространства имён System.Collections.Generic, а если быть конкретнее, то его внутренней реализации и некоторым особенностям. Это самая часто используемая коллекция языка. И это не только моё мнение — так писали в своих книгах Эндрю Троелсен, Филипп Джепикс и Джон Скит. И это понятно – с List<T> легко работать. Он довольно гибкий и тем самым покрывает огромную часть повседневных задач программиста. С этим также помогает большое количество методов, идущих с ним в комплекте. А наличие LINQ ещё больше расширяет возможности данной коллекции.

Внутри List

Исходный код класса List<T> доступен на GitHub. Это значит, что мы можем взглянуть на его реализацию. Пройдёмся по важным аспектам.

Класс List<T> представляет последовательный список элементов с динамически изменяемым размером. Под капотом List<T> построен с использованием массива.

Класс List<T> содержит 3 основных поля:

  • T[] _items – внутренний массив, на основе которого строится список;

  • int _size – хранит информацию о количестве элементов в списке;

  • int _version – содержит версию коллекции.

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

Как уже было сказано, размер списка динамически изменяется. Взглянем поподробнее, что же происходит при добавлении элемента в список.

public void Add(T item) {   _version++;   T[] array = _items;   int size = _size;   if ((uint)size < (uint)array.Length)   {     _size = size + 1;     array[size] = item;   }   else   {     AddWithResize(item);   } } 

В первую очередь значение поля _version увеличивается на 1 (смысл данного действия мы разберём чуть позже). После этого происходит создание двух локальных переменных – массива array с элементами типа T и size типа int. Им присваиваются соответствующие поля. Далее если в массиве ещё есть место для одного элемента, то происходит изменение элемента массива по индексу size + 1. Если же размер массива не позволяет добавить ещё один элемент, то вызывается метод AddWithResize.

private void AddWithResize(T item) {   Debug.Assert(_size == _items.Length);   int size = _size;   Grow(size + 1);   _size = size + 1;   _items[size] = item; } 

Здесь вызывается метод Grow для увеличения текущего размера внутреннего массива. Далее производятся те же действия, что и в методе Add, для добавления при доступном месте.

Рассмотрим метод Grow подробнее:

private void Grow(int capacity) {   ....    int newcapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;    if ((uint)newcapacity > Array.MaxLength) newcapacity = Array.MaxLength;    if (newcapacity < capacity) newcapacity = capacity;    Capacity = newcapacity; } 

Алгоритм работы метода Grow:

  • если внутренний массив пуст, то ёмкость списка будет равна 4, иначе удвоенной длине массива;

  • если новое значение ёмкости получается больше максимально возможной длины массива, то данная ёмкость станет равна Array.MaxLength;

  • если новое значение ёмкости коллекции получилось меньше текущего, то новая ёмкость станет равна текущей;

  • в конце newcapacity записывается в свойство Capacity.

Зачем нужно поле _version?

Но зачем же всё-таки нужно поле _version, значение которого менялось в методе Add? Как уже было написано ранее, это поле, которое позволяет отслеживать версию списка. Его значение проверяется при обходе списка. К примеру, рассмотрим метод ForEach:

public void ForEach(Action<T> action) {   ....   int version = _version;    for (int i = 0; i < _size; i++)   {     if (version != _version)     {       break;     }     action(_items[i]);   }    if (version != _version)     ThrowHelper       .ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion(); } 

Перед началом обхода значение поля _version сохраняется в переменную. Если во время обхода список будет изменён, то обход прекращается и выбрасывается исключение типа System.InvalidOperationException. Похожим образом _version отслеживается и в List<T>.Enumerator. Поэтому изменение списка при его обходе в foreach также приведёт к выбрасыванию исключения.

Capacity

У List<T> есть конструктор, который первым аргументом принимает число – начальную ёмкость.

List<int> list = new List<int>(8); 

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

Кстати, размером внутреннего массива можно управлять, ещё и используя свойство Capacity:

list.Capacity = 8; 

Рассмотрим код данного свойства:

public int Capacity {   get => _items.Length;   set   {     if (value < _size)     {       ThrowHelper.ThrowArgumentOutOfRangeException(....);     }      if (value != _items.Length)     {       if (value > 0)       {         T[] newItems = new T[value];         if (_size > 0)         {           Array.Copy(_items, newItems, _size);         }         _items = newItems;       }       else       {         _items = s_emptyArray;       }     }   } } 

Аксессор get возвращает значение _items.Length, то есть длину внутреннего массива.

Аксессор set действует по следующему алгоритму:

  • если value меньше количества элементов в коллекции, то будет выброшено исключение;

  • если value не равно длине внутреннего массива и value больше 0, то будет создан новый массив с ёмкостью, равной value;

  • если количество элементов в списке больше 0, то будет выполнено копирование элементов из старого массива в новый;

  • если value равно 0, то полю, которое представляет собой внутренний массив, будет присвоен пустой массив.

Прочие особенности методов List

Insert

Метод Insert позволяет вставить элемент в коллекцию только в рамках начала и конца этой коллекции. Если количество элементов в коллекции будет равно размерности внутреннего массива, то произойдёт увеличение ёмкости массива с помощью метода Grow(_size + 1). При попытке вставить элемент на индекс, который больше list.Count, будет выброшено исключение System.ArgumentOutOfRangeException.

List<string> list = new List<string>() { "1", "2"}; list.Insert(1, "10"); // OK list.Insert(2, "15"); // OK list.Insert(10, 12);  // throw exception 

Подобное поведение останется даже при явном управлении размером внутреннего массива.

Рассмотрим пример:

List<string> list = new List<string>() { "1", "2"}; list.Capacity = 8; list.Insert(3, "3"); 

В свойство Capacity присваивается 8, что приводит к изменению размера внутреннего массива. Однако это не даёт возможности вставить элемент на позицию, превышающую list.Count. Результатом выполнения приведённого кода будет выбрасывание исключения.

Clear

Данный метод производит очистку коллекции. В результате этой операции свойство Count будет иметь значение 0. Элементы коллекции ссылочного типа получают значение по умолчанию. Если элементы коллекции являются структурами и имеют поля ссылочного типа, то данные поля тоже получат значение по умолчанию. Стоит заметить, что размер внутреннего массива остаётся неизменным. Если до вызова Clear свойство Capacity было равно 8, то и после Clear размер массива останется равным 8. Для освобождения памяти, выделяемой под сам массив, необходимо после Clear вызвать метод TrimExcess.

TrimExcess

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

list.Clear(); list.TrimExcess(); 

Sort и OrderBy

Между двумя этими методами есть несколько различий:

  • метод Sort принадлежит классу List<T>, а метод OrderBy является методом расширения из LINQ;

  • метод Sort модифицирует исходную коллекцию, а OrderBy возвращает отсортированную копию с типом IOrderedEnumerable<TSource>;

  • метод OrderBy производит устойчивую сортировку, а Sort – нет. Если вы используете метод Sort, то эквивалентные элементы могут быть переупорядочены.

Немного о производительности

List против ArrayList

List<T> является обобщённым, а это значит, что мы должны при создании списка указать, с объектами какого типа он работает.

List<string> list = new List<string>(); 

Джеффри Рихтер в своей книге «CLR via C#» приводит следующие преимущества обобщений:

  • защита исходного кода;

  • безопасность типов;

  • более простой и понятный код;

  • повышение производительности.

В той же книге в начале 12-ой главы про обобщения имеется хороший пример сравнения List<T> и его необобщённого аналога ArrayList. Суть теста заключается в добавлении элемента в список и присваивании этого же элемента из списка в переменную 10 миллионов раз.

Пример кода для тестирования ArrayList со значимым типом:

public void ValueTypeArrayList() {   ArrayList a = new ArrayList();   for (Int32 n = 0; n < _count; n++)   {     a.Add(n);     Int32 x = (Int32)a[n];   } } 

Тестирование производилось с объектами значимых (Int32) и ссылочных (String) типов.

Переписав приведённый в книге код и протестировав его с помощью BenchmarkDotNet, я получил следующие результаты:

Из результатов видно, что c Int32 алгоритм List<T> работает гораздо быстрее, чем ArrayList. В целых 13 раз! Плюс с List<T> в 4 раза меньше выделяется память.

Из-за того что при работе ArrayList производится множество операций упаковки, увеличивается и число сборок мусора. При этом получение элемента требует выполнения распаковки. Всё это приводит к снижению производительности.

Разница при использовании ссылочных типов несущественная, так как нет операций упаковки и распаковки, которые являются очень тяжёлыми. Судя по коду, небольшая разница в скорости появляется из-за операции преобразования типов.

Преимущества задания Capacity

Как уже было сказано ранее, если разработчик заранее знает размер списка, то он может указать его.

Проведём небольшой тест.

public void ListWithoutCapacity() {   for (int i = 0; i < Count; i++)   {     List<int> list = new List<int>();     for (int j = 0; j < Length; j++)     {       list.Add(j);     }   } } 

В данном случае происходит добавление в 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.


ссылка на оригинал статьи https://habr.com/ru/company/pvs-studio/blog/691476/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *