Linq
Escopo: C#. Idiomas específicos deste ecossistema.
LINQ (Language Integrated Query · Consulta Integrada à Linguagem) é o jeito de transformar coleções em C#. Você encadeia operadores que descrevem o que quer (Where, Select, GroupBy) e o resultado só é calculado quando alguém percorre a coleção, comportamento chamado lazy evaluation (avaliação sob demanda). Essa característica define o bom uso: LINQ serve para consultar e transformar dados. Quando você coloca log, escrita ou chamada externa dentro da query, o momento em que essas ações acontecem passa a depender de quem percorre o resultado, e aí elas podem rodar duas vezes ou nenhuma.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| LINQ (Language Integrated Query · Consulta Integrada à Linguagem) | API de C# para transformar coleções de forma declarativa: Where, Select, GroupBy, OrderBy |
| IEnumerable<T> (sequência iterável) | Interface que expõe iteração em memória; cada operador aplica em sequência |
| IQueryable<T> (sequência consultável) | Interface que constrói árvore de expressão; provedor traduz para SQL/remote query |
| lazy evaluation (avaliação sob demanda) | A query só executa no momento da iteração (ToList, foreach); permite composição |
| side effect (efeito colateral) | Mudança de estado externo (log, escrita, I/O); proibido dentro de queries LINQ |
| deferred execution (execução adiada) | Resultado materializado só quando enumerado; ToList()/ToArray() força execução |
| method syntax (sintaxe de método) | Forma fluente xs.Where(...).Select(...); preferida no projeto sobre query syntax |
A query transforma dados e nada mais
Dentro de um Select, escreva só o cálculo que produz o novo valor. Um _logger.LogInformation ou um order.ProcessedAt = ... ali dentro roda quando a coleção for percorrida, e a coleção pode ser percorrida duas vezes, ou nunca. O log sai duplicado, o campo é escrito de novo, e nada disso aparece lendo a query. Separe: o LINQ produz a lista, e o foreach logo abaixo executa as ações.
❌ Ruim: side effect dentro de query LINQ
var summaries = orders
.Where(order => order.IsActive)
.Select(order =>
{
_logger.LogInformation("Processing order {Id}", order.Id); // side effect
order.ProcessedAt = DateTime.UtcNow; // altera estado
return new OrderSummary(order.Id, order.Total);
})
.ToList();
✅ Bom: LINQ transforma, foreach executa side effects
var activeOrders = orders
.Where(order => order.IsActive)
.ToList();
var summaries = activeOrders
.Select(order => new OrderSummary(order.Id, order.Total))
.ToList();
foreach (var order in activeOrders)
_logger.LogInformation("Processing order {Id}", order.Id);
Quando usar Select e quando usar foreach
Select cabe quando cada item de entrada vira exatamente um item de saída. É o caso de transformar pedidos em resumos: dez pedidos, dez resumos. Escrever isso com foreach e uma lista que recebe Add a cada volta funciona, mas gasta quatro linhas para dizer o que Select diz em uma.
foreach cabe quando o resultado não acompanha a entrada item a item: somar tudo num total, disparar ações. Aggregate também soma, e vale saber que ele existe, mas ler Aggregate(0m, (acc, o) => acc + ...) custa mais atenção do que ler um foreach com uma variável chamada totalRevenue.
❌ Ruim: Aggregate onde foreach é mais claro
var totalRevenue = orders.Aggregate(
0m,
(accumulated, order) => accumulated + order.Items.Sum(item => item.Price * item.Quantity) // difícil de rastrear
);
❌ Ruim: foreach manual para construir lista, sem Select
var summaries = new List<OrderSummary>();
foreach (var order in orders)
{
var summary = new OrderSummary(order.Id, order.Total, order.CreatedAt);
summaries.Add(summary); // acumulação manual para transformação 1-para-1: Select é mais direto
}
✅ Bom: foreach com variável de acumulação explícita
decimal totalRevenue = 0;
foreach (var order in orders)
foreach (var item in order.Items)
totalRevenue += item.Price * item.Quantity;
✅ Bom: Select para transformação 1-para-1
var summaries = orders
.Select(order => new OrderSummary(order.Id, order.Total, order.CreatedAt))
.ToList();
Materializar só no limite
Enquanto a query é um IEnumerable<T>, ela é uma receita, e nada foi calculado. .ToList() executa a receita e guarda o resultado na memória. Chame .ToList() uma vez, no fim, quando for devolver o resultado ou percorrê-lo várias vezes. Cada .ToList() no meio do caminho cria uma lista intermediária que existe só para o próximo operador ler e descartar.
❌ Ruim: materialização prematura no meio do pipeline
public IEnumerable<OrderSummary> BuildSummaries(IEnumerable<Order> orders, DateTime cutoff)
{
var activeOrders = orders
.Where(order => order.IsActive)
.ToList(); // materializa cedo
var recentOrders = activeOrders
.Where(order => order.CreatedAt > cutoff)
.ToList(); // materializa de novo
var summaries = recentOrders
.Select(order => new OrderSummary(order.Id, order.Total));
return summaries;
}
✅ Bom: pipeline lazy, materialização única no limite
public IReadOnlyList<OrderSummary> BuildSummaries(IEnumerable<Order> orders, DateTime cutoff)
{
var summaries = orders
.Where(order => order.IsActive)
.Where(order => order.CreatedAt > cutoff)
.Select(order => new OrderSummary(order.Id, order.Total))
.ToList();
return summaries;
}
Cadeias longas viram etapas com nome
Uma cadeia que filtra, agrupa, projeta, ordena e corta os dez primeiros faz cinco coisas, e quem lê precisa segurar as cinco na cabeça até o ToList(). Quebre em variáveis com nome: recentConfirmedOrders, customerGroups, report. Cada nome responde uma pergunta que o leitor faria, e a linha densa de projeção vira um método com nome próprio.
❌ Ruim: chain monolítica, difícil de rastrear
var report = orders
.Where(o => o.Status == "CONFIRMED" && o.CreatedAt > DateTime.UtcNow.AddDays(-30))
.GroupBy(o => o.CustomerId)
.Select(g => new CustomerReport(g.Key, g.Sum(o => o.Total), g.Count(), g.Max(o => o.CreatedAt))) // linha densa
.OrderByDescending(r => r.Total)
.Take(10)
.ToList();
✅ Bom: etapas nomeadas, cada uma com responsabilidade clara
var recentConfirmedOrders = orders
.Where(order => order.Status == "CONFIRMED")
.Where(order => order.CreatedAt > DateTime.UtcNow.AddDays(-30));
var customerGroups = recentConfirmedOrders
.GroupBy(order => order.CustomerId);
var report = customerGroups
.Select(group => BuildCustomerReport(group))
.OrderByDescending(customerReport => customerReport.Total)
.Take(10)
.ToList();
return report;
static CustomerReport BuildCustomerReport(IGrouping<Guid, Order> group)
{
decimal total = 0;
foreach (var order in group)
total += order.Total;
var customerReport = new CustomerReport(
group.Key,
total,
group.Count(),
group.Max(order => order.CreatedAt)
);
return customerReport;
}
Left join com coleções em memória
Join devolve só os pares que casaram: um pedido sem pagamento some do resultado, e um relatório de inadimplência construído assim mostra zero. Para manter todos os itens da esquerda e aceitar a ausência do lado direito, use GroupJoin seguido de SelectMany com DefaultIfEmpty(). O pagamento vem null quando não existe, e o ?.Amount trata esse caso.
Para queries EF Core 10+, use o operador
LeftJoinnativo. Veja Entity Framework.
❌ Ruim: Join exclui registros sem correspondência
var result = orders
.Join(
payments,
order => order.Id,
payment => payment.OrderId,
(order, payment) => new OrderPaymentView(order.Id, order.Total, payment.Amount) // orders sem payment são excluídas
)
.ToList();
✅ Bom: GroupJoin + SelectMany preserva todos os registros do lado esquerdo
var result = orders
.GroupJoin(
payments,
order => order.Id,
payment => payment.OrderId,
(order, matchedPayments) => new { order, matchedPayments }
)
.SelectMany(
grouped => grouped.matchedPayments.DefaultIfEmpty(),
(grouped, payment) => new OrderPaymentView(
grouped.order.Id,
grouped.order.Total,
payment?.Amount // null quando não há pagamento correspondente
)
)
.ToList();
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.