C# Eventos

Um evento é o jeito de um objeto avisar "aconteceu alguma coisa" para quem quiser saber, sem conhecer essas pessoas de antemão. O estoque da lojinha da Ana avisa quando um produto está acabando; um botão avisa que foi clicado; um download avisa que terminou. Por baixo, um evento é um delegate multicast (com += e -=) com uma proteção a mais: só o dono pode disparar.

Você vai aprender
  • O que é um evento e quem são o publicador e os inscritos.
  • Declarar com event, inscrever com +=, sair com -= e disparar com ?.Invoke.
  • Por que usar event e não um delegate público (com a demonstração do estrago).
  • O padrão do .NET: EventHandler<T>, o sender, uma classe de EventArgs e o método OnEvento.
  • A armadilha de tentar tirar uma lambda com -=.
  • Os erros reais: CS0070, CS0123, o aviso CS8618 e a NullReferenceException.

Todos os programas rodaram com o .NET 10 (C# 14) na máquina da Ana, na pasta ~/cs, com dotnet run arquivo.cs. Você vai precisar de classes e das aulas de Delegates, Lambdas e Func e Action.


A ideia: o aviso de "chegou o produto"

Na lojinha, vários clientes perguntam pelo mesmo caderno, que está em falta. A Ana não vai ligar para a fábrica de hora em hora. Ela anota o telefone de cada interessado numa lista de aviso, e quando o caderno chega, liga para todos da lista. Quem desistir pede para sair da lista.

Na lojinhaEm C#
A lista de aviso do cadernoO evento: public event Action<string>? EstoqueBaixo;
Quem tem a lista e liga para todos: a AnaO publicador: a classe que declara e dispara o evento
Os clientes interessadosOs inscritos (subscribers): métodos ou lambdas
Pôr o nome na lista / pedir para sair+= / -=
Ligar para todosDisparar o evento: EstoqueBaixo?.Invoke(produto)

O ponto importante: a Ana não precisa saber quem está na lista nem o que cada um vai fazer com o aviso. O estoque só avisa; cada inscrito decide sua reação.


1. O primeiro evento

Estoque cadernos = new("Caderno", 5);

cadernos.EstoqueBaixo += AvisarAna;
cadernos.EstoqueBaixo += produto => Console.WriteLine($"[compras] Pedir mais {produto}");

cadernos.Vender(2);
cadernos.Vender(2);
cadernos.Vender(1);

static void AvisarAna(string produto) => Console.WriteLine($"[Ana] Está acabando: {produto}");

class Estoque(string produto, int quantidade)
{
    public event Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        Console.WriteLine($"Vendeu {qtd} {produto}, sobrou {quantidade}");
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}
$ dotnet run estoque.cs
Vendeu 2 Caderno, sobrou 3
Vendeu 2 Caderno, sobrou 1
[Ana] Está acabando: Caderno
[compras] Pedir mais Caderno
Vendeu 1 Caderno, sobrou 0
[Ana] Está acabando: Caderno
[compras] Pedir mais Caderno

Primeiro, o lado do publicador (a classe Estoque):

class Estoque(string produto, int quantidade)
Uma classe com construtor primário C# 12: os parâmetros entre parênteses ficam disponíveis na classe inteira (veja Construtores). new("Caderno", 5) cria um estoque de 5 cadernos.
public event
Declara um evento. É público, para que outros possam se inscrever.
Action<string>
O tipo delegate do evento: diz o formato dos inscritos. Aqui, métodos que recebem um string (o nome do produto) e não devolvem nada (veja Func, Action e Predicate).
?
O evento começa null: ninguém se inscreveu ainda. O ? diz isso ao compilador (veja Referências Anuláveis).
EstoqueBaixo
O nome do evento. A convenção é um nome que diz o que aconteceu: EstoqueBaixo, VendaFeita, Clicou.
EstoqueBaixo?.Invoke(produto)
Dispara o evento: chama todos os inscritos, na ordem em que se inscreveram. O ?. evita o erro quando ninguém está inscrito.

Agora o lado dos inscritos (as instruções do começo do arquivo):

LinhaO que faz
cadernos.EstoqueBaixo += AvisarAna;Inscreve o método AvisarAna (sem parênteses: é o método, não a chamada).
cadernos.EstoqueBaixo += produto => …;Inscreve uma lambda. O produto recebe o texto que o estoque mandou no Invoke.
cadernos.Vender(2);Sobram 3: mais que 2, nenhum aviso.
cadernos.Vender(2);Sobra 1: o estoque dispara o evento, e os dois inscritos reagem.
cadernos.Vender(1);Sobra 0: dispara de novo.

Repare que a classe Estoque não sabe nada sobre a Ana nem sobre o setor de compras. Amanhã alguém pode inscrever um terceiro método (mandar um e-mail, gravar num arquivo) sem mexer em uma linha do Estoque.

Missão: ninguém está inscrito no evento, então as vendas acontecem sem aviso. Inscreva no EstoqueBaixo uma lambda que escreva Repor: seguido do nome do produto. A saída deve terminar com Repor: Caderno.

Estoque cadernos = new("Caderno", 4);

// inscreva-se no evento aqui

cadernos.Vender(1);
cadernos.Vender(2);

class Estoque(string produto, int quantidade)
{
    public event Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        Console.WriteLine($"Vendeu {qtd} {produto}, sobrou {quantidade}");
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}
Estoque cadernos = new("Caderno", 4);

cadernos.EstoqueBaixo += produto => Console.WriteLine($"Repor: {produto}");

cadernos.Vender(1);
cadernos.Vender(2);

class Estoque(string produto, int quantidade)
{
    public event Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        Console.WriteLine($"Vendeu {qtd} {produto}, sobrou {quantidade}");
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}

2. Por que event? O estrago de um delegate público

Se o event é "só" um delegate, por que não usar um campo público public Action<string>? EstoqueBaixo;? Veja o que o código de fora consegue fazer com ele. Esta versão tira a palavra event:

Estoque cadernos = new("Caderno", 3);

cadernos.EstoqueBaixo += p => Console.WriteLine($"[Ana] Está acabando: {p}");
cadernos.EstoqueBaixo = p => Console.WriteLine($"[Bruno] {p}?");
cadernos.EstoqueBaixo("Mochila");
cadernos.Vender(2);

class Estoque(string produto, int quantidade)
{
    public Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}
$ dotnet run campo.cs
[Bruno] Mochila?     « alarme falso: ninguém vendeu mochila nenhuma
[Bruno] Caderno?     « e o aviso da Ana sumiu

Dois estragos, os dois em silêncio:

  1. = no lugar de +=: o Bruno trocou a lista inteira pela lambda dele. A inscrição da Ana foi apagada, e ninguém foi avisado disso.
  2. Disparar de fora: cadernos.EstoqueBaixo("Mochila") deu um aviso falso. Só o estoque deveria saber quando o estoque está baixo.

Agora, a mesma coisa com public event Action<string>? EstoqueBaixo;:

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

Estoque cadernos = new("Caderno", 3);

cadernos.EstoqueBaixo += p => Console.WriteLine($"[Ana] Está acabando: {p}");
cadernos.EstoqueBaixo = p => Console.WriteLine($"[Bruno] {p}?");
cadernos.EstoqueBaixo("Mochila");

class Estoque(string produto, int quantidade)
{
    public event Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}
$ dotnet run evento-erro.cs
/home/ana/cs/evento-erro.cs(4,10): error CS0070: O evento "Estoque.EstoqueBaixo" pode apenas aparecer à esquerda de + = ou -= (exceto quando usado de dentro do tipo "Estoque")
/home/ana/cs/evento-erro.cs(5,10): error CS0070: O evento "Estoque.EstoqueBaixo" pode apenas aparecer à esquerda de + = ou -= (exceto quando usado de dentro do tipo "Estoque")

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

"O evento só pode aparecer à esquerda de += ou -=, exceto dentro do tipo Estoque" (a tradução escreve + = com um espaço; em inglês, The event 'Estoque.EstoqueBaixo' can only appear on the left hand side of += or -=). A linha 3 (+=) passou; as linhas 4 (=) e 5 (disparar) foram barradas.

De fora da classe, com…delegate públicoevent
+= (se inscrever)podepode
-= (sair)podepode
= (trocar a lista toda)pode (apaga os outros!)CS0070
Disparar: Evento(…), Evento.Invoke(…)pode (aviso falso!)CS0070

Regra prática: se o objetivo é avisar outros objetos, use event. É uma questão de encapsulamento: o estoque controla quando avisa.


3. O padrão do .NET: EventHandler<T>

Os eventos da biblioteca do .NET (e de quase todo código C# que você vai ler) seguem um padrão fixo. Em vez de um Action qualquer, eles usam o delegate pronto EventHandler ("tratador de evento"), que sempre recebe dois parâmetros:

void Tratador(object? sender, TEventArgs e)
              │               └─ os dados do acontecimento ("argumentos do evento")
              └─ quem disparou o evento ("remetente")

Com o sender, um mesmo método pode ouvir vários objetos e saber de qual veio o aviso. Veja dois estoques avisando o mesmo método:

Estoque cadernos = new("Caderno", 5);
Estoque lapis = new("Lápis", 3);

cadernos.EstoqueBaixo += AoFicarBaixo;
lapis.EstoqueBaixo += AoFicarBaixo;

cadernos.Vender(4);
lapis.Vender(1);
lapis.Vender(1);

static void AoFicarBaixo(object? sender, EstoqueBaixoEventArgs e)
{
    Estoque origem = (Estoque)sender!;
    Console.WriteLine($"Aviso de {origem.Produto}: restam {e.Restante}");
}

class EstoqueBaixoEventArgs(int restante) : EventArgs
{
    public int Restante { get; } = restante;
}

class Estoque(string produto, int quantidade)
{
    public string Produto { get; } = produto;

    public event EventHandler<EstoqueBaixoEventArgs>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        if (quantidade <= 1)
        {
            OnEstoqueBaixo(new EstoqueBaixoEventArgs(quantidade));
        }
    }

    protected virtual void OnEstoqueBaixo(EstoqueBaixoEventArgs e)
    {
        EstoqueBaixo?.Invoke(this, e);
    }
}
$ dotnet run handler.cs
Aviso de Caderno: restam 1
Aviso de Lápis: restam 1

O lápis começou com 3: a primeira venda deixou 2 (sem aviso), a segunda deixou 1 (aviso). As peças do padrão:

PeçaO que é
class EstoqueBaixoEventArgs(int restante) : EventArgsA "ficha" com os dados do acontecimento. Por convenção, o nome termina em EventArgs e a classe herda de EventArgs (veja Herança). Para mandar mais dados, acrescente propriedades.
event EventHandler<EstoqueBaixoEventArgs>?O evento no formato padrão: inscritos que recebem (object? sender, EstoqueBaixoEventArgs e).
EstoqueBaixo?.Invoke(this, e)Dispara mandando this (o próprio estoque) como sender, e a ficha e.
protected virtual void OnEstoqueBaixo(…)Por convenção, o disparo fica num método On + nome do evento, protected virtual: classes filhas podem reagir ou mudar o disparo com override (veja virtual e override).
(Estoque)sender!No inscrito, o sender chega como object?. O cast (Estoque) devolve o tipo verdadeiro, e o ! diz ao compilador "aqui não é null". Se não precisar dele, use o descarte: (_, e) => ….
E quando não há dados para mandar?

Use o EventHandler sem <>, cujo segundo parâmetro é um EventArgs "vazio", e dispare com Evento?.Invoke(this, EventArgs.Empty);. Exemplo: public event EventHandler? Fechou; num caixa que só avisa "fechei".

Action ou EventHandler?

Num programa pequeno, só seu, event Action<string>? é mais curto e funciona muito bem. Numa biblioteca que outras pessoas vão usar, siga o padrão EventHandler<T>: é o que todo programador C# espera encontrar, e é o que os editores e as ferramentas do .NET entendem.


4. Sair da lista (-=) e a armadilha da lambda

Para parar de receber avisos, use -= com o mesmo método. Com um método com nome, funciona. Com uma lambda, veja o que acontece:

Estoque cadernos = new("Caderno", 10);

cadernos.EstoqueBaixo += AvisarAna;
cadernos.EstoqueBaixo += p => Console.WriteLine($"[lambda] {p}");
cadernos.Vender(9);

Console.WriteLine("--- tirando os dois");
cadernos.EstoqueBaixo -= AvisarAna;
cadernos.EstoqueBaixo -= p => Console.WriteLine($"[lambda] {p}");
cadernos.Vender(0);

static void AvisarAna(string produto) => Console.WriteLine($"[Ana] {produto}");

class Estoque(string produto, int quantidade)
{
    public event Action<string>? EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        if (quantidade <= 2)
        {
            EstoqueBaixo?.Invoke(produto);
        }
    }
}
$ dotnet run desinscrever.cs
[Ana] Caderno
[lambda] Caderno
--- tirando os dois
[lambda] Caderno     « a lambda continua inscrita!

O -= AvisarAna funcionou. O -= p => … não fez nada, e sem erro nenhum: cada lambda escrita no código é um objeto novo. A segunda lambda tem o mesmo texto, mas não é a mesma que foi inscrita, e o -= não acha nada para tirar.

Como fazer certo: se você vai precisar sair da lista, guarde a lambda numa variável e use a variável nas duas vezes:

Action<string> avisoCompras = p => Console.WriteLine($"[compras] {p}");
cadernos.EstoqueBaixo += avisoCompras;
// …
cadernos.EstoqueBaixo -= avisoCompras;   // agora tira de verdade
Inscrições esquecidas

Enquanto um objeto está inscrito num evento, o publicador guarda uma referência a ele, e o coletor de lixo não pode liberá-lo. Em programas que rodam por muito tempo (um servidor, um aplicativo com janelas), lembre de fazer o -= quando o inscrito não precisa mais dos avisos.


Quando dá errado

CS0070: usar = ou disparar o evento de fora da classe

Visto na seção 2: O evento "Estoque.EstoqueBaixo" pode apenas aparecer à esquerda de + = ou -= (exceto quando usado de dentro do tipo "Estoque").

Como consertar: de fora, use só += e -=. Se o código de fora precisa provocar o aviso, crie um método público na classe (como o Vender) que decide quando disparar.

CS0123: o inscrito não tem o formato do evento

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

Caixa caixa = new();
caixa.Fechou += Avisar;

static void Avisar(string mensagem) => Console.WriteLine(mensagem);

class Caixa
{
    public event EventHandler? Fechou;

    public void Fechar() => Fechou?.Invoke(this, EventArgs.Empty);
}
$ dotnet run assinatura-ev.cs
/home/ana/cs/assinatura-ev.cs(2,1): error CS0123: Nenhuma sobrecarga de "Avisar" corresponde ao representante "EventHandler"

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

O que quer dizer: o evento é um EventHandler, que pede métodos (object? sender, EventArgs e), e Avisar recebe um string.

Como consertar: escreva o inscrito no formato do evento, static void Avisar(object? sender, EventArgs e) => Console.WriteLine("Caixa fechado");, ou use uma lambda que descarta os parâmetros: caixa.Fechou += (_, _) => Console.WriteLine("Caixa fechado");.

CS8618 e NullReferenceException: disparar sem ?.

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

Estoque cadernos = new("Caderno", 3);
cadernos.Vender(2);

class Estoque(string produto, int quantidade)
{
    public event Action<string> EstoqueBaixo;

    public void Vender(int qtd)
    {
        quantidade -= qtd;
        if (quantidade <= 2)
        {
            EstoqueBaixo(produto);
        }
    }
}
$ dotnet run semnulo.cs
/home/ana/cs/semnulo.cs(6,33): warning CS8618: O evento não anulável 'EstoqueBaixo' precisa conter um valor não nulo ao sair do construtor. Considere adicionar o modificador "obrigatório" ou declarar o evento como anulável.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Estoque.Vender(Int32 qtd) in /home/ana/cs/semnulo.cs:line 13
   at Program.<Main>$(String[] args) in /home/ana/cs/semnulo.cs:line 2

O que quer dizer: o aviso CS8618 diz que o evento foi declarado sem ?, como se nunca fosse null, mas nada garante isso (o "modificador obrigatório" da tradução é o required). Como ninguém se inscreveu, o evento era null, e a linha 13 tentou chamar um delegate inexistente. A pilha mostra o caminho: o erro aconteceu dentro de Estoque.Vender, chamado pela linha 2.

Como consertar: declare o evento com ? (event Action<string>? EstoqueBaixo;) e dispare sempre com EstoqueBaixo?.Invoke(produto);.

O -= com lambda não tira nada

Não há erro nem aviso: o inscrito continua recebendo os avisos (veja a seção 4). Como consertar: guarde a lambda numa variável, ou inscreva um método com nome.


Referência

CódigoOndeO que faz
public event Action<T>? Nome;publicadorDeclara um evento simples.
public event EventHandler<XEventArgs>? Nome;publicadorDeclara um evento no padrão do .NET.
public event EventHandler? Nome;publicadorEvento padrão sem dados (dispare com EventArgs.Empty).
Nome?.Invoke(…);só dentro do publicadorDispara o evento, se houver inscritos.
protected virtual void OnNome(XEventArgs e)publicadorMétodo padrão que dispara o evento.
obj.Nome += Metodo;qualquer lugarInscreve um método.
obj.Nome += (s, e) => …;qualquer lugarInscreve uma lambda.
obj.Nome -= Metodo;qualquer lugarTira a inscrição (com lambda, só via variável).

Exercícios

1. Inscrever

Inscreva o método AvisarAna no evento EstoqueBaixo do objeto cadernos:

2. Disparar com segurança

Dentro da classe, dispare o evento VendaFeita (um Action<decimal>?) mandando a variável valor:

3. Declarar

Complete a declaração de um evento público Fechou, no padrão do .NET, sem dados (e que pode ser null):

public event

4. Com public event Action<string>? Aviso;, o que o código de fora da classe pode fazer?

5. obj.Aviso += x => Console.WriteLine(x); e depois obj.Aviso -= x => Console.WriteLine(x);. O que acontece?

6. No padrão EventHandler, o que é o sender?

Missão final: o caixa já tem o evento VendaGrande, e o programa já se inscreveu nele, mas o evento nunca é disparado (o compilador avisa CS0067: "o evento nunca é usado"). Dentro de Registrar, dispare o evento com ?.Invoke quando o valor for de 100 ou mais. A venda de R$ 18,90 não pode dar aviso; a de R$ 129,90 deve mostrar Venda grande: R$ 129,90.

Caixa caixa = new();
caixa.VendaGrande += valor => Console.WriteLine($"Venda grande: {valor:C}");

caixa.Registrar(18.90m);
caixa.Registrar(129.90m);

class Caixa
{
    public event Action<decimal>? VendaGrande;

    public void Registrar(decimal valor)
    {
        Console.WriteLine($"Registrou {valor:C}");
        // dispare o evento aqui, se o valor for de 100 ou mais
    }
}
Caixa caixa = new();
caixa.VendaGrande += valor => Console.WriteLine($"Venda grande: {valor:C}");

caixa.Registrar(18.90m);
caixa.Registrar(129.90m);

class Caixa
{
    public event Action<decimal>? VendaGrande;

    public void Registrar(decimal valor)
    {
        Console.WriteLine($"Registrou {valor:C}");
        if (valor >= 100m)
        {
            VendaGrande?.Invoke(valor);
        }
    }
}

Resumo

  • Um evento avisa "aconteceu algo" para uma lista de inscritos. O publicador declara e dispara; os inscritos reagem.
  • Declare com public event Tipo? Nome;, dispare com Nome?.Invoke(…), inscreva com += e saia com -=.
  • Com event, o código de fora só pode += e -= (CS0070): ninguém apaga os outros inscritos nem dá aviso falso.
  • O padrão do .NET é EventHandler<XEventArgs>: inscritos (object? sender, XEventArgs e), disparo num protected virtual void OnNome.
  • Para tirar uma lambda com -=, guarde-a numa variável; uma lambda nova nunca é a mesma.
  • Sem ? e sem ?., um evento sem inscritos dá CS8618 e NullReferenceException.

Próxima aula: Métodos de Extensão, como "acrescentar" métodos a tipos que não são seus, como decimal e string.