1. Introduzione
Quando scriviamo
worker.WorkCompleted += listener.OnWorkCompleted;
stiamo in realtà aggiungendo un puntatore al metodo nella "invocation list" (multicast delegate) dell'evento. Questa "lista" dentro l'evento è solo una sequenza di metodi che verranno chiamati quando l'evento viene sollevato. In C# l'evento è implementato sopra un delegate che supporta più subscriber.
Immagina una newsletter: hai una lista di iscritti (indirizzi email). Quando invii la newsletter (scateni l'evento), tutti gli iscritti ricevono la mail. Se qualcuno si disiscrive, viene rimosso dalla lista e non riceve più le mail.
Come aggiungere o rimuovere un subscriber
La sottoscrizione (+=) e l'unsubscribe (-=) operano sul delegate interno all'evento. Ecco un esempio con una lambda, che puoi sia sottoscrivere che rimuovere:
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Lambda] Lavoro completato: {e.Message}");
};
worker.WorkCompleted += handler; // Sottoscriviamo
worker.WorkCompleted -= handler; // Rimuoviamo
Nel caso di metodi normali l'unsubscribe è uguale:
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
Nota: se hai sottoscritto lo stesso metodo più volte, verrà chiamato più volte e per rimuoverlo devi invocare -= lo stesso numero di volte.
2. Perché gestirle manualmente?
Perché è importante gestire i subscriber?
Nelle applicazioni reali, soprattutto long-lived (es. desktop o server), una gestione scorretta delle sottoscrizioni può causare memory leak. Se un oggetto sottoscrittore non serve più ma è ancora "appeso" nella lista degli subscriber, non verrà raccolto dal garbage collector — perché c'è ancora un riferimento a lui dal delegate dell'evento.
Illustrazione
| Azione | Risultato per il subscriber |
|---|---|
| += (sottoscritto) | Aggiunto alla lista |
| -= (rimosso) | Rimosso dalla lista |
| Oggetto subscriber eliminato | Se NON disiscritto! — NON eliminato, perché c'è ancora il riferimento nell'evento |
| Oggetto subscriber eliminato | Se DISISCRITTO — verrà rimosso normalmente |
Come scoprire chi è sottoscritto a un evento?
Gli eventi incapsulano la lista dei subscriber, quindi dall'esterno della classe-publisher non puoi ottenere direttamente quella lista — puoi solo aggiungere (+=) o rimuovere (-=) handler.
Tuttavia dentro la classe dove è dichiarato l'evento basato su un delegate (es. EventHandler) è possibile ottenere la lista corrente dei subscriber usando il metodo GetInvocationList():
// Dentro la classe-publisher
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"Handler: {subscriber.Method.Name}, Oggetto: {subscriber.Target}");
}
}
Questo approccio è raro nel day-to-day, ma può essere utile per il debug o per implementare l'unsubscribe di massa di tutti i subscriber.
3. Chiamare eventi in modo sicuro: "mine" e come evitarle
Cosa può andare storto quando si chiama un evento?
Tutto sembra semplice: chiami
WorkCompleted?.Invoke(this, args);
e tutto funziona... La maggior parte del tempo! Ma ci sono delle sfumature. Eccole:
1. Pericolo multithread
In un'app multithread è possibile che tra il controllo dell'evento per null e la chiamata qualcuno cambi le sottoscrizioni. Per esempio:
1) Thread A controlla: WorkCompleted != null.
2) Nel frattempo thread B si disiscrive dall'evento (-=), e la lista degli handler diventa vuota.
3) Thread A prova a invocare WorkCompleted.Invoke(...) — scatta una NullReferenceException, perché non ci sono più handler.
Questa è una classica race condition negli eventi.
2. Eccezioni inaspettate negli handler
Se uno dei subscriber lancia un'eccezione mentre gestisce l'evento, la chiamata agli altri handler viene interrotta. Quindi l'evento "si rompe" al primo errore e gli altri subscriber non ricevono la notifica. Per evitarlo, è consigliabile mettere la chiamata di ciascun handler in un try-catch, se è importante che tutti ricevano il segnale.
3. Perdita di contesto indesiderata
Un handler è spesso un metodo d'istanza che cattura il riferimento all'oggetto subscriber (this). Se il subscriber dimentica di disiscriversi dal publisher, il riferimento a lui rimane nella invocation list del delegate del publisher. Di conseguenza il GC non potrà liberare quell'oggetto — ottieni un memory leak.
Come invocare un evento in modo sicuro?
1) Copiare il delegate in una variabile locale
Chiamare tramite una variabile locale garantisce che durante la chiamata la lista degli subscriber non cambi:
// Il vecchio metodo affidabile
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
E più modernamente, con l'operatore null-conditional:
WorkCompleted?.Invoke(this, args);
Nella maggior parte dei casi questo è sufficiente, perché il compilatore C# "capisce" questa costruzione e fa la copia interna del riferimento (vedi documentazione ufficiale).
2) Protezione dalle eccezioni degli handler
Se è critico che tutti gli handler vengano chiamati (anche se uno fallisce), itera manualmente:
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// Logghiamo, ma non facciamo fallire l'intero evento
Console.WriteLine($"Errore nell'handler: {ex.Message}");
}
}
}
Questo pattern è raro per scenari UI semplici, ma utile in librerie, logger e sistemi complessi.
3) Prevenire memory leak
Se il subscriber "vive" meno del publisher (es. una window che si sottoscrive a eventi dell'app), deve disiscriversi:
worker.WorkCompleted -= listener.OnWorkCompleted;
Altrimenti il garbage collector non potrà liberare listener, anche se non ci sono più riferimenti "visibili" ad esso.
4. Esempio pratico: manager per sottoscrizioni e unsubscribe di massa
Estendiamo l'app educativa. Immaginiamo di avere diversi listener — vogliamo sottoscriverli e rimuoverli dinamicamente durante l'esecuzione.
public class WorkListener
{
private readonly string _name;
public WorkListener(string name)
{
_name = name;
}
public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"Listener {_name}: {e.Message}");
}
}
Nel programma principale:
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Ivan"),
new WorkListener("Maria"),
new WorkListener("Denis")
};
// Sottoscriviamo tutti i listener
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// Solleviamo l'evento
worker.DoWork();
// Unsubscribe di massa
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// Controlliamo che nessuno reagisca più
worker.DoWork();
In console dopo la prima DoWork appariranno 3 messaggi, dopo la seconda — nessuno.
5. Suggerimenti per lavorare in sicurezza con gli eventi
- Disiscriviti in tempo, se il ciclo di vita del subscriber è più breve di quello del publisher.
- Se implementi il pattern "publisher long-lived — subscriber transient", fai sempre l'unsubscribe, per esempio in Dispose(), alla chiusura della finestra o alla fine esplicita della vita dell'oggetto.
- Per eventi one-shot puoi usare un handler anonimo (lambda) che si toglie da solo dentro il suo corpo:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("Evento gestito una sola volta!");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- Non conservare riferimenti ai subscriber o agli handler per "controllare chi è sottoscritto" — non è necessario nella normale business logic. Fallo solo per debug.
6. Errori comuni e come evitarli
Errore #1: dimenticare di disiscriversi — memory leak.
Se un subscriber non si disiscrive, specialmente in applicazioni grandi con molti eventi e subscriber, gli oggetti possono rimanere in memoria più a lungo del necessario. Questo problema spesso non si nota subito ma porta a crescita dell'uso di memoria e degradazione delle performance.
Errore #2: invocare l'evento senza controllare il null.
Se l'evento non ha subscriber e provi a invocarlo direttamente, otterrai una NullReferenceException. Nelle versioni recenti di C# aiuta l'operatore di chiamata sicura ?., ma se lavori con codice legacy o iteri manualmente gli handler, non dimenticare il controllo su null.
Errore #3: un'eccezione in un handler interrompe gli altri.
Se uno degli handler lancia un'eccezione, i successivi non saranno chiamati. Se è importante che tutti i subscriber ricevano la notifica, itera gli handler in un ciclo e incapsula ogni chiamata in un blocco try/catch.
GO TO FULL VERSION