Предположим, нам нужно отсортировать коллекцию по нескольким ключам. В C# мы можем сделать это с помощью OrderBy().OrderBy() или OrderBy().ThenBy(). Но в чем разница между этими вызовами? Чтобы ответить на этот вопрос, нам нужно углубиться в исходный код.
В статье три главы:
- Фон. Для тех, кто любит немного размяться перед чтением статьи. Здесь вы узнаете, почему я решил провести небольшое исследование и найти разницу между OrderBy().OrderBy() и OrderBy().ThenBy().
- Сравнение эффективности. Здесь мы сравним производительность и потребление памяти этими методами сортировки.
- Различия в поведении. Здесь мы углубимся в исходный код .NET, чтобы выяснить, почему эти методы сортировки различаются по эффективности.
Фон
Все началось со следующей статьи: «Подозрительные сортировки в Unity, ASP.NET Core и не только». В статье описаны случаи, когда порядок вызовов OrderBy().OrderBy() может привести к ошибкам. Однако оказывается, что иногда разработчики намеренно используют OrderBy().OrderBy(), а не OrderBy().ThenBy().
Взгляните на пример. Допустим, у нас есть класс Wrapper и массив экземпляров следующего типа:
class Wrapper { public int Primary { get; init; } public int Secondary { get; init; } }
var arr = new Wrapper[] { new() { Primary = 1, Secondary = 2 }, new() { Primary = 0, Secondary = 1 }, new() { Primary = 2, Secondary = 1 }, new() { Primary = 2, Secondary = 0 }, new() { Primary = 0, Secondary = 2 }, new() { Primary = 0, Secondary = 3 }, };
Мы хотим отсортировать этот массив: сначала по основному значению, затем по дополнительному.
Если мы произведем сортировку следующим образом — мы ошибемся:
var sorted = arr.OrderBy(p => p.Primary)
.OrderBy(p => p.Secondary);
....
Вот результат:
Primary: 2 Secondary: 0
Primary: 0 Secondary: 1
Primary: 2 Secondary: 1
Primary: 0 Secondary: 2
Primary: 1 Secondary: 2
Primary: 0 Secondary: 3
Получаем неверный результат. Чтобы правильно отсортировать коллекцию, нам нужно использовать OrderBy().ThenBy():
var sorted = arr.OrderBy(p => p.Primary)
.ThenBy(p => p.Secondary);
....
Вот результат:
Primary: 0 Secondary: 1
Primary: 0 Secondary: 2
Primary: 0 Secondary: 3
Primary: 1 Secondary: 2
Primary: 2 Secondary: 0
Primary: 2 Secondary: 1
Однако мы также можем использовать OrderBy().OrderBy(), чтобы получить правильный результат. Нам просто нужно поменять местами звонки.
Вот как мы не должны делать:
var sorted = arr.OrderBy(p => p.Primary)
.OrderBy(p => p.Secondary);
И вот как мы должны сделать:
var sorted = arr.OrderBy(p => p.Secondary)
.OrderBy(p => p.Primary);
Получается, что для получения правильного результата мы можем использовать оба варианта:
// #1 var sorted1 = arr.OrderBy(p => p.Secondary) .OrderBy(p => p.Primary);
// #2 var sorted2 = arr.OrderBy(p => p.Primary) .ThenBy(p => p.Secondary);
Я думаю, что второй вариант более читабелен.
Когда вы видите OrderBy().OrderBy(), вы задаетесь вопросом: а не ошибка ли это? Поэтому лучше использовать OrderBy().ThenBy(): код читается легче, и замысел разработчика понятен.
Однако эти методы сортировки различаются не только внешне: различаются также их производительность и потребление памяти.
Сравнение производительности
Давайте поэкспериментируем со следующим кодом:
struct Wrapper { public int Primary { get; init; } public int Secondary { get; init; } }
Wrapper[] arr = ....;
// #1 _ = arr.OrderBy(p => p.Secondary) .OrderBy(p => p.Primary) .ToArray();
// #2 _ = arr.OrderBy(p => p.Primary) .ThenBy(p => p.Secondary) .ToArray();
Итак, резюмируем основные моменты:
- Оболочка – это структура с двумя целочисленными свойствами. Мы будем использовать их как ключи для сортировки;
- arr — это массив экземпляров Wrapper, которые нам нужно отсортировать. То, как он был создан, не имеет значения для тестов. Измерим производительность сортировки и получения конечного массива;
- есть два способа сортировки: первый — OrderBy().OrderBy(), второй — OrderBy().ThenBy();
- вызов ToArray() используется для инициации сортировки.
Для запуска тестов я взял два набора сгенерированных тестовых данных (экземпляры типа Wrapper). В первом наборе разброс значений Primary и Secondary больше, во втором меньше. Я записал объекты Wrapper (от 10 до 1 000 000) в arr и отсортировал их.
Тестовый проект работает на .NET 6.
Производительность
Я использовал BenchmarkDotNet для отслеживания производительности.
Ниже вы можете увидеть, сколько времени ушло на выполнение сортировки и получение массива. Нас не интересуют абсолютные значения — важнее разница между методами сортировки.
Набор данных №1:
Набор данных №2:
Пойдем дальше и посмотрим, какая разница во времени, если мы проведем сразу несколько сортировок. Для этого мы используем цикл for:
for (int i = 0; i < iterNum; ++i)
{
// Perform sorting
}
Вот время (в секундах), затрачиваемое на сортировку 1 000 000 экземпляров типа Wrapper:
Что ж, разница в 20 секунд стоит учитывать.
Потребление памяти
То же самое и с памятью — OrderBy().OrderBy() потребляет больше. Особенно это заметно на больших объемах данных и нескольких итерациях.
Вот разница в количестве объектов, созданных за итерацию:
Как следует из таблицы, вызовы OrderBy().OrderBy() создают еще два массива. Когда я запускал тест со 100 сортировками, я получил разницу в 1 ГБ выделенной памяти.
Важно отметить, что чем больше отсортированная коллекция, тем больше «лишние» массивы. В результате увеличивается и объем потребляемой памяти.
Различия в поведении
А теперь пора заглянуть «под капот». Напомню — мы рассматриваем два способа сортировки:
// #1 _ = arr.OrderBy(p => p.Secondary) .OrderBy(p => p.Primary) .ToArray();
// #2 _ = arr.OrderBy(p => p.Primary) .ThenBy(p => p.Secondary) .ToArray();
Чтобы понять разницу, нам нужно проанализировать:
- вызываемые методы;
- состояние объектов, для которых вызываются методы;
- поток выполнения.
Исходный код .NET 6 доступен на GitHub.
Методы верхнего уровня
Нам нужно обсудить три метода верхнего уровня: OrderBy, ThenBy и ToArray. Рассмотрим каждый из них.
По порядку
OrderBy — это метод расширения, который возвращает экземпляр типа OrderedEnumerable‹TElement, TKey›:
public static IOrderedEnumerable<TSource>
OrderBy<TSource, TKey>(this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector)
=> new OrderedEnumerable<TSource,
TKey>(source,
keySelector,
null,
false,
null);
Вот конструктор OrderedEnumerable‹TElement, TKey›:
internal OrderedEnumerable( IEnumerable<TElement> source,
Func<TElement, TKey> keySelector,
IComparer<TKey>? comparer,
bool descending,
OrderedEnumerable<TElement>? parent
) : base(source)
{
....
_parent = parent;
_keySelector = keySelector;
_comparer = comparer ?? Comparer<TKey>.Default;
_descending = descending;
}
Здесь нас интересует вызов базового конструктора — base(source). Базовый тип — OrderedEnumerable‹TElement›. Конструктор выглядит следующим образом:
protected OrderedEnumerable(IEnumerable<TElement> source)
=> _source = source;
Давайте освежим в памяти то, что мы уже обсуждали: в результате вызова OrderBy создается экземпляр OrderedEnumerable‹TElement, TKey›. Его состояние определяется следующими полями:
- _источник;
- _родитель;
- _ключСелектор;
- _компаратор;
- _по убыванию.
Затем
ThenBy — это метод расширения:
public static IOrderedEnumerable<TSource>
ThenBy<TSource, TKey>(this IOrderedEnumerable<TSource> source,
Func<TSource, TKey> keySelector)
{
....
return source.CreateOrderedEnumerable(keySelector, null, false);
}
В нашем случае тип переменной source — OrderedEnumerable‹TElement, TKey›. Давайте взглянем на реализацию метода CreateOrderedEnumerable, который будет вызываться:
IOrderedEnumerable<TElement>
IOrderedEnumerable<TElement>
.CreateOrderedEnumerable<TKey>(Func<TElement, TKey> keySelector,
IComparer<TKey>? comparer,
bool descending)
=> new OrderedEnumerable<TElement,
TKey>(_source,
keySelector,
comparer,
@descending,
this);
Мы видим, что вызывается конструктор типа OrderedEnumerable‹TElement, TKey› (который мы уже обсуждали в разделе OrderBy). Аргументы вызова различаются, и, как следствие, различается состояние создаваемого объекта.
Уточним: ThenBy (как и OrderBy), в нашем случае возвращает экземпляр типа OrderedEnumerable‹TElement, TKey›.
В массив
ToArray — это метод расширения:
public static TSource[] ToArray<TSource>(this IEnumerable<TSource> source)
{
....
return source is IIListProvider<TSource> arrayProvider
? arrayProvider.ToArray()
: EnumerableHelpers.ToArray(source);
}
В обоих случаях source — это экземпляры типа OrderedEnumerable‹TElement, TKey›. Этот тип реализует интерфейс IIlistProvider‹TSource›. Поэтому выполнение будет проходить через вызов arrayProvider.ToArray(). Фактически будет вызван метод OrderedEnumerable‹TElement›.ToArray:
public TElement[] ToArray() { Buffer<TElement> buffer = new Buffer<TElement>(_source);
int count = buffer._count; if (count == 0) { return buffer._items; }
TElement[] array = new TElement[count]; int[] map = SortedMap(buffer); for (int i = 0; i != array.Length; i++) { array[i] = buffer._items[map[i]]; }
return array; }
И здесь происходят основные отличия. Прежде чем мы продолжим погружаться глубже, нам нужно изучить состояние объектов, с которыми мы будем работать.
Состояния объектов OrderedEnumerable
Вернемся к исходным примерам:
// #1 _ = arr.OrderBy(p => p.Secondary) // Wrapper[] -> #1.1 .OrderBy(p => p.Primary) // #1.1 -> #1.2 .ToArray(); // #1.2 -> Wrapper[]
// #2 _ = arr.OrderBy(p => p.Primary) // Wrapper[] -> #2.1 .ThenBy(p => p.Secondary) // #2.1 -> #2.2 .ToArray(); // #2.2 -> Wrapper[]
Нам нужно сравнить четыре объекта, расположенных парами:
- #1.1 и #2.1 — это объекты, созданные первыми вызовами OrderBy в обоих примерах;
- #1.2 и #2.2 — это объекты, созданные вторым вызовом OrderBy в первом примере и вызовом ThenBy во втором примере.
В итоге получаем 2 таблицы сравнения состояний объектов.
Вот состояния объектов, созданных первыми вызовами OrderBy:
Эта пара идентична. Отличаются только селекторы.
Вот состояния объектов, созданных вторым вызовом функций OrderBy (№1.2) и ThenBy (№2.2):
Здесь селекторы тоже отличаются — и это ожидаемо. Любопытно, что поля _source и _parent различаются. Состояние объекта при вызове ThenBy (#2.2) выглядит более корректным: сохраняется ссылка на исходную коллекцию, и есть «родитель» — результат предыдущей сортировки.
Поток выполнения
Теперь нам нужно выяснить, как состояние объектов влияет на поток выполнения.
Вернемся к методу ToArray:
public TElement[] ToArray() { Buffer<TElement> buffer = new Buffer<TElement>(_source);
int count = buffer._count; if (count == 0) { return buffer._items; }
TElement[] array = new TElement[count]; int[] map = SortedMap(buffer); for (int i = 0; i != array.Length; i++) { array[i] = buffer._items[map[i]]; }
return array; }
Имейте в виду, что объекты, полученные разными вызовами, имеют разные поля _source:
- OrderBy().OrderBy() относится к экземпляру OrderedEnumerable‹TElement, TKey›;
- OrderBy().ThenBy() относится к экземпляру Wrapper[].
Рассмотрим определение типа Buffer‹TElement›:
internal readonly struct Buffer<TElement> { internal readonly TElement[] _items; internal readonly int _count;
internal Buffer(IEnumerable<TElement> source) { if (source is IIListProvider<TElement> iterator) { TElement[] array = iterator.ToArray(); _items = array; _count = array.Length; } else { _items = EnumerableHelpers.ToArray(source, out _count); } } }
Здесь поведение начинает отличаться:
- для вызовов OrderBy().OrderBy() выполнение следует по ветви then, поскольку OrderedEnumerable реализует интерфейс IIListProvider‹TElement›;
- для вызовов OrderBy().ThenBy() выполнение следует по ветке else, потому что массивы (в нашем случаеэто Wrapper[]) не реализовывать этот интерфейс.
В первом случае мы возвращаемся к методу ToArray, приведенному выше. Затем мы снова попадаем в конструктор Buffer, но выполнение будет следовать по ветке else, поскольку _source объекта #1.1 — это Wrapper[].
EnumerableHelpers.ToArray просто создает копию массива:
internal static T[] ToArray<T>(IEnumerable<T> source, out int length) { if (source is ICollection<T> ic) { int count = ic.Count; if (count != 0) { T[] arr = new T[count]; ic.CopyTo(arr, 0); length = count; return arr; } } else ....
.... }
Исполнение следует за ветвью then. Остальной код я опустил, потому что в нашем случае это неважно.
Разница более очевидна в стеках вызовов. Обратите внимание на выделенные жирным шрифтом «лишние» звонки:
Кстати, этим же объясняется и разница в количестве создаваемых объектов. Вот таблица, которую мы обсуждали ранее:
Наиболее интересные массивы здесь: Int32[] и Wrapper[]. Они возникают из-за того, что поток выполнения без необходимости проходит через метод OrderedEnumerable‹TElement›.ToArray еще раз:
public TElement[] ToArray()
{
....
TElement[] array = new TElement[count];
int[] map = SortedMap(buffer);
....
}
Напомню, что размеры массивов array и map зависят от размера отсортированной коллекции: чем она больше, тем больше будет накладных расходов — из-за ненужный вызов OrderedEnumerable‹TElement›.ToArray.
То же самое и с производительностью. Давайте еще раз взглянем на код метода OrderedEnumerable‹TElement›.ToArray:
public TElement[] ToArray() { Buffer<TElement> buffer = new Buffer<TElement>(_source);
int count = buffer._count; if (count == 0) { return buffer._items; }
TElement[] array = new TElement[count]; int[] map = SortedMap(buffer); for (int i = 0; i != array.Length; i++) { array[i] = buffer._items[map[i]]; }
return array; }
Нас интересует массив map. Он описывает взаимосвязь между позициями элементов в массивах:
- index — позиция элемента в результирующем массиве;
- значение индекса — это позиция в исходном массиве.
Допустим, map[5] == 62. Это означает, что элемент занимает 62-ю позицию в исходном массиве и 5-ю позицию в результирующем массиве.
Для получения так называемой «карты отношений» используется метод SortedMap:
private int[] SortedMap(Buffer<TElement> buffer)
=> GetEnumerableSorter().Sort(buffer._items, buffer._count);
Вот метод GetEnumerableSorter:
private EnumerableSorter<TElement> GetEnumerableSorter()
=> GetEnumerableSorter(null);
Давайте посмотрим на перегрузку метода:
internal override EnumerableSorter<TElement> GetEnumerableSorter(EnumerableSorter<TElement>? next) { ....
EnumerableSorter<TElement> sorter = new EnumerableSorter<TElement, TKey>(_keySelector, comparer, _descending, next); if (_parent != null) { sorter = _parent.GetEnumerableSorter(sorter); }
return sorter; }
Здесь возникает еще одно различие между методами сортировки, которые мы обсуждаем:
- OrderBy().OrderBy(): _parent объекта #1.2 имеет значение null. В результате создается один экземпляр EnumerableSorter.
- OrderBy().ThenBy(): _parent объекта № 2.2 указывает на объект № 2.1. Это означает, что будут созданы два экземпляра EnumerableSorter, связанные друг с другом. Это происходит потому, что метод _parent.GetEnumerableSorter(sorter) вызывается еще раз.
Вот вызываемый конструктор EnumerableSorter:
internal EnumerableSorter(
Func<TElement, TKey> keySelector,
IComparer<TKey> comparer,
bool descending,
EnumerableSorter<TElement>? next)
{
_keySelector = keySelector;
_comparer = comparer;
_descending = descending;
_next = next;
}
Конструктор просто инициализирует поля объекта. Есть еще одно поле, которое не используется в конструкторе — _keys. Он будет инициализирован позже в методе ComputeKeys.
Рассмотрим, за что отвечают поля. Для этого давайте обсудим один из способов сортировки:
_ = arr.OrderBy(p => p.Primary)
.ThenBy(p => p.Secondary)
.ToArray();
Для выполнения сортировки через OrderBy будет создан экземпляр EnumerableSorter. Он имеет следующие поля:
- _keySelector: делегат, отвечающий за сопоставление исходного объекта с ключом. В нашем случае это Wrapper -> int. Делегат: p =› p.Primary;
- _comparer: компаратор, используемый для сравнения ключей. Comparer‹T›.Default, если не указано явно;
- _descenging: флаг, означающий, что коллекция отсортирована в порядке убывания;
- _next: ссылка на объект EnumerableSorter, отвечающий за следующие критерии сортировки. В примере выше есть ссылка на объект, созданный для сортировки по критерию из ThenBy.
После создания и инициализации экземпляра EnumerableSorter для него вызывается метод Sort:
private int[] SortedMap(Buffer<TElement> buffer)
=> GetEnumerableSorter().Sort(buffer._items, buffer._count);
Вот тело метода Sort:
internal int[] Sort(TElement[] elements, int count)
{
int[] map = ComputeMap(elements, count);
QuickSort(map, 0, count - 1);
return map;
}
Вот метод ComputeMap:
private int[] ComputeMap(TElement[] elements, int count) { ComputeKeys(elements, count); int[] map = new int[count]; for (int i = 0; i < map.Length; i++) { map[i] = i; }
return map; }
Давайте взглянем на метод ComputeKeys:
internal override void ComputeKeys(TElement[] elements, int count) { _keys = new TKey[count]; for (int i = 0; i < count; i++) { _keys[i] = _keySelector(elements[i]); }
_next?.ComputeKeys(elements, count); }
В этом методе инициализируется массив _keys экземпляра EnumerableSorter. Вызов _next?.ComputeKeys(elements, count) позволяет инициализировать всю цепочку связанных объектов EnumerableSorter.
Зачем нам нужно поле _keys? Массив хранит результаты вызова селектора для каждого элемента исходного массива. Итак, мы получаем массив ключей, которые будут использоваться для выполнения сортировки.
Вот пример:
var arr = new Wrapper[] { new() { Primary = 3, Secondary = 2 }, new() { Primary = 3, Secondary = 1 }, new() { Primary = 1, Secondary = 0 } };
_ = arr.OrderBy(p => p.Primary) .ThenBy(p => p.Secondary) .ToArray();
В этом примере будут созданы два связанных экземпляра EnumerableSorter.
Таким образом, _keys хранит ключи сортировки для каждого элемента исходного массива.
Вернемся к методу ComputeMap:
private int[] ComputeMap(TElement[] elements, int count) { ComputeKeys(elements, count); int[] map = new int[count]; for (int i = 0; i < map.Length; i++) { map[i] = i; }
return map; }
После вызова метода ComputeKeys создается и инициализируется массив map. Это массив, описывающий взаимосвязь между позициями в исходном и результирующем массивах. В этом методе он по-прежнему описывает отношение i → i. Это означает, что позиции в исходном массиве совпадают с позициями в результирующем массиве.
Вернемся к методу Sort:
internal int[] Sort(TElement[] elements, int count)
{
int[] map = ComputeMap(elements, count);
QuickSort(map, 0, count - 1);
return map;
}
Нас интересует метод QuickSort, который заставляет массив map выглядеть так, как нам нужно. Сразу после этой операции мы получаем правильное соотношение между позициями элементов в исходном массиве и в результирующем.
Вот тело метода QuickSort:
protected override void QuickSort(int[] keys, int lo, int hi)
=> new Span<int>(keys, lo, hi - lo + 1).Sort(CompareAnyKeys);
Мы не будем углубляться в детали Span и его метода Sort. Обратим внимание на тот факт, что он сортирует массив с учетом делегата Comparison:
public delegate int Comparison<in T>(T x, T y);
Это классический делегат для сравнения. Он принимает два элемента, сравнивает их и возвращает значение:
- ‹ 0, если x меньше, чем y;
- 0, если x равно y;
- › 0, если x больше, чем y.
В нашем случае для сравнения используется метод CompareAnyKeys:
internal override int CompareAnyKeys(int index1, int index2) { Debug.Assert(_keys != null);
int c = _comparer.Compare(_keys[index1], _keys[index2]); if (c == 0) { if (_next == null) { return index1 - index2; // ensure stability of sort }
return _next.CompareAnyKeys(index1, index2); }
// .... return (_descending != (c > 0)) ? 1 : -1; }
Итак, давайте посмотрим, что у нас есть:
int c = _comparer.Compare(_keys[index1], _keys[index2]); if (c == 0) ....
return (_descending != (c > 0)) ? 1 : -1;
Два элемента сравниваются с помощью компаратора, написанного на _comparer. Поскольку компаратор явно не задан, используется Comparer‹T›.Default (в нашем случае — Comparer‹Int32›.Default).
Если элементы не равны, условие c == 0 не выполняется, и поток выполнения переходит к return. Поле _descending хранит информацию о том, как сортируются элементы — в порядке убывания или возрастания. При необходимости поле используется для исправления значения, возвращаемого методом.
Но что, если элементы равны?
if (c == 0) { if (_next == null) { return index1 - index2; // ensure stability of sort }
return _next.CompareAnyKeys(index1, index2); }
Вот цепочки экземпляров EnumerableSorter, связанных друг с другом. Если сравниваемые ключи равны, выполняется проверка на наличие других критериев сортировки. Если есть (_next != null), сравнение выполняется по ним.
Получается, что все критерии сортировки учитываются в одном вызове метода Sort.
Что произойдет, если мы используем OrderBy().OrderBy()? Чтобы выяснить это, вернемся к созданию экземпляра EnumerableSorter:
internal override EnumerableSorter<TElement> GetEnumerableSorter(EnumerableSorter<TElement>? next) { ....
EnumerableSorter<TElement> sorter = new EnumerableSorter<TElement, TKey>(_keySelector, comparer, _descending, next); if (_parent != null) { sorter = _parent.GetEnumerableSorter(sorter); }
return sorter; }
Значение _parent объекта, полученного в результате второго вызова OrderBy, равно null. Это означает, что создается один экземпляр EnumerableSorter. Это ни с чем не связано, значение _next равно null.
Получается, что все описанные выше действия нам нужно выполнить дважды. Мы уже обсуждали, как это влияет на производительность. Чтобы напомнить вам, я бы продублировал одну из таблиц, приведенных выше.
Вот время (в секундах), затрачиваемое на сортировку 1 000 000 экземпляров типа Wrapper:
Разница в двух словах
Методы OrderBy и ThenBy создают экземпляры OrderedEnumerable, которые используются для выполнения сортировки. Экземпляры типа EnumerableSorter помогают выполнять сортировку. Они влияют на алгоритм, используют указанные селекторы и компаратор.
Основное различие между вызовами OrderBy().OrderBy() и OrderBy().ThenBy() заключается в отношениях между объектами.
Порядок().Порядок(). Между OrderedEnumerable и EnumerableSorter нет никаких отношений. В результате создаются «лишние» объекты — вместо одной сортировки получается две. Потребление памяти растет, а код работает медленнее.
Порядок().ЗатемПо(). Экземпляры OrderedEnumerable и EnumerableSorter связаны между собой. Из-за этого одна операция сортировки выполняется сразу по нескольким критериям. Лишние объекты не создаются. Потребление памяти снижено, а код работает быстрее.
Заключение
Код, который использует OrderBy().ThenBy() вместо OrderBy().OrderBy():
- лучше читать;
- менее подвержен ошибкам;
- работает быстрее;
- потребляет меньше памяти.
Подписывайтесь на меня в Твиттере, если вам интересны подобные публикации.