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

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

95f4e27e046fba448446f72be5315f13.png

Введение

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

Внутри List

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

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

Класс List содержит 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 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.Enumerator. Поэтому изменение списка при его обходе в foreach также приведёт к выбрасыванию исключения.

Capacity

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

List list = new List(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 list = new List() { "1", "2"};
list.Insert(1, "10"); // OK
list.Insert(2, "15"); // OK
list.Insert(10, 12);  // throw exception

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

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

List list = new List() { "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, а метод OrderBy является методом расширения из LINQ;

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

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

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

List против ArrayList

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

List list = new List();

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

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

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

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

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

В той же книге в начале 12-ой главы про обобщения имеется хороший пример сравнения List и его необобщённого аналога 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, я получил следующие результаты:

1343953a68f2281b499fec62f65c13e1.png

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

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

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

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

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

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

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

В данном случае происходит добавление в list 150 000 элементов. Для наглядности проведём эту операцию 1000 раз. И сравним производительность с таким же методом, но с указанным capacity, который равен количеству операций добавления.

01a2cbe9194a6cc1aabd0d1e444be325.png

Из результатов видно, что затраченное время на выполнение метода без capacity в 2 раза больше, чем с заранее установленным. Также памяти выделяется почти в 4 раза больше. Подобные действия убирают 17 ненужных операций копирования на каждой итерации внешнего цикла.

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

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

  • использовать метод Count из LINQ и сравнить результат с 0;

  • использовать свойство Count и сравнить результат с 0;

  • использовать метод расширения Any из LINQ.

Проведя тестирование, получаем следующие результаты для списка из 1 500 000 элементов:

cdaaa08c0d0b3c048858aa711ecc8a1b.png

Самым быстрым оказался доступ к свойству Count, так как оно просто возвращает значение поля _size.

Метод Count пытается преобразовать исходную коллекцию к ICollection. При успешном преобразовании метод вернёт значение свойства Count. В случае неудачи потребуется обойти всю коллекцию для высчитывания количества элементов. К счастью, List реализует данный интерфейс.

Метод Any при обнаружении хотя бы одного элемента в коллекции вернёт true.

Заключение

Можно сказать, что List является более удобной для работы версией массива. Например, со списком удобнее работать, когда заранее неизвестно количество элементов последовательности.

C# содержит ещё множество коллекций, которые помогают в работе разработчикам. Какие-то из них более специфичные, а какие-то менее. Надеюсь, что данная статья поможет вам в работе и сделает ваше понимание списков чуть лучше:).

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Artem Rovenskii. List in C#: implementation and features.

© Habrahabr.ru