Предположим, есть задача: нужно отсортировать коллекцию по нескольким ключам. В C# это можно сделать с помощью вызовов OrderBy().OrderBy() или OrderBy().ThenBy(). Но в чём разница между этими вызовами? Чтобы ответить на этот вопрос, придётся покопаться в исходниках.


0991_OrderBy_ThenBy_ru/image1.png


Статья состоит из трёх основных разделов:


  • Предыстория. Для тех, кто любит затравки. История о том, откуда вообще возникла идея провести исследование и изучить, в чём разница между 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 },
};

Мы хотим отсортировать этот массив: сначала по значению Primary, затем – по Secondary.


По ошибке сортировку можно выполнить так:


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();

Зафиксируем основные моменты:


  • Wrapper – структура с двумя целочисленными свойствами. Они будут использоваться в качестве ключей для сортировок;
  • arr – массив экземпляров Wrapper, который нужно отсортировать. Как он получается – для тестов неважно. Измерять будем только скорость сортировки и получения итогового массива;
  • два способа сортировки: первый – через вызовы OrderBy().OrderBy(), второй – OrderBy().ThenBy();
  • вызов ToArray() нужен, чтобы инициировать выполнение сортировки.

Для тестов я взял два набора сгенерированных тестовых данных (экземпляры типа Wrapper). В первом наборе разброс значений Primary и Secondary больше, во втором – меньше. В arr записывал от 10 до 1 000 000 объектов Wrapper и сортировал их.


Тестовый проект работает на .NET 6.


Производительность


Время работы измерял с помощью BenchmarkDotNet.


Ниже привожу результаты времени выполнения сортировки и получения массива. Абсолютные значения не так интересны – важна разница между способами сортировок.


Набор данных #1:


arr.Length 10 100 1 000 10 000 100 000 1 000 000
OrderBy().OrderBy() 619 ns 9 us 170 us 2 ms 25.8 ms 315 ms
OrderBy().ThenBy() 285 ns 4.5 us 100 us 1.4 ms 20.4 ms 271 ms
Соотношение 2.17 2 1.7 1.43 1.26 1.16

Набор данных #2:


arr.Length 10 100 1 000 10 000 100 000 1 000 000
OrderBy().OrderBy() 553.3 ns 8.7 us 154 us 2.1 ms 29.5 ms 364 ms
OrderBy().ThenBy() 316.4 ns 4.2 us 80 us 1.1 ms 16.9 ms 240 ms
Соотношение 1.75 2.07 1.93 1.91 1.75 1.52

Можно пойти дальше и посмотреть разницу во времени, если выполнять не одну операцию сортировки, а несколько. Для этого воспользуемся циклом for:


for (int i = 0; i < iterNum; ++i)
{
  // Perform sorting
}

Время сортировки (в секундах) 1 000 000 экземпляров типа Wrapper:


Количество итераций 1 10 100
OrderBy().OrderBy() 0.819 6.52 65.15
OrderBy().ThenBy() 0.571 5.21 42.94
Соотношение 1.43 1.25 1.30

Согласитесь, разница в 20 секунд – повод призадуматься.


Использование памяти


С памятью похожая история – OrderBy().OrderBy() потребляет больше. Сильнее заметно на больших объёмах данных и нескольких итерациях.


Разница в количестве создаваемых объектов на одной итерации:


Тип OrderBy().OrderBy() OrderBy().ThenBy()
Int32[] 4 3
Comparison<Int32> 2 1
Wrapper[] 3 2

Из таблицы видно, что вызовы 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


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>. Его состояние определяется полями:


  • _source;
  • _parent;
  • _keySelector;
  • _comparer;
  • _descending.

ThenBy


ThenBy — метод расширения:


public static IOrderedEnumerable<TSource> 
ThenBy<TSource, TKey>(this IOrderedEnumerable<TSource> source, 
                      Func<TSource, TKey> keySelector)
{
  ....
  return source.CreateOrderedEnumerable(keySelector, null, false);
}

В нашем случае тип переменной sourceOrderedEnumerable<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


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:


Поле Object #1.1 Object #2.1
_source arr arr
_comparer Comparer<Int32>.Default Comparer<Int32>.Default
_descending false false
_keySelector p => p.Secondary p => p.Primary
_parent null null

Эта пара одинакова. Исключение – селекторы.


Состояния объектов, порождённых вторым вызовом OrderBy (#1.2) и ThenBy (#2.2):


Поле Object #1.2 Object #2.2
_source Object #1.1 arr
_comparer Comparer<Int32>.Default Comparer<Int32>.Default
_descending false false
_keySelector p => p.Primary p => p.Secondary
_parent null Object #2.1

Селекторы тоже различаются, это ожидаемо. Что более интересно – отличаются поля _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-ветке. Остальной код я опустил, т.к. в нашем случае он неважен.


Более наглядно разницу видно по стекам вызовов. Обратите внимание на выделенные "лишние" вызовы:


Call stack для OrderBy().OrderBy() Call stack для OrderBy().ThenBy()
EnumerableHelpers.ToArray EnumerableHelpers.ToArray
Buffer.ctor Buffer.ctor
OrderedEnumerable.ToArray OrderedEnumerable.ToArray
Buffer.ctor Enumerable.ToArray
OrderedEnumerable.ToArray Main
Enumerable.ToArray
Main

Отсюда, кстати, и отличие в количестве создаваемых объектов. Выше мы рассматривали таблицу с ними:


Тип OrderBy().OrderBy() OrderBy().ThenBy()
Int32[] 4 3
Comparison<Int32> 2 1
Wrapper[] 3 2

Наиболее интересными здесь выглядят массивы: 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. Он описывает отношения между позициями элементов в массивах:


  • индекс – позиция элемента в результирующем массиве;
  • значение по индексу – позиция в исходном массиве.

Допустим, 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.


Поле EnumerableSorter #1 EnumerableSorter #2
_keySelector p => p.Primary p => p.Secondary
_keys [3, 3, 1] [2, 1, 0]

Таким образом, _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. Он ни с чем не связан, значение _nextnull.


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


Время сортировки (в секундах) 1 000 000 экземпляров типа Wrapper:


Количество итераций 1 10 100
OrderBy().OrderBy() 0.819 6.52 65.15
OrderBy().ThenBy() 0.571 5.21 42.94

Разница в двух словах


Методы OrderBy и ThenBy создают экземпляры OrderedEnumerable, которые используются для выполнения сортировки. Помогают выполнять сортировку экземпляры типа EnumerableSorter. Именно они влияют на алгоритм, используют заданные селекторы и компаратор.


Основное различие между вызовами OrderBy().OrderBy() и OrderBy().ThenBy() – связи между объектами.


OrderBy().OrderBy(). Связей нет ни между OrderedEnumerable, ни между EnumerableSorter. Из-за этого создаются "лишние" объекты, проводится две сортировки, а не одна. Расходуется больше памяти, код работает медленнее.


OrderBy().ThenBy(). И экземпляры OrderedEnumerable, и EnumerableSorter связаны. Из-за этого выполняется одна операция сортировки сразу по нескольким критериям. Лишние объекты не создаются. Памяти потребляется меньше, код работает быстрее.


Выводы


Код, в котором OrderBy().ThenBy() используется вместо OrderBy().OrderBy():


  • лучше читается;
  • меньше подвержен ошибкам;
  • работает быстрее;
  • расходует меньше памяти.

Как обычно, приглашаю подписаться на Twitter, если интересны подобные публикации.

Комментарии (10)


  1. JordanCpp
    20.09.2022 13:52
    -4

    Еще ругают С++ за его адовые конструкции и 100500 способов сделать один и тот же результат.


  1. blowin
    20.09.2022 14:39
    +4

    Можно даже в один OrderBy)

    var items = arr.OrderBy(p => (p.Primary, p.Secondary));


    1. foto_shooter Автор
      20.09.2022 15:17

      ????

      Можно объявлять конкурс, кто извращённее изощрённее проведёт сортировку. :)


    1. a-tk
      20.09.2022 15:26

      А давно так можно стало? И где написано чтобы почитать, что ключ сортировки может быть кортежем? Что-то на MSDN не вижу сходу такого.


      1. blowin
        20.09.2022 15:44
        +4

        А в чём противоречие?

        В OrderBy может быть любой объект, для сортировки будет использоваться IComparer, по умолчанию Comparer<TKey>.Default.

        Т.е у ValueTuple реализован интерфейс сравнения и будет использоваться это сравнение объектов.


        1. a-tk
          21.09.2022 08:23

          И правда. Забавно.


  1. firegurafiku
    20.09.2022 15:23
    +9

    ... получить правильный результат можно и через последовательность вызовов OrderBy().OrderBy(), нужно только поменять вызовы местами.

    Чтобы это работало, в реализациии OrderBy должна использоваться устойчивая сортировка. В то же время, SQL-оператор ORDER BY не гарантирует устойчивость. Поэтому я бы не поручился за то, что все провайдеры LINQ-to-SQL правильно обработают это ваше .OrderBy().OrderBy().

    Неужели вообще кто-то так делает? Откуда возникла идея написать статью на тему «сравним в подробностях два способа сделать сортировку: очевидно правильный, быстрый и предсказуемый и очевидно неправильный, медленный и с граблями»?


    1. foto_shooter Автор
      20.09.2022 15:28
      -1

      Неужели вообще кто-то так делает? Откуда возникла идея написать статью на тему «сравним в подробностях два способа сделать сортировку: очевидно правильный, быстрый и предсказуемый и очевидно неправильный, медленный и с граблями»?

      В статье я отвечаю на этот вопрос в разделе "Предыстория". Можно почитать комменты на Хабре: тык.

      Некоторые из описанных в той статье вызовов OrderBy().OrderBy() разработчики править не стали (Unity, например).


    1. mvv-rus
      20.09.2022 23:18
      -1

      Поэтому я бы не поручился за то, что все провайдеры LINQ-to-SQL правильно обработают это ваше .OrderBy().OrderBy()

      В LINQ to Objects, когда на вход поступает нечто, реализующеее IEnumerable но не IQueryable, провадеры не используются, и OrderBy реализуется методом Enumerable.OrderBy. А для него документация явно сообщает, что сортировка является устойчивой.


  1. mvv-rus
    21.09.2022 00:10

    У метода Enumerable.OrderBy есть второй перегруженный вариант, который принимает дополнительным параметром интерфейс IComparer<TKey> Если сделать делегат keySelector возвращающим сам объект класса Wrapper — p=>p и реализовать и предать в OrderBy IComparer<Wrapper>, который сравнивает объекты Wrapper сначала по primary, а при их равенстве — по secondary), то можно просто один сетод OrderBy в этом варианте, чтобы выполнить однократную сортировку, а не двукратную.
    Да, это потребует пяток лишних строк кода, но, может, если уж речь зашла о производительности, оно того стоит?