Escrevendo Operadores LINQ e Como o LINQ É Implementado
Objective
O LINQ to Objects é C# comum dentro do System.Linq.dll: métodos de extensão
sobre IEnumerable<T>, classes de iterador e uma longa lista de verificações
de tipo que escolhem um caminho mais rápido quando a fonte é um array, um
List<T> ou outro iterador do LINQ. Conhecer esse código explica por que
Count() é instantâneo numa lista e lento numa query, por que
OrderBy(...).First() não ordena e quanto um pipeline custa em alocações. Este
conceito mostra como escrever um operador próprio no mesmo estilo e depois
percorre como a implementação do .NET 10 funciona, a partir do código do
runtime e de medições.
Use Cases
- Criar um operador de domínio (
EveryNth,WhereNotNull, umBatchcom regra própria) que se comporte como os nativos. - Escrever uma agregação numérica uma vez para todos os tipos de número.
- Entender um trace de profiler que mostra
ArrayWhereSelectIteratorouTryGetSpan. - Decidir se uma API que alimenta LINQ deve devolver
IEnumerable<T>, array ou lista.
Deep Dive
Escrevendo um operador: valide agora, itere depois
Um operador próprio é um método de extensão sobre IEnumerable<T>. Coloque a
validação dos argumentos no método público e o laço com yield num iterador
separado, para que argumento inválido falhe na chamada e não no primeiro
MoveNext(). LINQ, Execução Adiada e Iteradores explica por que essa divisão é
necessária:
csharppublic static class MyLinq
{
public static IEnumerable<T> EveryNth<T>(this IEnumerable<T> source, int n)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(n);
return Iterate(source, n);
static IEnumerable<T> Iterate(IEnumerable<T> source, int n)
{
int i = 0;
foreach (var item in source)
if (i++ % n == 0) yield return item;
}
}
}
Enumerable.Range(1, 10).EveryNth(3); // 1,4,7,10
MyLinq.EveryNth<int>(null!, 3); // ArgumentNullException já na chamadaO compilador transforma Iterate numa classe que é ao mesmo tempo o enumerável
e o seu primeiro enumerador. Chamar GetEnumerator() nela devolveu o próprio
objeto na primeira vez, na thread que o criou, e uma cópia nova enquanto esse
primeiro enumerador ainda estava em uso ou quando a chamada veio de outra
thread. Os iteradores do próprio LINQ seguem a mesma regra, então uma alocação
atende o caso comum de "monta e faz um foreach".
Uma agregação numérica com generic math
Desde o .NET 7 uma agregação pode ser escrita uma vez para todos os tipos
numéricos com as interfaces INumber<T> vistas em Generic math e membros
static abstract em interfaces. Um segundo parâmetro de tipo deixa o acumulador
ser mais largo que os elementos, que é exatamente como Enumerable.Sum é
construído:
csharppublic static TResult SumChecked<T, TResult>(this IEnumerable<T> source)
where T : INumber<T> where TResult : INumber<TResult>
{
ArgumentNullException.ThrowIfNull(source);
TResult sum = TResult.Zero;
foreach (var v in source) sum = checked(sum + TResult.CreateChecked(v));
return sum;
}
new byte[] { 200, 100 }.SumChecked<byte, int>(); // 300
new[] { 1.5, 2.5 }.SumChecked<double, double>(); // 4
new[] { int.MaxValue, 1 }.SumChecked<int, int>(); // OverflowExceptionO que um pipeline realmente constrói
Where não devolve um iterador genérico único. No código do .NET 10 ele
verifica o tipo da fonte e escolhe uma classe especializada, e todo iterador do
LINQ deriva de um Iterator<T> interno que pode sobrescrever Select,
Where, Skip, Count e First para o seu formato:
csharpint[] arr = Enumerable.Range(1, 10).ToArray();
arr.Where(x => x > 2); // ArrayWhereIterator`1
arr.ToList().Where(x => x > 2); // ListWhereIterator`1
Gen().Where(x => x > 2); // IEnumerableWhereIterator`1 (um método com yield)
arr.Where(x => x > 2).Select(x => x * 2); // ArrayWhereSelectIterator`2: um estágio fundido
arr.Where(x => x > 2).Where(x => x < 8); // ArrayWhereIterator`1: predicados combinados
Enumerable.Range(0, 5).Select(x => x); // RangeSelectIterator`2As versões de array e de lista acessam o armazenamento por índice em vez de
passar por IEnumerator<T>, e um Where seguido de Select vira um único
iterador que filtra e projeta no mesmo laço.
Contando sem enumerar
Count() testa primeiro ICollection<T> e lê o seu Count. Depois pergunta a
contagem a um iterador do LINQ e só então enumera.
TryGetNonEnumeratedCount (.NET 6) faz as mesmas verificações mas nunca
enumera:
| Fonte | TryGetNonEnumeratedCount |
|---|---|
List<int> com 10 itens |
10 |
arr.Select(...) |
10 |
Enumerable.Range(0, 5).Select(...) |
5 |
arr.Select(...).Skip(3) |
7 |
arr.Where(...) |
desconhecido (false) |
um método com yield |
desconhecido (false) |
Where não sabe a contagem sem rodar o predicado, e um método iterador é
opaco. Há uma sutileza nas projeções: espera-se que Select rode o seletor em
todo elemento quando Count() força a execução, então arr.Select(f).Count()
rodou f dez vezes, enquanto TryGetNonEnumeratedCount não rodou nenhuma e
ainda respondeu 10.
Caminhos rápidos: spans, e OrderBy sem ordenar
Vários operadores (Sum, Count(predicate), Any(predicate), Min, Max)
chamam primeiro um TryGetSpan interno, que só tem sucesso quando o tipo em
tempo de execução é exatamente T[] ou List<T> (ele compara GetType(), não
usa is). Aí o laço roda sobre um ReadOnlySpan<T>, sem enumerador nenhum.
Queries ordenadas também olham adiante. OrderBy(...).First() é respondido por
uma varredura única que guarda o menor elemento, e ElementAt usa uma seleção
parcial em vez de ordenar tudo:
csharp// 1.000 ints embaralhados, o comparer conta as próprias chamadas
data.OrderBy(x => x, counting).First(); // 999 comparações
data.OrderBy(x => x, counting).ToList(); // 10.917 comparaçõesSum: generic math mais vetorização
Enumerable.Sum é uma casca fina sobre um método genérico,
Sum<TSource, TResult> restrito a INumber<T>; Sum(IEnumerable<float>) o
chama com TResult = double. Quando a fonte é um span e os tipos de elemento e
de resultado coincidem, ele vetoriza int e long com Vector<T>, verificando
overflow em vetores inteiros pelos bits de sinal. O código exige
Vector<T>.Count > 2, então com vetores de 128 bits só int (4 posições) se
qualifica; long (2 posições) cai no laço escalar. Num Apple M4 (Arm64, 128
bits), sobre 10 milhões de elementos em Release:
| Chamada | Tempo |
|---|---|
int[].Sum() |
2 ms, igual a um laço for unchecked escrito à mão |
long[].Sum() |
10 ms |
IEnumerable<int> (um Select sobre o array) .Sum() |
19 ms |
O caminho do array é rápido e checked; esconder o array atrás de uma projeção
ou de um IEnumerable<T> próprio perde o span e os vetores.
Quanto um pipeline aloca
Medido por chamada com GC.GetAllocatedBytesForCurrentThread() depois do
aquecimento, no .NET 10:
Código sobre um int[] de 10 elementos |
Bytes |
|---|---|
arr.Sum() |
0 |
arr.Count(x => x > 2) |
0 |
arr.Where(x => x > 2).Sum() |
48: um ArrayWhereIterator |
foreach sobre arr.Where(...).Select(...) |
104: o iterador do Where mais o iterador fundido WhereSelect |
foreach sobre arr.EveryNth(2) |
96 |
As lambdas não capturam nada, então o compilador guarda os delegates em cache e eles não aparecem. Uma lambda com captura acrescenta uma closure e um delegate por chamada, como mediu Value Types, Reference Types e Boxing.
Trade-offs
- Método iterador versus enumerador escrito à mão.
yield returné curto e correto; uma classeIEnumerator<T>escrita à mão pode ganhar os caminhos rápidos que o LINQ usa (contagem conhecida, acesso por índice), mas é muito mais código para acertar. Escreva uma só depois que o profiler apontar para ela. - Expor
IEnumerable<T>versus um tipo concreto. DevolverIEnumerable<T>deixa a API flexível, mas se quem chama roda LINQ em cima, os caminhos rápidos de span e deICollection<T>só valem quando o objeto em tempo de execução é mesmo um array ou uma lista.csharpIEnumerable<int> Ids() => _ids; // continua int[]: Sum vetoriza IEnumerable<int> Ids() => _ids.Select(x => x); // opaco: laço escalar, iterador alocado - Confiar em otimizações internas.
OrderBy().First()e os iteradores fundidos são detalhes de implementação do .NET 10, não contratos documentados. Escreva a forma clara (MinBy) quando ela existir e trate o resto como bônus. - LINQ versus laço num caminho quente. Em arrays e listas,
Sum,CounteAnycom predicado não alocam nada e podem ganhar de um laço ingênuo; pipelines mais longos alocam um iterador por estágio a cada execução.