Предположим, нам нужно отсортировать коллекцию по нескольким ключам. В 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);
}

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

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

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

Подписывайтесь на меня в Твиттере, если вам интересны подобные публикации.