Filtrar, ordenar e reagir são sempre o mesmo esqueleto com um pedaço de comportamento diferente no meio, e em C# esse pedaço pode ser passado como argumento.
Em "Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão" aprendemos a escrever um algoritmo uma vez e aplicá-lo a qualquer tipo. Terminamos percebendo que falta a outra metade: filtrar produtos baratos, filtrar e-books ou filtrar livros de um autor são o mesmo laço, com o mesmo if. Só muda a condição. Se a condição pudesse ser passada como argumento, o laço seria escrito uma única vez. Nesta aula vemos como o C# trata comportamento como dado: os delegates, as lambdas, que são a forma curta de escrevê-los, e os eventos, que permitem a um objeto avisar outros de que algo aconteceu.
O problema: um laço para cada critério
Sem essa ferramenta, cada critério vira um método:
static List<Produto> Baratos(List<Produto> produtos)
{
var resultado = new List<Produto>();
foreach (var p in produtos)
if (p.Preco < 40m) resultado.Add(p);
return resultado;
}
static List<Produto> DoAutor(List<Produto> produtos, string autor)
{
var resultado = new List<Produto>();
foreach (var p in produtos)
if (p.Autor == autor) resultado.Add(p);
return resultado;
}
Os dois métodos são idênticos, exceto pela condição do if. Com dez critérios, seriam dez cópias do mesmo laço.
Delegates: um tipo para "um método com esta assinatura"
Um delegate é um tipo cujos valores são referências a métodos. Você declara a assinatura que os métodos devem ter:
delegate bool CriterioProduto(Produto p);
Agora CriterioProduto é um tipo, como int ou Livro. Uma variável desse tipo guarda qualquer método que receba um Produto e devolva bool:
static bool EhBarato(Produto p) => p.Preco < 40m;
static bool EhDigital(Produto p) => p is Ebook or Audiolivro;
CriterioProduto criterio = EhBarato; // guarda uma referência ao método
bool resultado = criterio(algumProduto); // chama o método referenciado
E um único método de filtro passa a servir para todos os critérios:
static List<Produto> Filtrar(List<Produto> produtos, CriterioProduto criterio)
{
var resultado = new List<Produto>();
foreach (var p in produtos)
if (criterio(p)) resultado.Add(p);
return resultado;
}
var baratos = Filtrar(catalogo, EhBarato);
var digitais = Filtrar(catalogo, EhDigital);
Se você vem do C, a ideia é a do ponteiro para função (int (*criterio)(Produto*)), usado por exemplo no qsort. A diferença é que o delegate é verificado pelo compilador: tentar guardar um método com assinatura diferente é erro de compilação, e não uma falha misteriosa em execução.
Func, Action e Predicate: os delegates prontos
Declarar um tipo de delegate para cada assinatura seria cansativo. A biblioteca oferece delegates genéricos que cobrem quase todos os casos:
Func<..., TResult>: recebe de 0 a 16 argumentos e devolve um valor.Func<Produto, bool>recebe um produto e devolvebool.Func<decimal, decimal, decimal>recebe dois decimais e devolve um.Action<...>: recebe argumentos e não devolve nada.Action<string>recebe uma string.Predicate<T>: equivale aFunc<T, bool>. Aparece em APIs mais antigas, comoList<T>.RemoveAll, que usamos em "Guardando Muitos Valores".
Na prática moderna, prefira Func e Action a declarar delegates próprios. Com genéricos, o filtro serve para qualquer tipo:
static List<T> Filtrar<T>(IEnumerable<T> itens, Func<T, bool> criterio)
{
var resultado = new List<T>();
foreach (T item in itens)
if (criterio(item)) resultado.Add(item);
return resultado;
}
Lambdas: o comportamento escrito no lugar
Criar um método nomeado só para passar como critério ainda é verboso, especialmente para condições usadas uma única vez. A expressão lambda é um método anônimo escrito no próprio ponto de uso:
var baratos = Filtrar(catalogo, p => p.Preco < 40m);
var doMachado = Filtrar(catalogo, p => p.Autor == "Machado de Assis");
var pares = Filtrar([1, 2, 3, 4, 5, 6], n => n % 2 == 0);
Leia p => p.Preco < 40m como "dado p, devolva p.Preco < 40m". O tipo de p é deduzido pelo contexto: como Filtrar espera um Func<Produto, bool>, p é um Produto. Você já escreveu lambdas antes, sem esse nome: o n => n % 2 == 0 do RemoveAll.
As formas possíveis:
Func<int, int> dobro = x => x * 2; // um parâmetro: parênteses opcionais
Func<int, int, int> soma = (a, b) => a + b; // vários parâmetros
Func<decimal> precoPadrao = () => 29.90m; // nenhum parâmetro
Action<string> saudar = nome => // corpo com várias instruções
{
string mensagem = $"Olá, {nome}!";
Console.WriteLine(mensagem);
};
var triplo = (int x) => x * 3; // C# 10: tipo natural, deduz Func<int, int>
Closures: lambdas que lembram do contexto
Uma lambda pode usar variáveis do método onde foi escrita, e não só os próprios parâmetros:
decimal limite = 50m;
var abaixoDoLimite = Filtrar(catalogo, p => p.Preco < limite);
A lambda captura a variável limite. O conjunto formado pela função e pelas variáveis capturadas se chama closure. O detalhe crucial é que a lambda captura a variável, e não uma cópia do valor. Se limite mudar depois, a lambda enxerga o valor novo. Isso permite fábricas de funções elegantes:
static Func<decimal, decimal> CriarDesconto(decimal percentual) =>
preco => preco * (1 - percentual);
var dezPorCento = CriarDesconto(0.10m);
var blackFriday = CriarDesconto(0.40m);
Console.WriteLine(dezPorCento(100m)); // 90,00
Console.WriteLine(blackFriday(100m)); // 60,00
Cada chamada a CriarDesconto produz uma função diferente, que "lembra" do seu próprio percentual.
A captura por variável também produz uma das armadilhas mais famosas do C#:
var acoes = new List<Action>();
for (int i = 0; i < 3; i++)
acoes.Add(() => Console.Write($"{i} "));
foreach (var acao in acoes)
acao(); // 3 3 3
As três lambdas capturaram a mesma variável i, que ao fim do laço vale 3. Quando elas finalmente executam, todas leem 3. Com foreach isso não acontece: desde o C# 5, cada volta do foreach tem sua própria variável, então foreach (int i in new[] { 0, 1, 2 }) imprime 0 1 2. No for, a correção é copiar para uma variável local dentro do laço: int copia = i; acoes.Add(() => Console.Write($"{copia} "));.
Quando você quer garantir que uma lambda não capture nada, por clareza ou para evitar a pequena alocação que uma closure causa, marque-a como static: Filtrar(catalogo, static p => p.Preco < 40m). Tentar usar uma variável externa dentro dela passa a ser erro de compilação.
Delegates multicast
Um delegate pode guardar vários métodos, combinados com +=, e chamá-los todos em sequência:
Action<string> notificar = texto => Console.WriteLine($"[tela] {texto}");
notificar += texto => Console.WriteLine($"[log] {texto}");
notificar("Pedido 1001 pago");
// [tela] Pedido 1001 pago
// [log] Pedido 1001 pago
Com -= se remove um método da lista. Se o delegate devolve valor (um Func), só o resultado do último método chamado é devolvido, o que torna os multicast úteis quase exclusivamente com Action. E é exatamente sobre esse mecanismo que os eventos são construídos.
Eventos: um objeto avisa, outros reagem
Um livro impresso com estoque baixo é algo que várias partes do sistema querem saber: a tela do gerente, o setor de compras, um registro de log. O livro não deveria conhecer nenhuma delas. Se conhecesse, precisaria mudar a cada novo interessado. Um evento inverte a dependência: o livro publica "meu estoque ficou baixo", e quem se interessar se inscreve.
Primeiro, os dados que o evento carrega. A convenção do .NET é uma classe derivada de EventArgs:
public sealed class EstoqueBaixoEventArgs(LivroImpresso livro, int restante) : EventArgs
{
public LivroImpresso Livro { get; } = livro;
public int Restante { get; } = restante;
}
(Um record seria o tipo natural para esses dados, mas records só podem herdar de outros records, e EventArgs é uma classe comum. O construtor primário deixa a classe quase tão curta quanto um record.)
Na classe LivroImpresso, declaramos o evento e o disparamos no momento certo:
public sealed class LivroImpresso : Produto
{
// ... membros de antes ...
public event EventHandler<EstoqueBaixoEventArgs>? EstoqueBaixo;
public int LimiteEstoqueBaixo { get; set; } = 2;
public override void Vender(int quantidade)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
if (quantidade > Estoque)
throw new EstoqueInsuficienteException(Titulo, Estoque, quantidade);
Estoque -= quantidade;
if (Estoque <= LimiteEstoqueBaixo)
EstoqueBaixo?.Invoke(this, new EstoqueBaixoEventArgs(this, Estoque));
}
}
EventHandler<T> é um delegate pronto, com a assinatura padrão dos eventos do .NET: (object? remetente, T argumentos). O ?.Invoke dispara o evento só se houver alguém inscrito. Sem inscritos, o evento vale null, e chamá-lo diretamente lançaria NullReferenceException.
Os interessados se inscrevem com +=:
var dom = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 5);
dom.EstoqueBaixo += (remetente, e) =>
Console.WriteLine($"[alerta] \"{e.Livro.Titulo}\" com apenas {e.Restante} em estoque.");
dom.EstoqueBaixo += (remetente, e) =>
{
if (e.Restante == 0)
Console.WriteLine($"[compras] Pedido urgente de reposição para \"{e.Livro.Titulo}\".");
};
dom.Vender(2); // restam 3: nada acontece
dom.Vender(1); // restam 2: dispara o alerta
dom.Vender(2); // restam 0: dispara alerta e compras
O que a palavra event acrescenta a um delegate comum? Proteção. De fora da classe, só é permitido += e -=. Ninguém consegue disparar o evento em nome do livro (dom.EstoqueBaixo(...)), nem apagar todos os inscritos com dom.EstoqueBaixo = null. Um campo delegate público permitiria as duas coisas. É o encapsulamento de "Propriedades e Encapsulamento" aplicado a notificações.
Um aviso honesto sobre eventos: o objeto que publica guarda referências para os inscritos. Se um objeto de vida curta, como uma tela que será fechada, se inscreve no evento de um objeto de vida longa, como um livro do catálogo, e nunca remove a inscrição com -=, a tela nunca será recolhida pelo coletor de lixo. Esse é um vazamento de memória clássico em aplicações .NET, e voltaremos a ele na aula sobre memória. A regra: quem se inscreve deve se desinscrever quando deixar de existir.
Juntando tudo: critérios compostos na Livraria
Com lambdas e Func, os critérios de busca da Livraria viram peças combináveis:
using System.Globalization;
CultureInfo.CurrentCulture = new CultureInfo("pt-BR");
List<Produto> catalogo =
[
new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10),
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),
];
Func<Produto, bool> ate40 = p => p.Preco <= 40m;
Func<Produto, bool> digital = p => p is Ebook;
Func<Produto, bool> doGraciliano = p => p.Autor.Contains("Graciliano", StringComparison.OrdinalIgnoreCase);
Exibir("Até R$ 40", Filtrar(catalogo, ate40));
Exibir("Digitais do Graciliano", Filtrar(catalogo, E(digital, doGraciliano)));
Exibir("Impressos até R$ 40", Filtrar(catalogo, E(ate40, Nao(digital))));
static Func<T, bool> E<T>(Func<T, bool> a, Func<T, bool> b) => x => a(x) && b(x);
static Func<T, bool> Nao<T>(Func<T, bool> criterio) => x => !criterio(x);
static List<T> Filtrar<T>(IEnumerable<T> itens, Func<T, bool> criterio)
{
var resultado = new List<T>();
foreach (T item in itens)
if (criterio(item)) resultado.Add(item);
return resultado;
}
static void Exibir(string titulo, IEnumerable<Produto> produtos)
{
Console.WriteLine($"== {titulo} ==");
foreach (var p in produtos)
Console.WriteLine($" {p.Titulo,-25}{p.Formato,-10}{p.Preco,10:C}");
}
== Até R$ 40 ==
Dom Casmurro e-book R$ 19,90
Vidas Secas impresso R$ 38,00
São Bernardo e-book R$ 22,90
== Digitais do Graciliano ==
São Bernardo e-book R$ 22,90
== Impressos até R$ 40 ==
Vidas Secas impresso R$ 38,00
Os métodos E e Nao recebem funções e devolvem funções. São as chamadas funções de ordem superior, e com elas os critérios se combinam como peças de montar. O filtro não sabe nada sobre produtos, preços ou autores: ele percorre, pergunta ao critério e acumula.
E aqui está a revelação que prepara a próxima aula: esse Filtrar<T>, que recebe uma sequência e um critério e devolve os itens aprovados, já existe na biblioteca padrão do .NET, junto com dezenas de irmãos para ordenar, transformar, agrupar, somar, contar e paginar. Eles formam uma linguagem de consultas inteira, construída exatamente sobre genéricos e lambdas, e com ela a maioria dos laços que escrevemos desde a Fase 1 vira uma única linha que diz o que queremos, e não como obter.
Fontes e leituras recomendadas
- Delegates (Guia de programação C#): https://learn.microsoft.com/dotnet/csharp/programming-guide/delegates/. É a introdução oficial, com delegates multicast e variância.
- Expressões lambda: https://learn.microsoft.com/dotnet/csharp/language-reference/operators/lambda-expressions. Cobre sintaxe, captura de variáveis, lambdas estáticas e tipo natural.
- Eventos: https://learn.microsoft.com/dotnet/csharp/programming-guide/events/. Explica como declarar, disparar e assinar eventos.
- O padrão de eventos do .NET: https://learn.microsoft.com/dotnet/csharp/event-pattern. Detalha as convenções de
EventHandler<T>eEventArgse o raciocínio por trás delas. Func<T, TResult>(referência da API): https://learn.microsoft.com/dotnet/api/system.func-2. É a família de delegates genéricos que você vai usar o tempo todo.
Exercícios
Exercício 1
Escreva um método genérico Contar<T>(IEnumerable<T> itens, Func<T, bool> criterio) e use-o para contar, no catálogo da aula, os produtos com preço acima de R$ 30 e os livros impressos com estoque menor que 5.
Ver resposta
✓ Resposta:
static int Contar<T>(IEnumerable<T> itens, Func<T, bool> criterio)
{
int total = 0;
foreach (T item in itens)
if (criterio(item)) total++;
return total;
}
int caros = Contar(catalogo, p => p.Preco > 30m); // 3
int estoqueBaixo = Contar(catalogo, p => p is LivroImpresso { Estoque: < 5 }); // 2
No catálogo da aula, os produtos acima de R$ 30 são os dois impressos de R$ 59,90 e R$ 89,00 e o de R$ 38,00, um total de 3. Os impressos com menos de 5 exemplares são Vidas Secas (4) e Grande Sertão (2). A segunda lambda usa um padrão de propriedade, de "Enums e Pattern Matching", dentro do critério: lambdas e padrões se combinam naturalmente. Contar nunca precisará mudar, não importa quantos critérios surjam.
Exercício 2
Preveja a saída do código abaixo, explique-a e corrija-o para imprimir 0 1 2:
var acoes = new List<Action>();
for (int i = 0; i < 3; i++)
acoes.Add(() => Console.Write($"{i} "));
foreach (var a in acoes) a();
Ver resposta
✓ Resposta: Imprime 3 3 3. As três lambdas capturam a mesma variável i, e não o valor que ela tinha quando cada lambda foi criada. Quando as ações executam, o laço já terminou, e i vale 3, o valor que encerrou o laço ao falhar em i < 3. A correção é criar uma variável nova a cada volta:
for (int i = 0; i < 3; i++)
{
int copia = i;
acoes.Add(() => Console.Write($"{copia} "));
}
Cada volta declara uma copia diferente, e cada lambda captura a sua. Outra correção é usar foreach (int i in new[] { 0, 1, 2 }), porque desde o C# 5 cada iteração do foreach tem sua própria variável. Essa mudança de comportamento foi feita justamente porque a armadilha era muito comum.
Exercício 3
Escreva um método CriarValidadorFaixa(decimal minimo, decimal maximo) que devolva um Func<decimal, bool> que diga se um valor está na faixa. Use-o para criar validadores de preço para livros de bolso (até R$ 30) e edições de luxo (R$ 150 a R$ 500).
Ver resposta
✓ Resposta:
static Func<decimal, bool> CriarValidadorFaixa(decimal minimo, decimal maximo)
{
if (minimo > maximo)
throw new ArgumentException("O mínimo não pode ser maior que o máximo.");
return valor => valor >= minimo && valor <= maximo;
}
var bolso = CriarValidadorFaixa(0.01m, 30m);
var luxo = CriarValidadorFaixa(150m, 500m);
Console.WriteLine(bolso(24.90m)); // True
Console.WriteLine(luxo(24.90m)); // False
Console.WriteLine(luxo(299m)); // True
A lambda devolvida captura minimo e maximo. Cada validador é uma closure com a sua própria faixa. A validação dos parâmetros acontece uma vez, na criação, e não a cada uso, porque os limites não mudam. Esse é o padrão de "fábrica de funções": configurar uma vez e usar muitas. Ele reaparece na API, quando configurarmos regras de validação e políticas de acesso.
Exercício 4
Por que as duas linhas abaixo não compilam, se EstoqueBaixo é, por baixo, um delegate? Que problemas elas causariam se fossem permitidas?
dom.EstoqueBaixo = null;
dom.EstoqueBaixo?.Invoke(dom, new EstoqueBaixoEventArgs(dom, 0));
Ver resposta
✓ Resposta: A palavra event restringe o que o código fora da classe pode fazer com o delegate: apenas += e -=. A atribuição direta e a invocação só são permitidas dentro da própria classe que declara o evento (erro CS0070). Se fossem permitidas, a primeira linha apagaria todos os inscritos de uma vez: um componente qualquer poderia, por descuido, desligar os alertas do setor de compras. A segunda permitiria que qualquer código fingisse que o estoque ficou baixo, disparando pedidos de reposição sem que nenhuma venda tivesse acontecido. O evento garante que só o livro anuncia mudanças no próprio estoque, e que cada inscrito só gerencia a própria inscrição. É exatamente a diferença entre um campo público e uma propriedade encapsulada, aplicada a notificações.
Exercício 5
Escreva um método genérico Compor<T> que receba duas funções Func<T, T> e devolva uma função que aplique a primeira e depois a segunda. Use-o para calcular o preço final de um livro de R$ 100 com "10% de desconto e depois R$ 15 de frete" e com "R$ 15 de frete e depois 10% de desconto". Os resultados são iguais?
Ver resposta
✓ Resposta:
static Func<T, T> Compor<T>(Func<T, T> primeiro, Func<T, T> segundo) =>
x => segundo(primeiro(x));
Func<decimal, decimal> desconto = p => p * 0.90m;
Func<decimal, decimal> frete = p => p + 15m;
var descontoDepoisFrete = Compor(desconto, frete);
var freteDepoisDesconto = Compor(frete, desconto);
Console.WriteLine(descontoDepoisFrete(100m)); // 105,00
Console.WriteLine(freteDepoisDesconto(100m)); // 103,50
Os resultados são diferentes: 100 × 0,90 + 15 = 105, mas (100 + 15) × 0,90 = 103,50. No segundo caso, o desconto incide também sobre o frete. A composição de funções não é comutativa, e a ordem é uma regra de negócio, que precisa ser decidida e documentada, e não um detalhe de implementação. Compor devolve uma closure que captura as duas funções recebidas: é uma função de ordem superior tanto no que recebe quanto no que devolve. Pipelines de processamento, como os de middlewares de uma API na Fase 6, são construídos exatamente com essa ideia.