C# LINQ GroupBy

"Quantos produtos há em cada categoria?" "Quanto vale o estoque de cada seção da loja?" Para responder, primeiro é preciso separar os produtos em montes: um monte para Papelaria, outro para Bolsas, outro para Cozinha. É isso que o GroupBy ("agrupar por") faz: ele reparte uma sequência em grupos, um para cada valor da chave que você escolher. Depois, você pode percorrer cada grupo ou fazer contas com ele.

Você vai aprender
  • Agrupar com GroupBy(p => p.Categoria) e percorrer os grupos.
  • O que é um grupo: a chave (Key) e os elementos.
  • Em que ordem os grupos saem, e como ordená-los.
  • Fazer um resumo por grupo com Count() e Sum().
  • Agrupar só uma parte de cada elemento (o seletor de elemento) e agrupar por uma chave calculada.
  • A sintaxe de consulta group … by … into.
  • Os atalhos CountBy .NET 9 e ToLookup.
  • Os erros reais: CS1061 num grupo e a ArgumentException do ToDictionary com chaves repetidas.

Todos os programas rodaram com o .NET 10 (C# 14) na máquina da Ana, na pasta ~/cs, com dotnet run arquivo.cs. Eles usam a lista de produtos da lojinha apresentada em O que é LINQ (linhas 1 a 10 de cada arquivo) e o record Produto no fim; os arquivos abaixo mostram só o programa.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

// … o programa de cada exemplo começa na linha 12 …

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

A ideia: as caixas com etiqueta

Chegou uma caixa grande com os produtos todos misturados. A Ana pega caixas menores, escreve uma etiqueta em cada uma ("Papelaria", "Bolsas", "Cozinha") e vai pondo cada produto na caixa da sua categoria. Se aparece um produto de uma categoria nova, ela pega mais uma caixa.

Na lojinhaNo LINQ
O critério ("separe por categoria")A chave: GroupBy(p => p.Categoria)
Uma caixa pequenaUm grupo (IGrouping<string, Produto>)
A etiqueta da caixagrupo.Key ("chave")
O que está dentro da caixaOs elementos do grupo: percorra com foreach (Produto p in grupo)

1. O primeiro GroupBy

// (a lista de produtos)

var grupos = produtos.GroupBy(p => p.Categoria);

foreach (var grupo in grupos)
{
    Console.WriteLine($"== {grupo.Key}");
    foreach (Produto p in grupo)
    {
        Console.WriteLine($"   {p.Nome} {p.Preco:C}");
    }
}

// (o record Produto)
$ dotnet run groupby.cs
== Papelaria
   Caderno R$ 18,90
   Lápis R$ 2,50
   Borracha R$ 1,75
== Bolsas
   Mochila R$ 129,90
   Estojo R$ 24,90
== Cozinha
   Garrafa R$ 35,00
   Caneca R$ 22,50
LinhaO que faz
produtos.GroupBy(p => p.Categoria)Reparte os produtos pela categoria. O resultado é uma sequência de grupos, um por categoria diferente (aqui, 3). Como todo o LINQ, é adiado.
foreach (var grupo in grupos)O laço de fora passa por cada grupo. O tipo de cada um é IGrouping<string, Produto>: "um grupo com chave string e elementos Produto". O var poupa escrever esse nome comprido.
grupo.KeyA chave do grupo, a "etiqueta da caixa": "Papelaria", "Bolsas"…
foreach (Produto p in grupo)O laço de dentro passa pelos produtos do grupo. Um grupo é uma sequência de produtos, que se percorre como qualquer outra (veja Laços Aninhados).

A ordem dos grupos: eles saem na ordem em que cada chave apareceu pela primeira vez na lista. O primeiro produto é o Caderno (Papelaria), o terceiro é a Mochila (Bolsas) e o sexto é a Garrafa (Cozinha). Dentro de cada grupo, os produtos também mantêm a ordem original. Para outra ordem, ordene os grupos (seção 2) ou ordene os produtos antes do GroupBy.

Missão: em vez de contar os produtos de cada categoria, mostre os nomes, separados por vírgula. Dica: um grupo é uma sequência de produtos, então g.Select(p => p.Nome) funciona nele, e o string.Join(", ", …) junta os nomes. A primeira linha deve ser Papelaria: Caderno, Lápis, Borracha.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var grupos = produtos.GroupBy(p => p.Categoria);

foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: {g.Count()} produtos");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);
List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var grupos = produtos.GroupBy(p => p.Categoria);

foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(p => p.Nome))}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

2. Um resumo por grupo

O uso mais comum do GroupBy não é listar, e sim resumir: uma linha por grupo, com contas sobre os elementos dele. Como cada grupo é uma sequência, todos os métodos do LINQ funcionam nele: g.Count() conta, g.Sum(…) soma (os dois são o assunto da próxima aula, Agregar):

// (a lista de produtos)

var resumo = produtos
    .GroupBy(p => p.Categoria)
    .OrderBy(g => g.Key)
    .Select(g => new
    {
        Categoria = g.Key,
        Quantos = g.Count(),
        Valor = g.Sum(p => p.Preco * p.Estoque)
    });

foreach (var linha in resumo)
{
    Console.WriteLine($"{linha.Categoria,-10} {linha.Quantos} produtos  {linha.Valor,10:C}");
}

// (o record Produto)
$ dotnet run resumo.cs
Bolsas     2 produtos   R$ 389,70
Cozinha    2 produtos   R$ 392,50
Papelaria  3 produtos   R$ 370,55
.GroupBy(p => p.Categoria)
Faz os 3 grupos.
.OrderBy(g => g.Key)
Ordena os grupos pela chave: agora o g da lambda é um grupo, não um produto. As categorias saem em ordem alfabética.
.Select(g => new { … })
Transforma cada grupo numa linha do relatório, com um tipo anônimo.
g.Count()
Quantos produtos há no grupo.
g.Sum(p => p.Preco * p.Estoque)
A soma, para cada produto do grupo, de preço × quantidade: o valor do estoque da categoria. Em Bolsas: 129,90 × 3 + 24,90 × 0 = 389,70.

É o mesmo que uma tabela dinâmica de planilha, ou o GROUP BY da linguagem SQL dos bancos de dados: agrupar e depois agregar (contar, somar, tirar a média) cada grupo.


3. Outras formas de agrupar

Agrupar só uma parte: o seletor de elemento

Um segundo parâmetro no GroupBy diz o que entra em cada grupo. Com p => p.Nome, cada grupo guarda só os nomes, em vez dos produtos inteiros. O mesmo agrupamento na sintaxe de consulta vem logo abaixo:

// (a lista de produtos)

var nomesPorCategoria = produtos.GroupBy(p => p.Categoria, p => p.Nome);

foreach (var g in nomesPorCategoria)
{
    Console.WriteLine($"{g.Key}: {string.Join(", ", g)}");
}

var consulta =
    from p in produtos
    group p.Nome by p.Categoria into g
    orderby g.Key
    select $"{g.Key} ({g.Count()})";

Console.WriteLine(string.Join(" | ", consulta));

// (o record Produto)
$ dotnet run elementos.cs
Papelaria: Caderno, Lápis, Borracha
Bolsas: Mochila, Estojo
Cozinha: Garrafa, Caneca
Bolsas (2) | Cozinha (2) | Papelaria (3)
  • GroupBy(p => p.Categoria, p => p.Nome): a chave é a categoria; os elementos, os nomes. Cada g é um IGrouping<string, string>, e string.Join(", ", g) junta os nomes direto.
  • Na sintaxe de consulta, group p.Nome by p.Categoria diz "agrupe os nomes pela categoria". O into g ("para dentro de g") dá nome a cada grupo e deixa a consulta continuar (com orderby, select…). Sem o into, o group tem de ser a última cláusula.

Agrupar por uma chave calculada

A chave não precisa ser uma propriedade: pode ser qualquer conta. Abaixo, faixas de preço com uma expressão switch e uma chave bool:

// (a lista de produtos)

var porFaixa = produtos.GroupBy(p => p.Preco switch
{
    < 10m => "até R$ 10",
    < 50m => "R$ 10 a R$ 50",
    _ => "acima de R$ 50"
});

foreach (var g in porFaixa)
{
    Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(p => p.Nome))}");
}

var situacao = produtos.GroupBy(p => p.Estoque == 0);
foreach (var g in situacao)
{
    Console.WriteLine($"Em falta? {g.Key}: {g.Count()} produto(s)");
}

// (o record Produto)
$ dotnet run faixas.cs
R$ 10 a R$ 50: Caderno, Estojo, Garrafa, Caneca
até R$ 10: Lápis, Borracha
acima de R$ 50: Mochila
Em falta? False: 6 produto(s)
Em falta? True: 1 produto(s)

A faixa "R$ 10 a R$ 50" veio primeiro porque o primeiro produto da lista (o Caderno, 18,90) caiu nela. Com a chave bool, há no máximo dois grupos: False e True.

Atalhos: CountBy .NET 9 e ToLookup

// (a lista de produtos)

foreach (var par in produtos.CountBy(p => p.Categoria))
{
    Console.WriteLine($"{par.Key}: {par.Value}");
}

ILookup<string, Produto> porCategoria = produtos.ToLookup(p => p.Categoria);
Console.WriteLine(string.Join(", ", porCategoria["Cozinha"].Select(p => p.Nome)));
Console.WriteLine(porCategoria["Brinquedos"].Count());

// (o record Produto)
$ dotnet run countby.cs
Papelaria: 3
Bolsas: 2
Cozinha: 2
Garrafa, Caneca
0                « uma categoria que não existe dá um grupo vazio, sem erro
  • CountBy ("contar por") faz o GroupBy e o Count de uma vez, e devolve pares chave/valor (par.Key, par.Value). Existe desde o .NET 9; em código mais antigo, você vai ver GroupBy(…).Select(g => new { g.Key, Total = g.Count() }). Há também o AggregateBy, para somas e outras contas por chave.
  • ToLookup ("tabela de consulta") agrupa na hora (não é adiado) e deixa buscar um grupo pela chave, com colchetes, como um dicionário: porCategoria["Cozinha"]. Use quando for consultar os grupos várias vezes, por chave.

Quando dá errado

CS1061: usar o grupo como se fosse um produto

Este código tem um erro de propósito.

// (a lista de produtos, linhas 1 a 10)

var grupos = produtos.GroupBy(p => p.Categoria);
foreach (var g in grupos)
{
    Console.WriteLine(g.Nome);
}

// (o record Produto)
$ dotnet run grupo-erro.cs
/home/ana/cs/grupo-erro.cs(15,25): error CS1061: ‘IGrouping<string, Produto>’ não contém uma definição para "Nome" e não foi possível encontrar nenhum método de extensão "Nome" que aceite um primeiro argumento do tipo ‘IGrouping<string, Produto>’ (você está se esquecendo de usar uma diretiva ou uma referência de assembly?)

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que quer dizer: g é a caixa, não um produto. A caixa tem uma etiqueta (Key) e vários produtos dentro, mas não tem um Nome.

Como consertar: para a etiqueta, g.Key; para os produtos, percorra o grupo (foreach (Produto p in g)) ou use o LINQ nele (g.Select(p => p.Nome)).

Mostrar o grupo direto: sai o nome do tipo
// (a lista de produtos)

var grupos = produtos.GroupBy(p => p.Categoria);
foreach (var g in grupos)
{
    Console.WriteLine(g);
}

// (o record Produto)
$ dotnet run imprimir-grupo.cs
System.Linq.Grouping`2[System.String,Produto]
System.Linq.Grouping`2[System.String,Produto]
System.Linq.Grouping`2[System.String,Produto]

O que quer dizer: não é um erro, mas também não é o que se queria. O grupo não sabe se escrever como texto, e o Console.WriteLine mostra o nome do tipo (veja A Classe object).

Como consertar: escreva o que interessa: Console.WriteLine($"{g.Key}: {g.Count()}").

ArgumentException: ToDictionary com chave repetida

Este código tem um erro de propósito. A Ana queria "um dicionário por categoria" e tentou o ToDictionary:

// (a lista de produtos, linhas 1 a 10)

var porCategoria = produtos.ToDictionary(p => p.Categoria);
Console.WriteLine(porCategoria.Count);

// (o record Produto)
$ dotnet run dicionario-erro.cs
Unhandled exception. System.ArgumentException: An item with the same key has already been added. Key: Papelaria
   at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at System.Linq.Enumerable.SpanToDictionary[TSource,TKey](ReadOnlySpan`1 source, Func`2 keySelector, IEqualityComparer`1 comparer)
   at System.Linq.Enumerable.ToDictionary[TSource,TKey](IEnumerable`1 source, Func`2 keySelector, IEqualityComparer`1 comparer)
   at Program.<Main>$(String[] args) in /home/ana/cs/dicionario-erro.cs:line 12

O que quer dizer: "um item com a mesma chave já foi adicionado. Chave: Papelaria". Num dicionário, cada chave aparece uma vez só, com um valor. O ToDictionary pôs o Caderno na chave "Papelaria" e, ao chegar no Lápis, não tinha onde pô-lo.

Como consertar: quando uma chave tem vários elementos, você quer grupos: GroupBy ou ToLookup. Se quiser mesmo um dicionário, agrupe antes: produtos.GroupBy(p => p.Categoria).ToDictionary(g => g.Key, g => g.ToList()).


Referência

CódigoO que faz
GroupBy(p => chave)Reparte em grupos pela chave (adiado). Os grupos saem na ordem em que cada chave apareceu.
GroupBy(p => chave, p => elemento)O mesmo, guardando só o elemento escolhido em cada grupo.
g.KeyA chave do grupo.
foreach (var x in g), g.Count(), g.Sum(…)Um grupo é uma sequência: percorra ou use o LINQ nele.
.OrderBy(g => g.Key)Ordena os grupos pela chave.
group p by chave / group p by chave into gSintaxe de consulta (com into, a consulta continua).
CountBy(p => chave)Pares chave/quantidade .NET 9.
ToLookup(p => chave)Agrupa na hora; busca com lookup[chave] (chave ausente = grupo vazio).

Exercícios

1. Agrupar

Complete para agrupar os produtos pela categoria:

var grupos = produtos.;

2. A etiqueta

Dentro de foreach (var g in grupos), qual expressão dá a categoria do grupo?

3. Na sintaxe de consulta

Complete para agrupar os produtos pela categoria e continuar a consulta com o grupo chamado g:

from p in produtos select g.Key

4. Em que ordem saem os grupos de produtos.GroupBy(p => p.Categoria)?

5. Quantos grupos produtos.GroupBy(p => p.Estoque > 10) pode ter, no máximo?

6. A Ana precisa de "todos os produtos de cada categoria". Qual destas linhas dá erro ao rodar?

Missão final: mostre quanto vale o estoque de cada categoria, com as categorias em ordem alfabética. Ordene os grupos pela chave e troque o ? pela soma de p.Preco * p.Estoque do grupo, com :C. A saída deve ser exatamente Bolsas: R$ 389,70, Cozinha: R$ 392,50 e Papelaria: R$ 370,55.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var grupos = produtos.GroupBy(p => p.Categoria);

foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: ?");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);
List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var grupos = produtos
    .GroupBy(p => p.Categoria)
    .OrderBy(g => g.Key);

foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: {g.Sum(p => p.Preco * p.Estoque):C}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

Resumo

  • GroupBy(p => chave) reparte uma sequência em grupos, um para cada valor da chave.
  • Cada grupo (IGrouping<TChave, T>) tem a etiqueta Key e é uma sequência dos seus elementos: percorra com foreach ou use o LINQ nele (g.Count(), g.Sum(…)).
  • Os grupos saem na ordem em que cada chave apareceu; para ordenar, .OrderBy(g => g.Key).
  • Agrupar e depois resumir com Select faz uma linha de relatório por grupo.
  • O segundo parâmetro escolhe o que entra no grupo; a chave pode ser calculada (faixas, bool).
  • Sintaxe de consulta: group p by chave into g. Atalhos: CountBy (.NET 9) e ToLookup.
  • Chave com vários elementos é grupo, não dicionário: ToDictionary com chave repetida dá ArgumentException.

Próxima aula: Agregar (Count, Sum, Average).