LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa

LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa

LINQ avançado em C#: GroupBy e tipos anônimos, CountBy e ToLookup, Join e a sintaxe de consulta, GroupJoin e o LeftJoin do .NET 10, SelectMany, a execução adiada e suas armadilhas, enumeração múltipla e um relatório de vendas que fecha centavo por centavo.
Linguagem C#

• • 16 min de leitura

"Quanto cada autor faturou?" e "quais livros nunca venderam?" não se respondem olhando um elemento de cada vez: é preciso agrupar e cruzar coleções, e saber exatamente quando a consulta é executada.

Em "LINQ, Parte I" o catálogo passou a responder perguntas com uma linha por pergunta, mas todas elas olhavam os produtos um a um: filtrar, ordenar, somar. Terminamos com perguntas que exigem mais: agrupar elementos por uma característica comum e combinar informações de coleções diferentes, como produtos e vendas. E ficou um mistério para resolver: por que o LINQ não executa a consulta no momento em que ela é escrita. Nesta aula resolvemos as duas coisas, e o relatório de vendas da Livraria fica pronto.

Os dados desta aula

Além do catálogo de "LINQ, Parte I", vamos registrar vendas. Cada venda é um fato imutável, então é um record, como vimos em "Classe, Struct ou Record":

public sealed record Venda(int ProdutoId, int Quantidade, DateOnly Data);

DateOnly, disponível desde o .NET 6, guarda só a data, sem hora. É o tipo certo para "dia da venda". Para não depender dos identificadores gerados na criação, montamos as vendas a partir dos próprios produtos:

var domImpresso = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10);
var memorias    = new LivroImpresso("Memórias Póstumas de Brás Cubas", "Machado de Assis", 44.90m, pesoGramas: 300, estoque: 0);
var domEbook    = new Ebook("Dom Casmurro", "Machado de Assis", 19.90m, tamanhoMb: 2.4);
var vidasSecas  = new LivroImpresso("Vidas Secas", "Graciliano Ramos", 38.00m, pesoGramas: 250, estoque: 4);
var saoBernardo = new Ebook("São Bernardo", "Graciliano Ramos", 22.90m, tamanhoMb: 1.6);
var sertao      = new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 2);
var horaEstrela = new Audiolivro("A Hora da Estrela", "Clarice Lispector", 34.90m, duracaoMinutos: 190, narrador: "Ana Lima");

List<Produto> catalogo = [domImpresso, memorias, domEbook, vidasSecas, saoBernardo, sertao, horaEstrela];

List<Venda> vendas =
[
    new(domImpresso.Id, 3, new DateOnly(2026, 9, 1)),
    new(domEbook.Id,    5, new DateOnly(2026, 9, 1)),
    new(vidasSecas.Id,  2, new DateOnly(2026, 9, 2)),
    new(domImpresso.Id, 1, new DateOnly(2026, 9, 2)),
    new(saoBernardo.Id, 4, new DateOnly(2026, 9, 3)),
    new(horaEstrela.Id, 1, new DateOnly(2026, 9, 3)),
    new(domEbook.Id,    2, new DateOnly(2026, 9, 3)),
];

Repare que as vendas guardam só o identificador do produto, e não o objeto inteiro. É exatamente assim que os dados chegam de um banco de dados, e é por isso que precisamos de junções.

Agrupando: GroupBy

GroupBy separa os elementos em grupos que compartilham uma chave. Cada grupo é um IGrouping<TChave, TElemento>: uma sequência com uma propriedade Key.

foreach (var grupo in catalogo.GroupBy(p => p.Formato))
{
    Console.WriteLine($"{grupo.Key}: {grupo.Count()} produto(s), média {grupo.Average(p => p.Preco):C}");
}
impresso: 4 produto(s), média R$ 57,95
e-book: 2 produto(s), média R$ 21,40
audiolivro: 1 produto(s), média R$ 34,90

Dentro de cada grupo valem todos os operadores da primeira parte: Count, Average, MaxBy, Where. A pergunta "qual o livro mais caro de cada formato?" fica assim:

var maisCaroPorFormato = catalogo
    .GroupBy(p => p.Formato)
    .Select(g => g.MaxBy(p => p.Preco)!);

Muitas vezes o resultado de um agrupamento é uma combinação de valores que não merece um tipo com nome. Para isso existem os tipos anônimos, criados com new { ... }:

var resumo = catalogo
    .GroupBy(p => p.Autor)
    .Select(g => new { Autor = g.Key, Titulos = g.Count(), MaisBarato = g.Min(p => p.Preco) })
    .OrderByDescending(x => x.Titulos);

foreach (var r in resumo)
    Console.WriteLine($"{r.Autor}: {r.Titulos} título(s), a partir de {r.MaisBarato:C}");

O compilador gera uma classe sem nome, com as propriedades Autor, Titulos e MaisBarato, imutáveis e com igualdade por conteúdo, como um record. Tipos anônimos são ótimos dentro de um método. Como não têm nome, não podem ser devolvidos de forma útil por um método público nem passados adiante. Quando o resultado precisa viajar, declare um record.

Para agrupamentos simples, o .NET 9 trouxe atalhos: CountBy conta por chave, e AggregateBy acumula por chave, sem montar os grupos inteiros:

foreach (var (autor, quantidade) in catalogo.CountBy(p => p.Autor))
    Console.WriteLine($"{autor}: {quantidade}");

ToLookup, citado no último exercício da primeira parte, faz um agrupamento imediato e indexável: catalogo.ToLookup(p => p.Titulo)["Dom Casmurro"] devolve os dois formatos. Chaves inexistentes devolvem uma sequência vazia, e não uma exceção.

Cruzando coleções: Join

A venda sabe o ProdutoId, mas não o preço nem o autor. Para calcular o faturamento, precisamos cruzar cada venda com o seu produto. Join combina elementos de duas sequências cujas chaves coincidem:

var vendasDetalhadas = vendas.Join(
    catalogo,
    venda => venda.ProdutoId,       // chave do lado das vendas
    produto => produto.Id,          // chave do lado dos produtos
    (venda, produto) => new
    {
        venda.Data,
        produto.Titulo,
        produto.Autor,
        produto.Formato,
        venda.Quantidade,
        Receita = produto.Preco * venda.Quantidade
    });

Para cada venda, o Join encontra o produto de mesmo identificador e monta o resultado com os dados dos dois. Por dentro, ele cria uma tabela de dispersão com as chaves de um dos lados, a mesma ideia do Dictionary de "Guardando Muitos Valores", e por isso a junção é rápida mesmo com coleções grandes. Nos tipos anônimos, escrever apenas venda.Data dá à propriedade o nome Data, sem precisar repeti-lo.

Aqui a sintaxe de consulta, apresentada na primeira parte, mostra sua vantagem, porque se lê quase como SQL:

var vendasDetalhadas =
    from venda in vendas
    join produto in catalogo on venda.ProdutoId equals produto.Id
    select new { venda.Data, produto.Titulo, produto.Autor, Receita = produto.Preco * venda.Quantidade };

Repare no equals em vez de ==: a sintaxe de consulta deixa claro que é uma junção por igualdade de chaves.

Junção externa: o que não tem correspondente

O Join é uma junção interna: só produz resultados quando há correspondência dos dois lados. Um produto que nunca vendeu simplesmente não aparece. Para a pergunta "quais produtos nunca venderam?", precisamos de todos os produtos, com ou sem vendas. GroupJoin faz isso: para cada produto, entrega a coleção (possivelmente vazia) das suas vendas.

var nuncaVendidos = catalogo
    .GroupJoin(vendas, p => p.Id, v => v.ProdutoId, (produto, vendasDoProduto) => new { produto, vendasDoProduto })
    .Where(x => !x.vendasDoProduto.Any())
    .Select(x => x.produto.Titulo);
// Memórias Póstumas de Brás Cubas, Grande Sertão: Veredas

A partir do .NET 10, a biblioteca ganhou os operadores LeftJoin e RightJoin, que expressam a junção externa diretamente, com o lado sem correspondência chegando como null. Em código para versões anteriores, o GroupJoin é o caminho. Em bancos de dados, na Fase 5, esse mesmo conceito aparecerá como LEFT JOIN.

Achatando: SelectMany

Quando cada elemento contém uma coleção, como pedidos que contêm itens, SelectMany transforma a "coleção de coleções" numa sequência única:

IEnumerable<ItemPedido> todosOsItens = pedidos.SelectMany(p => p.Itens);
int unidadesVendidas = pedidos.SelectMany(p => p.Itens).Sum(i => i.Quantidade);

Sem ele, seriam dois foreach aninhados. Pense no SelectMany sempre que ouvir "de todos os X, todos os seus Y".

Execução adiada: o mistério resolvido

Agora, a promessa da primeira parte. Observe este experimento, com uma lambda que anuncia cada vez que é chamada:

List<int> numeros = [1, 2, 3];

var consulta = numeros.Where(n =>
{
    Console.WriteLine($"  testando {n}");
    return n > 1;
});

Console.WriteLine("Consulta criada.");
numeros.Add(4);

foreach (int n in consulta)
    Console.WriteLine($"  -> {n}");
Consulta criada.
  testando 1
  testando 2
  -> 2
  testando 3
  -> 3
  testando 4
  -> 4

Três fatos saltam da saída.

Nada executou na criação. "Consulta criada." aparece antes de qualquer teste. Where não filtrou nada: devolveu um objeto que descreve o filtro.

A consulta viu o 4. O número foi adicionado depois de a consulta ser escrita, mas antes de ela ser percorrida. A consulta enxerga os dados do momento em que executa, e não do momento em que foi declarada.

Os elementos passam um a um. O teste do 2 e a impressão do 2 acontecem antes do teste do 3. O Where não montou uma lista de resultados: entregou cada elemento aprovado assim que o encontrou. Isso se chama processamento em streaming.

Esse comportamento, a execução adiada, tem vantagens reais. Consultas podem ser montadas em etapas, com filtros adicionados conforme a necessidade, sem custo até o fim. E First ou Take(3) param assim que têm o que precisam, sem percorrer o resto. Mas há três armadilhas.

Enumeração múltipla. Cada foreach, Count ou ToList sobre a mesma consulta a executa de novo, do zero. Se a origem for cara, como um arquivo lido linha a linha ou, na Fase 5, um banco de dados, o custo se repete sem que o código deixe isso visível.

Variáveis capturadas. Uma consulta que usa uma variável externa, como Where(p => p.Preco < limite), lê o valor de limite na hora em que executa, pelas regras de closure de "Delegates, Lambdas e Eventos".

Exceções tardias. Um erro dentro de uma lambda só aparece quando a consulta é percorrida, possivelmente longe da linha onde ela foi escrita.

A regra prática: operadores que devolvem IEnumerable<T> (Where, Select, OrderBy, GroupBy, Join, Skip, Take) são adiados. Operadores que devolvem um valor ou uma coleção concreta (ToList, ToArray, ToDictionary, Count, Sum, First, Any) executam na hora. Quando for usar um resultado mais de uma vez, ou quando quiser "fotografar" os dados de um momento, materialize-o com ToList().

Uma última nuance: nem todo operador adiado processa em streaming. OrderBy e GroupBy só conseguem entregar o primeiro elemento depois de ler todos, porque o menor pode ser o último. Eles são adiados, mas acumulam tudo em memória quando executam.

Juntando tudo: o relatório de vendas

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

// (catálogo e vendas declarados como no início da aula)

var detalhadas = vendas
    .Join(catalogo, v => v.ProdutoId, p => p.Id, (v, p) => new
    {
        v.Data, p.Titulo, p.Autor, p.Formato, v.Quantidade,
        Receita = p.Preco * v.Quantidade
    })
    .ToList();   // materializa: será usada em várias perguntas

Console.WriteLine($"Faturamento total: {detalhadas.Sum(d => d.Receita):C}\n");

Console.WriteLine("Faturamento por autor:");
foreach (var a in detalhadas
    .GroupBy(d => d.Autor)
    .Select(g => new { Autor = g.Key, Total = g.Sum(d => d.Receita) })
    .OrderByDescending(a => a.Total))
{
    Console.WriteLine($"  {a.Autor,-22}{a.Total,12:C}");
}

Console.WriteLine("\nMais vendidos (unidades):");
foreach (var m in detalhadas
    .GroupBy(d => new { d.Titulo, d.Formato })
    .Select(g => new { g.Key.Titulo, g.Key.Formato, Unidades = g.Sum(d => d.Quantidade) })
    .OrderByDescending(m => m.Unidades).ThenBy(m => m.Titulo)
    .Take(3))
{
    Console.WriteLine($"  {m.Titulo,-22}{m.Formato,-12}{m.Unidades,4}");
}

Console.WriteLine("\nFaturamento por dia:");
foreach (var dia in detalhadas.GroupBy(d => d.Data).OrderBy(g => g.Key))
    Console.WriteLine($"  {dia.Key:dd/MM}{dia.Sum(d => d.Receita),14:C}");

var vendidos = vendas.Select(v => v.ProdutoId).ToHashSet();
var encalhados = catalogo.Where(p => !vendidos.Contains(p.Id)).Select(p => p.Titulo);
Console.WriteLine($"\nSem nenhuma venda: {string.Join(", ", encalhados)}");
Faturamento total: R$ 581,40

Faturamento por autor:
  Machado de Assis         R$ 378,90
  Graciliano Ramos         R$ 167,60
  Clarice Lispector         R$ 34,90

Mais vendidos (unidades):
  Dom Casmurro          e-book         7
  Dom Casmurro          impresso       4
  São Bernardo          e-book         4

Faturamento por dia:
  01/09     R$ 279,20
  02/09     R$ 135,90
  03/09     R$ 166,30

Sem nenhuma venda: Memórias Póstumas de Brás Cubas, Grande Sertão: Veredas

Os números fecham. Machado: 4 impressos a R$ 59,90 somam R$ 239,60, e 7 e-books a R$ 19,90 somam R$ 139,30, total R$ 378,90. Graciliano: R$ 76,00 + R$ 91,60 = R$ 167,60. Clarice: R$ 34,90. Os três somam R$ 581,40, o mesmo que a soma dos três dias.

Três escolhas merecem comentário. O ToList() depois do Join evita refazer a junção nas quatro perguntas seguintes. O agrupamento por new { d.Titulo, d.Formato } usa um tipo anônimo como chave composta, o que funciona porque tipos anônimos têm igualdade por conteúdo: sem o formato, o "Dom Casmurro" impresso e o e-book seriam somados juntos. E os encalhados foram calculados com um HashSet, em vez do GroupJoin mostrado antes: para cada produto, vendidos.Contains é uma consulta instantânea, enquanto vendas.Any(v => v.ProdutoId == p.Id) percorreria todas as vendas para cada produto. Com milhares de itens dos dois lados, a diferença vai de milissegundos a minutos.

Ao longo desta aula e da anterior, usamos FirstOrDefault, MaxBy(...)! e Produto? sem muita cerimônia. O ! do MaxBy diz ao compilador "confie, não é nulo". O ? do FirstOrDefault diz "pode ser nulo". Esses sinais pequenos são a parte visível de um sistema inteiro que o C# 8 introduziu para atacar o erro mais comum da história da programação, e que ligamos no .csproj desde a primeira aula prática sem nunca explicá-lo de verdade.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Agrupe o catálogo pelo formato e mostre, para cada formato, a quantidade de produtos, o preço médio e o título do produto mais barato.

Ver resposta

✓ Resposta:

var porFormato = catalogo
    .GroupBy(p => p.Formato)
    .Select(g => new
    {
        Formato = g.Key,
        Quantidade = g.Count(),
        Media = g.Average(p => p.Preco),
        MaisBarato = g.MinBy(p => p.Preco)!.Titulo
    });

foreach (var f in porFormato)
    Console.WriteLine($"{f.Formato}: {f.Quantidade} produto(s), média {f.Media:C}, mais barato: {f.MaisBarato}");

Os impressos são 4, com média de R$ 57,95 (soma de R$ 231,80), e o mais barato é Vidas Secas. Os e-books são 2, com média de R$ 21,40, e o mais barato é Dom Casmurro. O audiolivro é 1, de R$ 34,90. Cada grupo é uma sequência completa, então vários agregados podem ser calculados sobre o mesmo grupo. O ! depois de MinBy informa ao compilador que o resultado não é nulo. Isso é seguro aqui, porque o GroupBy nunca produz grupos vazios.

Exercício 2

Preveja o valor impresso e explique:

decimal limite = 30m;
var baratos = catalogo.Where(p => p.Preco < limite);
limite = 50m;
Console.WriteLine(baratos.Count());
Ver resposta

✓ Resposta: Imprime 5, e não 2. A consulta não executa na linha do Where: só no Count(). E a lambda captura a variável limite, e não o valor 30. Quando a consulta finalmente executa, limite já vale 50, e os produtos abaixo de R$ 50 são cinco: Memórias Póstumas (44,90), Dom Casmurro e-book (19,90), Vidas Secas (38,00), São Bernardo (22,90) e A Hora da Estrela (34,90). Se a intenção fosse "os baratos segundo o limite de 30", seria preciso materializar antes da mudança (var baratos = ...ToList();) ou copiar o limite para uma variável que não mudasse. Execução adiada e captura de variáveis juntas são uma fonte clássica de bugs difíceis de enxergar.

Exercício 3

Dada uma lista de pedidos (cada um com Itens), calcule o total de unidades vendidas de cada título somando todos os pedidos, do mais vendido para o menos vendido.

Ver resposta

✓ Resposta:

var unidadesPorTitulo = pedidos
    .SelectMany(p => p.Itens)
    .GroupBy(i => i.Produto.Titulo)
    .Select(g => new { Titulo = g.Key, Unidades = g.Sum(i => i.Quantidade) })
    .OrderByDescending(x => x.Unidades);

foreach (var t in unidadesPorTitulo)
    Console.WriteLine($"{t.Titulo}: {t.Unidades}");

O SelectMany transforma "lista de pedidos, cada um com sua lista de itens" numa sequência única de itens, e a partir daí é um agrupamento comum. Sem ele, seria preciso um laço sobre os pedidos, outro sobre os itens e um dicionário acumulando as quantidades: exatamente o exercício de contagem de palavras de "Guardando Muitos Valores", agora em três operadores.

Exercício 4

O método abaixo recebe uma sequência que, na prática, vem de uma leitura lenta de arquivo. Aponte o problema de desempenho e corrija-o.

static void Relatorio(IEnumerable<Venda> vendas)
{
    Console.WriteLine($"Vendas: {vendas.Count()}");
    Console.WriteLine($"Unidades: {vendas.Sum(v => v.Quantidade)}");
    foreach (var v in vendas)
        Console.WriteLine(v);
}
Ver resposta

✓ Resposta: A sequência é percorrida três vezes: pelo Count, pelo Sum e pelo foreach. Se ela for uma consulta adiada sobre um arquivo (ou, mais adiante, sobre um banco de dados), o arquivo será lido três vezes, com o custo triplicado. E, se o arquivo mudar entre as leituras, os três números podem nem ser coerentes entre si. A correção é materializar uma única vez no início:

static void Relatorio(IEnumerable<Venda> vendas)
{
    var lista = vendas.ToList();
    Console.WriteLine($"Vendas: {lista.Count}");
    Console.WriteLine($"Unidades: {lista.Sum(v => v.Quantidade)}");
    foreach (var v in lista)
        Console.WriteLine(v);
}

Outra opção é mudar o parâmetro para IReadOnlyList<Venda>, deixando explícito que o método precisa de uma coleção já em memória e passando a decisão de materializar a quem chama. Os analisadores de código do .NET e de editores como o Rider costumam alertar sobre "possível enumeração múltipla" exatamente neste padrão.

Exercício 5

Liste os produtos que nunca foram vendidos de duas formas: com Where e Any sobre as vendas, e com um HashSet dos identificadores vendidos. Quantas comparações cada forma faz, no pior caso, com P produtos e V vendas?

Ver resposta

✓ Resposta:

// Forma 1
var nunca1 = catalogo.Where(p => !vendas.Any(v => v.ProdutoId == p.Id));

// Forma 2
var vendidos = vendas.Select(v => v.ProdutoId).ToHashSet();
var nunca2 = catalogo.Where(p => !vendidos.Contains(p.Id));

As duas devolvem Memórias Póstumas e Grande Sertão. Na forma 1, para cada um dos P produtos, o Any pode percorrer todas as V vendas: no pior caso, P × V comparações. Com 10 mil produtos e 1 milhão de vendas, são 10 bilhões. Na forma 2, montar o HashSet custa V operações, e cada Contains é praticamente instantâneo, o que dá cerca de V + P operações no total: pouco mais de 1 milhão. A diferença entre as duas é a diferença entre um relatório instantâneo e um que não termina. É o mesmo princípio que torna o Join rápido, e é o que os índices de banco de dados fazem na Fase 5: trocar varreduras repetidas por uma estrutura de busca direta.

Comentários

Mais em Linguagem C#

Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo
Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo

O primeiro protótipo da Biblioteca em Apuros em MonoGame, escrito à mão…

MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional
MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional

O MongoDB além do básico: pipeline de agregação em LINQ, $lookup, índices…

Herança: Quando um Tipo É uma Versão Especializada de Outro
Herança: Quando um Tipo É uma Versão Especializada de Outro

Herança em C#: classe base e derivada, construtor com base, virtual e…