Vai al contenuto

Ti sono utili questi appunti? Sostieni AppuntiFacili con una piccola donazione.

Dona con PayPal

Delegates

Dennis Turco 9 min di lettura Avanzato
  • #csharp
  • #programmazione
  • #delegati
In questa lezione

1. Introduzione

Un delegato è un tipo reference di .NET che incapsula in modo sicuro un riferimento a uno o più metodi compatibili con una certa firma. Spesso viene descritto come un “puntatore a funzione tipizzato”, ma in C# è qualcosa di più strutturato:

  • è un tipo vero e proprio, generato come classe derivata da System.MulticastDelegate;
  • conserva il metodo da invocare e, per i metodi di istanza, anche il target object;
  • può contenere una lista di invocazione (più metodi);
  • il compilatore controlla parametri, tipo di ritorno e varianza.

I delegati servono per passare comportamenti come parametri, implementare callback e strategie, costruire eventi e alimentare lambda e LINQ. Li usi continuamente anche senza accorgertene: List<T>.ForEach(Action<T>), Enumerable.Where(Func<T, bool>), Task.Run(Action), Button.Click.

Action<string> stampa = Console.WriteLine;
stampa("Ciao"); // Ciao

Qui non stai salvando “il codice del metodo” in una variabile: stai creando un oggetto delegato che sa come chiamare in sicurezza Console.WriteLine.

2. Dichiarazione

Per dichiarare un tipo delegato si usa la parola chiave delegate:

[modificatore] delegate [tipo_ritorno] [nome_delegato]([lista_parametri]);

public class Program
{
    public delegate void Delegato(string messaggio);

    public static void MetodoStampa(string messaggio)
    {
        Console.WriteLine(messaggio);
    }

    public static void Main()
    {
        Delegato del = MetodoStampa;
        del("Hello World"); // equivale a MetodoStampa("Hello World")
    }
}

Un metodo è assegnabile a un delegato se ha lo stesso numero di parametri, tipi compatibili, tipo di ritorno compatibile e lo stesso uso di ref, out, in.

2.1 Passare un comportamento come parametro

Il vero vantaggio emerge quando il delegato diventa un parametro: lo stesso metodo cambia comportamento a seconda della funzione ricevuta.

public delegate int Operazione(int a, int b);

public static int Somma(int x, int y) => x + y;
public static int Moltiplica(int x, int y) => x * y;

public static int Applica(int a, int b, Operazione op) => op(a, b);

Console.WriteLine(Applica(3, 4, Somma));      // 7
Console.WriteLine(Applica(3, 4, Moltiplica)); // 12

2.2 Delegati statici e di istanza

Un delegato può riferirsi sia a un metodo statico sia a un metodo di istanza. Nel secondo caso memorizza anche l’oggetto su cui chiamarlo.

public delegate void Logger(string messaggio);

public class ConsoleLogger
{
    public string Prefisso { get; set; } = "LOG";
    public void Log(string messaggio) => Console.WriteLine($"{Prefisso}: {messaggio}");
}

public static void LogStatico(string messaggio) => Console.WriteLine($"STATICO: {messaggio}");

Logger a = LogStatico;

var logger = new ConsoleLogger();
Logger b = logger.Log;

a("ciao");               // STATICO: ciao
logger.Prefisso = "DBG";
b("mondo");              // DBG: mondo  (usa lo stato attuale dell'oggetto)
Console.WriteLine(a.Target == null); // True

Nota

Ogni delegate viene compilato in una classe sealed che eredita da MulticastDelegate, con un costruttore e un metodo Invoke(...). Scrivere del("x") equivale a del.Invoke("x"): il delegato è un oggetto creato a runtime, non una “funzione volante”.

Ha senso dichiarare un delegate personalizzato quando il nome comunica un’intenzione di dominio (ValidationRule, CalcoloCommissione) meglio di un generico Func<...>; per il resto si usano i delegati predefiniti (sezione 5).

3. Multicasting di un delegato

Un delegato multicast referenzia più metodi:

  • + o += aggiungono un metodo, - o -= lo rimuovono;
  • i metodi vengono invocati in ordine di registrazione.
public delegate void Delegato(string messaggio);

public static void MetodoA(string msg) => Console.WriteLine("A: " + msg);
public static void MetodoB(string msg) => Console.WriteLine("B: " + msg);
public static void MetodoC(string msg) => Console.WriteLine("C: " + msg);

Delegato d = MetodoA;
d += MetodoB;
d += MetodoC;
d("prova");   // A: prova / B: prova / C: prova

d -= MetodoB;
d("dopo");    // A: dopo / C: dopo

Attenzione

Rimuovere una lambda con -= non funziona se scrivi di nuovo la stessa lambda: d -= msg => Console.WriteLine(msg); crea un nuovo delegato e non rimuove nulla. Se devi disiscriverti, salva la lambda in una variabile.

3.1 Invocation list ed eccezioni

Con GetInvocationList() ottieni i singoli delegati della catena. È utile soprattutto per un motivo: se un handler lancia un’eccezione, quelli successivi non vengono eseguiti. Invocandoli uno per uno puoi isolare gli errori:

Action azione = () => Console.WriteLine("Handler A");
azione += () => throw new InvalidOperationException("boom");
azione += () => Console.WriteLine("Handler C");

foreach (Action handler in azione.GetInvocationList())
{
    try
    {
        handler();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Errore: {ex.Message}");
    }
}
// Handler A
// Errore: boom
// Handler C

3.2 Immutabilità e thread safety

I delegati sono immutabili: d += Metodo crea un nuovo delegato con una nuova lista. Resta però un rischio classico:

if (OnNotifica != null)
{
    OnNotifica("messaggio"); // un altro thread potrebbe averlo appena reso null
}

La forma corretta è leggere il delegato una sola volta:

OnNotifica?.Invoke("messaggio");

// equivalente esplicito: copia locale
var copia = OnNotifica;
copia?.Invoke("messaggio");

4. Delegati con tipo di ritorno

In un delegato multicast con valore di ritorno vengono eseguiti tutti i metodi, ma la chiamata restituisce solo il valore dell’ultimo.

public delegate int Calcola();

Calcola calc = () => 10;
calc += () => 20;
calc += () => 30;

Console.WriteLine(calc()); // 30

Se ti servono tutti i risultati devi iterare la invocation list:

List<int> risultati = new();

foreach (Calcola singolo in calc.GetInvocationList())
{
    risultati.Add(singolo());
}

Console.WriteLine(string.Join(", ", risultati)); // 10, 20, 30

Attenzione

Non usare un delegato multicast con ritorno per modellare votazioni o aggregazioni: è un errore frequente. Meglio una List<Func<T>> esplicita su cui applicare Select, Sum, All o simili.

I delegati con ritorno sono invece perfetti come singola strategia: calcolo di uno sconto, mapping, factory, comparatore (Comparison<T>).

5. Delegati predefiniti: Action, Func e Predicate

C# fornisce delegati generici già pronti, che nella maggior parte dei casi evitano di dichiarare nuovi tipi delegate.

DelegatoRitornoEsempio
Action<T1, ...>voidAction<string> log
Func<T1, ..., TResult>TResult (ultimo parametro generico)Func<string, int> lunghezza
Predicate<T>bool, un solo parametroPredicate<int> pari
Action<string> stampa = msg => Console.WriteLine(msg);
Func<int, int, int> somma = (a, b) => a + b;
Func<string, int> lunghezza = s => s.Length;
Predicate<int> pari = n => n % 2 == 0;

stampa("Ciao");                     // Ciao
Console.WriteLine(somma(5, 4));     // 9
Console.WriteLine(lunghezza("C#")); // 2
Console.WriteLine(pari(4));         // True

5.1 Esempi d’uso reali

Action per avvolgere un’operazione con logica comune, Func<T> per misurarne il tempo restituendo il risultato:

public static void EseguiConAudit(Action operazione)
{
    Console.WriteLine("Inizio");
    operazione();
    Console.WriteLine("Fine");
}

public static T Misura<T>(Func<T> operazione)
{
    var sw = System.Diagnostics.Stopwatch.StartNew();
    T risultato = operazione();
    Console.WriteLine($"Durata: {sw.ElapsedMilliseconds} ms");
    return risultato;
}

EseguiConAudit(() => Console.WriteLine("Salvataggio..."));
long totale = Misura(() => Enumerable.Range(1, 1_000_000).Sum(x => (long)x));

Predicate<T> è usato dai metodi di List<T> come Find, Exists, RemoveAll:

List<string> email = new() { "a@test.it", "b@azienda.it", "c@test.it" };
Predicate<string> aziendale = e => e.EndsWith("@azienda.it");

Console.WriteLine(email.Exists(aziendale)); // True
email.RemoveAll(aziendale);
Console.WriteLine(email.Count);             // 2

Suggerimento

Regola pratica: Action/Func per codice tecnico e firme semplici; un delegate custom quando il nome aggiunge significato. delegate decimal CalcoloCommissione(decimal importo, Cliente c) dice molto più di Func<decimal, Cliente, decimal>.

5.2 Delegato o interfaccia?

Entrambi modellano comportamenti, ma con scopi diversi:

  • delegato: “passami una funzione”. Ideale per una singola operazione, callback brevi, strategie semplici; si integra con lambda e LINQ;
  • interfaccia: “dammi un collaboratore”. Meglio quando il contratto ha più metodi correlati, stato interno o dipendenze.
// Delegato: basta una funzione
public static void ElaboraOrdine(Ordine ordine, Action<Ordine> callback) => callback(ordine);

// Interfaccia: più responsabilità correlate
public interface IOrderProcessor
{
    void Validate(Ordine ordine);
    void Save(Ordine ordine);
    void Notify(Ordine ordine);
}

6. Metodi anonimi e lambda expressions

Puoi creare un delegato inline, senza dichiarare un metodo separato. La forma storica è il metodo anonimo, quella moderna (e più usata) è la lambda con =>:

// Metodo anonimo
Action<string> a = delegate(string s) { Console.WriteLine($"Anonimo: {s}"); };

// Lambda con espressione singola
Action<string> b = s => Console.WriteLine($"Lambda: {s}");

// Lambda con body a blocco
Func<int, int> elaborata = x =>
{
    int doppio = x * 2;
    return doppio + 1;
};

a("ciao");                         // Anonimo: ciao
b("ciao");                         // Lambda: ciao
Console.WriteLine(elaborata(5));   // 11

Molto spesso il compilatore deduce il tipo di delegato dal contesto: in numeri.Where(n => n % 2 != 0) la lambda diventa un Func<int, bool>.

var numeri = new List<int> { 5, 2, 8, 1, 4 };

var risultato = numeri
    .Where(n => n > 1)      // Func<int, bool>
    .OrderBy(n => n)        // Func<int, int>
    .Select(n => $"#{n}");  // Func<int, string>

Console.WriteLine(string.Join(" ", risultato)); // #2 #4 #5 #8

Suggerimento

Se una lambda supera poche righe, estraila in un metodo con un nome chiaro: puoi passarlo direttamente al posto della lambda (numeri.Where(IsValido)) e il codice diventa più leggibile e testabile.

6.1 Closure

Quando una lambda usa variabili esterne, il compilatore crea una closure: la lambda cattura la variabile, non una copia del suo valore.

int contatore = 0;
Action incrementa = () => contatore++;

incrementa();
incrementa();
Console.WriteLine(contatore); // 2

int soglia = 10;
Func<int, bool> maggiore = n => n > soglia;
Console.WriteLine(maggiore(50)); // True
soglia = 100;
Console.WriteLine(maggiore(50)); // False: la lambda vede il nuovo valore

Le closure sono comode per creare funzioni “configurate”, come una factory di moltiplicatori:

static Func<int, int> CreaMoltiplicatore(int fattore) => x => x * fattore;

var doppio = CreaMoltiplicatore(2);
var triplo = CreaMoltiplicatore(3);
Console.WriteLine(doppio(5)); // 10
Console.WriteLine(triplo(5)); // 15

6.2 Il capture trap nei cicli

List<Action> azioni = new();

for (int i = 0; i < 3; i++)
{
    azioni.Add(() => Console.WriteLine(i));
}

foreach (var azione in azioni) azione(); // 3 3 3

Tutte le lambda catturano la stessa variabile i, che a fine ciclo vale 3. La correzione è una copia locale per ogni iterazione:

for (int i = 0; i < 3; i++)
{
    int copia = i;
    azioni.Add(() => Console.WriteLine(copia)); // 0 1 2
}

Pericolo

Le closure mantengono vivi gli oggetti catturati finché vive il delegato. Catturare variabili mutabili in callback asincrone o su più thread crea stato condiviso invisibile e bug difficili da riprodurre: cattura solo ciò che serve, preferibilmente valori immutabili.

7. Delegati ed eventi

Gli eventi sono costruiti sui delegati, con una restrizione fondamentale: dall’esterno della classe puoi solo iscriverti (+=) o disiscriverti (-=), non puoi invocare l’evento né sostituire la lista dei subscriber.

public delegate void Notifica(string message);

public class Publisher
{
    public event Notifica? OnNotifica;

    public void Invia() => OnNotifica?.Invoke("Evento generato!");
}

public class Subscriber
{
    public void Ricevi(string msg) => Console.WriteLine($"Ricevuto: {msg}");
}

var pub = new Publisher();
var sub = new Subscriber();

pub.OnNotifica += sub.Ricevi;
pub.Invia();                   // Ricevuto: Evento generato!

// pub.OnNotifica("ciao");     // errore di compilazione: fuori dalla classe
// pub.OnNotifica = null;      // errore di compilazione

Senza la keyword event, un campo delegato pubblico potrebbe essere sovrascritto con = da chiunque, cancellando tutti gli altri subscriber.

Nota

Il compilatore genera per ogni evento due accessors, add e remove. Puoi scriverli a mano (event EventHandler X { add { ... } remove { ... } }) per loggare le iscrizioni o gestire la sincronizzazione, ma è raramente necessario.

7.1 Pattern standard: EventHandler<TEventArgs>

Il pattern idiomatico .NET prevede handler con firma (object? sender, TEventArgs e):

  1. crea una classe dati che eredita da EventArgs;
  2. dichiara l’evento come EventHandler<TEventArgs>;
  3. invocalo passando this come sender.
public class DownloadCompletedEventArgs : EventArgs
{
    public string FileName { get; }
    public long Size { get; }

    public DownloadCompletedEventArgs(string fileName, long size)
    {
        FileName = fileName;
        Size = size;
    }
}

public class Downloader
{
    public event EventHandler<DownloadCompletedEventArgs>? DownloadCompleted;

    public void Complete(string fileName, long size) =>
        DownloadCompleted?.Invoke(this, new DownloadCompletedEventArgs(fileName, size));
}

var downloader = new Downloader();
downloader.DownloadCompleted += (sender, e) =>
    Console.WriteLine($"Scaricato {e.FileName} ({e.Size} byte)");

downloader.Complete("report.pdf", 1200); // Scaricato report.pdf (1200 byte)

Se non devi trasportare dati usa EventHandler e passa EventArgs.Empty.

Pericolo

Il publisher mantiene un riferimento a ogni subscriber iscritto. Se un oggetto di breve durata (una finestra, un view model) si iscrive a un publisher di lunga durata e non fa mai -=, non verrà mai raccolto dal garbage collector: è una delle cause più comuni di memory leak in .NET.

8. Covarianza e controvarianza

I delegati permettono di usare tipi più specifici o più generici in modo sicuro rispetto alla firma:

  • covarianza (ritorno): un delegato che promette un Animale può restituire un Cane;
  • controvarianza (parametri): un metodo che accetta un Animale può essere usato dove serve un handler per Cane.
public class Animale { }
public class Cane : Animale { }

public delegate Animale CreaAnimale();
public delegate void GestisciCane(Cane c);

static Cane NuovoCane() => new Cane();
static void Descrivi(Animale a) => Console.WriteLine(a.GetType().Name);

CreaAnimale crea = NuovoCane;   // covarianza: ritorna un tipo derivato
GestisciCane gestisci = Descrivi; // controvarianza: accetta un tipo base

gestisci(new Cane());           // Cane

Lo stesso vale per i delegati generici, grazie ai modificatori out e in nelle loro dichiarazioni (Func<out TResult>, Action<in T>):

Func<Cane> creaCane = () => new Cane();
Func<Animale> creaAnimale = creaCane;     // OK: covarianza

Action<Animale> logAnimale = a => Console.WriteLine("Animale");
Action<Cane> logCane = logAnimale;        // OK: controvarianza

Questa flessibilità evita di scrivere adattatori inutili quando si lavora con gerarchie di tipi.

9. Quiz

Mettiti alla prova

0/10 risposte

  1. Cos'è un delegato in C#?

  2. Cosa fa l'operatore += su un delegato?

  3. In un delegato multicast, cosa succede se il secondo di tre handler lancia un'eccezione?

  4. In un delegato multicast con tipo di ritorno, quale valore viene restituito?

  5. Quale delegato predefinito rappresenta un metodo che non restituisce valore?

  6. Func<string, int> rappresenta:

  7. Cosa stampa un ciclo for che aggiunge () => Console.WriteLine(i) a una lista per i da 0 a 2, se poi si invocano le lambda?

  8. Quale operazione NON è consentita su un evento dall'esterno della classe che lo dichiara?

  9. La covarianza in un delegato permette di:

  10. Qual è la forma consigliata per invocare un delegato che potrebbe essere null?

10. Esercizi

10.1 Esercizio

Scenario: Vuoi creare un piccolo motore di calcolo personalizzabile, dove l’utente può scegliere che tipo di operazione applicare su due numeri.

Consegna:

  1. Crea un delegato Operazione(int a, int b) che restituisce un int.
  2. Implementa tre metodi statici:
    • Somma(int a, int b)
    • Differenza(int a, int b)
    • Moltiplicazione(int a, int b)
  3. Scrivi un metodo EseguiOperazione(int a, int b, Operazione op) che invoca il delegato passato.
  4. Nel Main, chiedi all’utente di scegliere l’operazione da eseguire e usa il delegato corrispondente.

Obiettivo didattico: capire come passare comportamenti come parametri, e come cambiare la logica di un metodo a runtime.

10.2 Esercizio

Scenario: Hai un’applicazione che deve registrare log in modo diverso a seconda del contesto (console, file, o rete). Vuoi che il tipo di logging sia pluggabile tramite delegati.

Consegna:

  1. Crea un delegato Logger(string messaggio).
  2. Implementa tre metodi diversi:
    • LogConsole(string msg) → scrive in console.
    • LogFile(string msg) → scrive su file log.txt.
    • LogNetwork(string msg) → simula l’invio di un log in rete (es. Console.WriteLine("Inviato: " + msg)).
  3. Crea un metodo RegistraOperazione(string azione, Logger log) che invoca il logger passato.
  4. Nel Main, esegui varie chiamate con diversi logger e poi combina console e file in un unico delegato multicast.

Obiettivo didattico: imparare a disaccoppiare la logica di logging dal codice principale, passando comportamenti dinamici tramite delegati.

10.3 Esercizio

Scenario: Hai una lista di numeri interi e vuoi filtrarla in modo dinamico (pari, dispari, maggiori di N, ecc.) usando Predicate<int>.

Consegna:

  1. Crea una lista di interi da 1 a 20.
  2. Crea un metodo Filtra(List<int> numeri, Predicate<int> condizione) che restituisce una nuova lista contenente solo gli elementi che rispettano la condizione.
  3. Usa il metodo tre volte con filtri diversi:
    • Numeri pari
    • Maggiori di N (con N letto da una variabile catturata dalla lambda)
    • Multipli di 3
  4. Stampa i risultati a video.

Obiettivo didattico: comprendere come usare delegati predefiniti e closure per scrivere codice generico e riutilizzabile.

10.4 Esercizio

Scenario: Vuoi simulare un contatore di download che notifica gli utenti quando viene raggiunto un certo numero di download.

Consegna:

  1. Crea una classe LimiteRaggiuntoEventArgs : EventArgs con la proprietà Conteggio.
  2. Crea una classe DownloadManager con:
    • Un evento EventHandler<LimiteRaggiuntoEventArgs>? LimiteRaggiunto
    • Un campo conteggio
    • Un metodo RegistraDownload() che incrementa il contatore e, se arriva a 5, genera l’evento.
  3. Crea due classi “ascoltatrici”:
    • EmailNotifier che stampa: “Email inviata: limite di N download”
    • ConsoleNotifier che stampa: “Notifica: limite di N download”
  4. Nel Main, collega entrambe le classi all’evento, simula più download e infine disiscrivi EmailNotifier.

Obiettivo didattico: collegare il concetto di delegato → evento → subscriber, come accade nei sistemi reali.