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.
- 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()eSum(). - 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 eToLookup. - Os erros reais:
CS1061num grupo e aArgumentExceptiondoToDictionarycom 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 lojinha | No LINQ |
|---|---|
| O critério ("separe por categoria") | A chave: GroupBy(p => p.Categoria) |
| Uma caixa pequena | Um grupo (IGrouping<string, Produto>) |
| A etiqueta da caixa | grupo.Key ("chave") |
| O que está dentro da caixa | Os 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
| Linha | O 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.Key | A 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
gda 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. Cadagé umIGrouping<string, string>, estring.Join(", ", g)junta os nomes direto.- Na sintaxe de consulta,
group p.Nome by p.Categoriadiz "agrupe os nomes pela categoria". Ointo g("para dentro deg") dá nome a cada grupo e deixa a consulta continuar (comorderby,select…). Sem ointo, ogrouptem 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 oGroupBye oCountde uma vez, e devolve pares chave/valor (par.Key,par.Value). Existe desde o .NET 9; em código mais antigo, você vai verGroupBy(…).Select(g => new { g.Key, Total = g.Count() }). Há também oAggregateBy, 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
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)).
// (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()}").
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ódigo | O 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.Key | A 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 g | Sintaxe 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:
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:
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 etiquetaKeye é uma sequência dos seus elementos: percorra comforeachou 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
Selectfaz 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) eToLookup. - Chave com vários elementos é grupo, não dicionário:
ToDictionarycom chave repetida dáArgumentException.
Próxima aula: Agregar (Count, Sum, Average).