LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços

LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços

Consultas com LINQ em C#: Where, Select, OfType, ordenação, Count, Any e All, Sum, Average e MaxBy, First e Single e os operadores que lançam exceção, Skip e Take, a sintaxe de consulta, métodos e membros de extensão do C# 14 e um painel de perguntas ao catálogo.
Linguagem C#

• • 15 min de leitura

"Os livros disponíveis, do mais barato ao mais caro, só os dez primeiros": com LINQ, essa frase vira uma linha de código que diz o que se quer, e não como obter.

Em "Delegates, Lambdas e Eventos" construímos à mão um Filtrar<T> que recebia uma sequência e um critério, e terminamos com a revelação de que ele já existe na biblioteca padrão. Ele e dezenas de irmãos formam o LINQ (Language Integrated Query, consulta integrada à linguagem), apresentado na aula de história como o recurso que marcou o C# 3.0. O LINQ reúne tudo o que estudamos na Fase 3 até aqui: métodos genéricos que funcionam com qualquer tipo, lambdas que dizem o critério e interfaces que permitem aplicá-los a qualquer coleção. Nesta primeira parte vemos os operadores do dia a dia e o mecanismo que os faz aparecer em todas as coleções.

Da receita à pergunta

Compare duas formas de obter os títulos dos livros disponíveis abaixo de R$ 50, em ordem de preço. Com laços:

var selecionados = new List<Produto>();
foreach (var p in catalogo)
{
    if (p.Preco < 50m)
        selecionados.Add(p);
}
selecionados.Sort((a, b) => a.Preco.CompareTo(b.Preco));

var titulos = new List<string>();
foreach (var p in selecionados)
    titulos.Add(p.Titulo);

Com LINQ:

var titulos = catalogo
    .Where(p => p.Preco < 50m)
    .OrderBy(p => p.Preco)
    .Select(p => p.Titulo)
    .ToList();

A primeira versão é uma receita: crie uma lista, percorra, teste, adicione, ordene, percorra de novo, copie. A segunda é uma pergunta: onde o preço é menor que 50, ordenado por preço, selecione o título. O resultado é o mesmo. A diferença está no que o leitor precisa decifrar. A convenção de quebrar a cadeia, com um operador por linha, deixa cada etapa visível.

Os operadores essenciais

Todos os exemplos usam o catálogo abaixo, com a hierarquia de produtos de "Herança":

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

Filtrar: Where. Mantém os elementos que satisfazem o critério:

var baratos = catalogo.Where(p => p.Preco < 40m);
// Dom Casmurro (e-book), Vidas Secas, São Bernardo, A Hora da Estrela

Transformar: Select. Converte cada elemento em outra coisa:

var titulos = catalogo.Select(p => p.Titulo);
var etiquetas = catalogo.Select(p => $"{p.Titulo} ({p.Formato})");

Filtrar por tipo: OfType. Mantém só os elementos de um tipo, já convertidos. É o is LivroImpresso aplicado à coleção inteira:

IEnumerable<LivroImpresso> impressos = catalogo.OfType<LivroImpresso>();

Ordenar: OrderBy, OrderByDescending e ThenBy. Ordenam pela chave informada, e ThenBy desempata:

var ordenados = catalogo
    .OrderBy(p => p.Autor)
    .ThenByDescending(p => p.Preco);

Contar e verificar: Count, Any e All.

int quantosDigitais = catalogo.Count(p => p is Ebook or Audiolivro);          // 3
bool temEsgotado = catalogo.OfType<LivroImpresso>().Any(l => l.Estoque == 0); // true
bool todosComPreco = catalogo.All(p => p.Preco > 0);                           // true

Prefira Any() a Count() > 0 para saber se algo existe: Any para no primeiro elemento que encontrar, e Count percorre tudo.

Agregar: Sum, Average, Min, Max, MinBy e MaxBy.

decimal valorEmEstoque = catalogo.OfType<LivroImpresso>().Sum(l => l.Preco * l.Estoque);   // 929,00
decimal precoMedio = catalogo.Average(p => p.Preco);                                        // 44,2142...
decimal menorPreco = catalogo.Min(p => p.Preco);                                            // 19,90
Produto maisCaro = catalogo.MaxBy(p => p.Preco)!;                                           // Grande Sertão

Repare na diferença entre Max e MaxBy. Max(p => p.Preco) devolve o maior preço (89,00). MaxBy(p => p.Preco) devolve o produto que tem o maior preço. Antes do .NET 6, obter o produto exigia ordenar a coleção inteira e pegar o primeiro.

Pegar um elemento: First, FirstOrDefault, Single e SingleOrDefault.

Produto primeiroDoGraciliano = catalogo.First(p => p.Autor == "Graciliano Ramos");     // Vidas Secas
Produto? deKafka = catalogo.FirstOrDefault(p => p.Autor == "Franz Kafka");              // null

Paginar: Skip e Take. Pulam e pegam uma quantidade de elementos. O Paginar que escrevemos em "Genéricos" fica em uma linha:

var pagina2 = catalogo.OrderBy(p => p.Titulo).Skip(2).Take(2);

Remover repetições: Distinct e DistinctBy.

var autores = catalogo.Select(p => p.Autor).Distinct();            // 4 autores, sem repetir
var umPorTitulo = catalogo.DistinctBy(p => p.Titulo);               // um produto por título

Materializar: ToList, ToArray e ToDictionary. Transformam o resultado de uma consulta numa coleção concreta:

Dictionary<int, Produto> porId = catalogo.ToDictionary(p => p.Id);

Os operadores que lançam exceção

Alguns operadores assumem que existe um resultado, e lançam InvalidOperationException quando não existe. É bom conhecê-los antes de tropeçar:

  • First numa sequência vazia (ou sem nenhum elemento que satisfaça o critério) lança. FirstOrDefault devolve o valor padrão: null para classes, 0 para números.
  • Single lança se houver zero ou mais de um elemento. Use-o quando ter mais de um for um erro de dados, como buscar pelo ISBN, que deveria ser único.
  • Average, Min e Max numa sequência vazia de números lançam, porque não existe média de nada. Sum de uma sequência vazia devolve 0, o que é matematicamente correto.
  • ToDictionary lança ArgumentException se duas chaves se repetirem. No nosso catálogo, ToDictionary(p => p.Titulo) falharia, porque há dois "Dom Casmurro".

A regra: use as versões OrDefault quando a ausência é um caso esperado, e as versões estritas quando a ausência indica um erro que deve aparecer imediatamente. É a mesma distinção entre padrão Try e exceção de "Quando Algo Dá Errado".

Sintaxe de consulta

O LINQ tem uma segunda forma de escrita, inspirada em SQL:

var titulos =
    from p in catalogo
    where p.Preco < 50m
    orderby p.Preco
    select p.Titulo;

O compilador traduz essa forma exatamente para as chamadas Where, OrderBy e Select. Não há diferença de desempenho nem de resultado. A sintaxe de métodos, que usamos até aqui, é a mais comum em código moderno, porque cobre todos os operadores (vários, como Count, MaxBy e Skip, não têm palavra-chave na sintaxe de consulta). A sintaxe de consulta brilha em junções e agrupamentos complexos, que aparecem na segunda parte. Conheça as duas e use a que deixar cada consulta mais clara.

Como o Where aparece em todas as coleções: métodos de extensão

Uma pergunta deveria estar incomodando você: List<T> não tem um método Where, e arrays também não. Então de onde ele vem? A resposta são os métodos de extensão, um recurso do C# 3.0 criado justamente para o LINQ. Um método de extensão é um método estático, numa classe estática, cujo primeiro parâmetro é marcado com this:

public static class DecimalExtensions
{
    public static string EmReais(this decimal valor) => valor.ToString("C", new CultureInfo("pt-BR"));
}

decimal preco = 59.90m;
Console.WriteLine(preco.EmReais());   // R$ 59,90: parece um método de decimal

O compilador reescreve preco.EmReais() como DecimalExtensions.EmReais(preco). Nada foi adicionado ao tipo decimal: é só uma forma de chamar um método estático como se fosse de instância. Os operadores do LINQ são exatamente isso: métodos de extensão sobre IEnumerable<T>, declarados na classe System.Linq.Enumerable. Como arrays, listas, dicionários e qualquer coleção implementam IEnumerable<T>, todos "ganham" os operadores. O namespace System.Linq faz parte dos usings implícitos, e por isso eles estão sempre disponíveis.

O C# 14 ampliou esse mecanismo com os membros de extensão, que permitem declarar num bloco extension não só métodos, mas também propriedades de extensão:

public static class CatalogoExtensions
{
    extension(IEnumerable<Produto> produtos)
    {
        public IEnumerable<Produto> DoAutor(string autor) =>
            produtos.Where(p => p.Autor.Equals(autor, StringComparison.OrdinalIgnoreCase));

        public decimal PrecoMedio => produtos.Average(p => p.Preco);
    }
}

var doMachado = catalogo.DoAutor("machado de assis");
Console.WriteLine(catalogo.PrecoMedio);

Assim, o vocabulário da Livraria pode ser acrescentado às coleções, e as consultas passam a ler como a linguagem do negócio: catalogo.DoAutor(...).Disponiveis().MaisBaratos(5).

Um cuidado: métodos de extensão não acessam membros privados do tipo estendido, e usá-los em excesso espalha comportamento por classes estáticas difíceis de encontrar. Use-os para o que realmente é uma operação sobre um tipo que você não controla, como as coleções da biblioteca, ou para vocabulário de consulta. Comportamento central de uma classe sua deve morar na própria classe.

Um aviso sobre quando a consulta executa

Uma sutileza que a segunda parte vai explorar a fundo: catalogo.Where(...) não percorre o catálogo nesse momento. Ele devolve uma consulta que só executa quando alguém a percorre, com foreach, ToList, Count ou First. Por ora, basta uma regra prática: se você vai usar o resultado de uma consulta mais de uma vez, materialize-o com ToList() ou ToArray(). Assim ele não é recalculado a cada uso.

E uma nota honesta sobre desempenho. LINQ tem um pequeno custo em relação a um laço escrito à mão: cada lambda é uma chamada de delegate, e cada operador cria um objeto intermediário. Na imensa maioria do código de negócio, esse custo é irrelevante diante do ganho de clareza. Em trechos que executam milhões de vezes por segundo, como o loop de um jogo na Fase 9, laços simples ainda vencem. Nesses casos, meça antes de decidir, com as ferramentas da Fase 10.

Juntando tudo: o catálogo respondendo perguntas

Com LINQ, o Pedido e os relatórios da Livraria perdem boa parte do código. As propriedades calculadas do pedido, que em "Enums e Pattern Matching" usavam laços, viram uma linha cada:

public int QuantidadeTotal => _itens.Sum(i => i.Quantidade);
public decimal Subtotal => _itens.Sum(i => i.Subtotal);

E um pequeno painel de perguntas sobre o catálogo:

using System.Globalization;

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

Console.WriteLine($"Produtos: {catalogo.Count}");
Console.WriteLine($"Preço médio: {catalogo.Average(p => p.Preco):C}");
Console.WriteLine($"Mais caro: {catalogo.MaxBy(p => p.Preco)!.Titulo}");
Console.WriteLine($"Valor parado em estoque: {catalogo.OfType<LivroImpresso>().Sum(l => l.Preco * l.Estoque):C}");

var esgotados = catalogo
    .OfType<LivroImpresso>()
    .Where(l => l.Estoque == 0)
    .Select(l => l.Titulo)
    .ToList();
Console.WriteLine($"Esgotados: {(esgotados.Count > 0 ? string.Join(", ", esgotados) : "nenhum")}");

Console.WriteLine("Autores: " + string.Join(", ", catalogo.Select(p => p.Autor).Distinct().Order()));

Console.WriteLine("Os três mais baratos:");
foreach (var p in catalogo.OrderBy(p => p.Preco).Take(3))
    Console.WriteLine($"  {p.Titulo,-30}{p.Formato,-12}{p.Preco,10:C}");
Produtos: 7
Preço médio: R$ 44,21
Mais caro: Grande Sertão: Veredas
Valor parado em estoque: R$ 929,00
Esgotados: Memórias Póstumas de Brás Cubas
Autores: Clarice Lispector, Graciliano Ramos, Guimarães Rosa, Machado de Assis
Os três mais baratos:
  Dom Casmurro                  e-book        R$ 19,90
  São Bernardo                  e-book        R$ 22,90
  A Hora da Estrela             audiolivro    R$ 34,90

Os números conferem. A soma dos preços é R$ 309,50, e dividida por 7 dá R$ 44,21. No valor em estoque, 10 × 59,90 + 0 × 44,90 + 4 × 38,00 + 2 × 89,00 = 599,00 + 152,00 + 178,00 = 929,00. O Order() (sem By), disponível desde o .NET 7, ordena os próprios elementos quando eles já são comparáveis, como strings.

Cada linha do painel é uma pergunta de negócio respondida por uma expressão, sem variáveis acumuladoras nem laços aninhados. Ficaram de fora, porém, as perguntas mais interessantes: "quanto cada autor tem em estoque?", "qual o livro mais caro de cada formato?", "quais pedidos contêm livros esgotados?". Elas exigem agrupar elementos e cruzar coleções diferentes. E há o mistério da execução adiada, que tanto pode economizar trabalho quanto repetir uma consulta cara sem que você perceba. Os dois assuntos formam a segunda parte do LINQ.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Com o catálogo da aula, obtenha os títulos de todos os produtos digitais (e-books e audiolivros) em ordem alfabética.

Ver resposta

✓ Resposta:

var digitais = catalogo
    .Where(p => p is Ebook or Audiolivro)
    .Select(p => p.Titulo)
    .Order()
    .ToList();
// A Hora da Estrela, Dom Casmurro, São Bernardo

A ordem dos operadores importa para a legibilidade e, às vezes, para o desempenho: filtrar antes de ordenar reduz o que precisa ser ordenado. Order() ordena as próprias strings. A alternativa OrderBy(t => t) produz o mesmo resultado. Também seria possível ordenar antes de projetar, com .OrderBy(p => p.Titulo).Select(p => p.Titulo). O resultado é idêntico, e a escolha é questão de clareza.

Exercício 2

Para cada linha abaixo, diga se ela devolve um valor ou lança exceção, e qual:

List<int> vazia = [];
vazia.FirstOrDefault();
vazia.First();
vazia.Sum();
vazia.Average();
new List<int> { 1, 1 }.Single(n => n == 1);
Ver resposta

✓ Resposta: FirstOrDefault() devolve 0, o valor padrão de int. First() lança InvalidOperationException, porque a sequência não contém elementos. Sum() devolve 0: a soma de nada é zero. Average() lança InvalidOperationException, porque não existe média de zero elementos, e o LINQ prefere falhar a inventar um valor. Single(n => n == 1) lança InvalidOperationException porque dois elementos satisfazem o critério, e Single exige exatamente um. Atenção a um detalhe do primeiro caso: para uma lista de int, FirstOrDefault devolve 0 tanto quando a lista está vazia quanto quando o primeiro elemento é 0. Se essa distinção importa, use Any() antes, ou trabalhe com int?.

Exercício 3

Reescreva com LINQ o exercício 1 de "Guardando Muitos Valores", que calculava com um laço a média, a maior e a menor de um array de notas.

Ver resposta

✓ Resposta:

double[] notas = [7.5, 9.0, 6.0, 8.5, 10.0];

Console.WriteLine($"Média: {notas.Average():F2}");   // 8,20
Console.WriteLine($"Maior: {notas.Max()}");          // 10
Console.WriteLine($"Menor: {notas.Min()}");          // 6

Três linhas no lugar de um laço com três variáveis acumuladoras e dois if. Vale lembrar o que aprendemos lá: a versão com laço percorria o array uma vez, e esta percorre três, uma por operador. Com cinco notas, a diferença é invisível. Com cinco milhões de registros lidos de um arquivo, poderia importar, e aí o laço único, ou um Aggregate que calcule tudo numa passada, seria a escolha. Legibilidade primeiro. Otimização quando uma medição mostrar que vale a pena.

Exercício 4

Escreva um método de extensão Buscar(this IEnumerable<Produto> produtos, string termo) que devolva os produtos cujo título ou autor contenham o termo, ignorando maiúsculas e minúsculas. Busque "machado" e "sert".

Ver resposta

✓ Resposta:

public static class BuscaExtensions
{
    public static IEnumerable<Produto> Buscar(this IEnumerable<Produto> produtos, string termo)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(termo);
        string t = termo.Trim();
        return produtos.Where(p =>
            p.Titulo.Contains(t, StringComparison.OrdinalIgnoreCase) ||
            p.Autor.Contains(t, StringComparison.OrdinalIgnoreCase));
    }
}

foreach (var p in catalogo.Buscar("machado"))
    Console.WriteLine(p.Titulo);   // Dom Casmurro, Memórias Póstumas..., Dom Casmurro (e-book)

foreach (var p in catalogo.Buscar("sert"))
    Console.WriteLine(p.Titulo);   // Grande Sertão: Veredas

A classe precisa ser static e não genérica, e o this no primeiro parâmetro é o que transforma o método em extensão. O método devolve a consulta, e não uma lista: quem chama pode encadear mais operadores (catalogo.Buscar("machado").Where(p => p.Preco < 50m)) e decidir quando materializar. Uma limitação honesta: OrdinalIgnoreCase não ignora acentos, então buscar "sertao" não encontra "Sertão". Resolver isso exige remover diacríticos ou usar uma comparação cultural com opções específicas, e bancos de dados têm recursos próprios para isso, que veremos na Fase 5.

Exercício 5

Tente criar um dicionário do catálogo indexado pelo título com ToDictionary(p => p.Titulo). O que acontece e por quê? Proponha duas soluções diferentes, conforme o que o dicionário deve representar.

Ver resposta

✓ Resposta: O ToDictionary lança ArgumentException, porque o catálogo tem dois produtos com o título "Dom Casmurro" (o impresso e o e-book), e um dicionário não aceita chaves repetidas. A escolha da solução depende da pergunta que o dicionário deve responder. Se a intenção é identificar cada produto de forma única, a chave errada é o título: use o identificador, catalogo.ToDictionary(p => p.Id), que é único por construção. Se a intenção é "dado um título, quais produtos existem", cada chave precisa apontar para vários produtos, e a estrutura certa é um agrupamento: catalogo.ToLookup(p => p.Titulo), em que lookup["Dom Casmurro"] devolve os dois formatos. Agrupar elementos por uma chave é exatamente o primeiro assunto da segunda parte do LINQ. Uma terceira via, DistinctBy(p => p.Titulo) antes do ToDictionary, evita a exceção, mas descarta um dos formatos em silêncio. Só seria aceitável se isso fosse deliberado.

Comentários

Mais em Linguagem C#

C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas
C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas

Modelos de linguagem como infraestrutura na Livraria: Microsoft.Extensions.AI…

Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma
Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma

Uma API que diz ao cliente o que ele errou: validação nativa do .NET 10 com…

Blazor: modos de renderização, WebAssembly e estado
Blazor: modos de renderização, WebAssembly e estado

Os quatro modos de renderização do Blazor e como escolher por componente: SSR…