Рассмотрим следующий перечислитель:
var items = (new int[] { 1, 2, 3, 4, 5 }).Select(x =>
{
Console.WriteLine($"inspect {x}");
return x;
});
Это дает элементы [1, 2, 3, 4, 5]
, печатая их по мере их использования.
Когда я вызываю метод Last
для этого перечислителя, он запускает быстрый путь, который обращается только к одному элементу:
items.Last();
inspect 5
Но когда я передаю обратный вызов Last
, он перебирает весь список с самого начала:
items.Last(x => true);
inspect 1
inspect 2
inspect 3
inspect 4
inspect 5
Просматривая исходный код .NET Core, я обнаружил, что:
-
Last(IEnumerable<T>)
пересылается вTryGetLast(IEnumerable<T>, out bool)
; -
TryGetLast(IEnumerable<T>, out bool)
имеет быстрый путь дляIPartition<T>
; - А поскольку
ArraySelectIterator<T>
реализуетIPartition<T>
, этот быстрый путь запущен, и все в порядке.
С другой стороны:
-
Last(IEnumerable<T>, Func<T, bool>)
перенаправляет вTryGetLast(IEnumerable<T>, Func<T, bool>, out bool)
- Это быстрые пути для
OrderedEnumerator
иIList<T>
, но неArraySelectIterator<T>
. - Поэтому он берет медленный путь и повторяется с самого начала.
Это объясняет, как случай обратного вызова не оптимизирован. Но это не объясняет почему.
Концептуально, если хотя бы один элемент удовлетворяет предикату (что вероятно на практике), то итерация в обратном направлении может позволить досрочно выйти из цикла.
Это также не кажется сложным для реализации: из того, что я видел, все, что требуется, это дополнительный метод в IPartition<T>
.
Отсутствие оптимизации также может быть удивительным. Поскольку эти перегрузки имеют одно и то же имя, можно предположить, что они также оптимизированы аналогичным образом. (По крайней мере, так я и думал.)
Учитывая эти причины для оптимизации этого случая, почему авторы LINQ решили не делать этого?