Vai al contenuto

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

Dona con PayPal

Lambda expressions

Dennis Turco 12 min di lettura Intermedio
  • #csharp
  • #programmazione
  • #lambda
  • #linq
  • #funzionale
In questa lezione

1. Introduzione

Le lambda expressions sono il modo più compatto con cui il C# moderno rappresenta una funzione anonima. Una lambda descrive un comportamento che può essere:

  • assegnato a un delegate o passato come parametro;
  • restituito da un metodo;
  • trasformato in un expression tree;
  • usato per costruire query LINQ.

L’idea viene dal lambda calculus di Alonzo Church (1936), dove una funzione si scrive λx.e\lambda x . e (xx parametro, ee corpo) ed è la base teorica della programmazione funzionale: le funzioni sono valori di prima classe, che si possono passare, restituire e comporre.

Nota

Le lambda sono arrivate con C# 3.0 (2007), insieme a LINQ, extension methods, anonymous types ed expression trees. Da allora il C# è un linguaggio ibrido: orientato agli oggetti, ma con forti strumenti funzionali.

2. Sintassi

La forma base è:

(parametri) => espressione

L’operatore => si legge “va in” oppure “produce”.

2.1 Expression lambda e statement lambda

Una expression lambda ha come corpo una singola espressione, il cui valore viene restituito implicitamente. Una statement lambda usa un blocco { } e, se il delegate ha un tipo di ritorno, richiede return esplicito.

// expression lambda
Func<int, int> raddoppia = x => x * 2;

// statement lambda
Func<int, int, int> max = (a, b) =>
{
    if (a > b)
        return a;
    return b;
};

Console.WriteLine(raddoppia(4)); // 8
Console.WriteLine(max(3, 7));    // 7

2.2 Parametri, parentesi e tipi

Action saluto = () => Console.WriteLine("ciao");           // nessun parametro: () obbligatorie
Func<int, int> quadrato = x => x * x;                       // un parametro: parentesi opzionali
Func<int, int, int> prodotto = (x, y) => x * y;             // più parametri: parentesi obbligatorie
Func<int, int, int> somma = (int x, int y) => x + y;        // tipi espliciti
Func<int, int> assoluto = (int x) => x < 0 ? -x : x;

saluto();                           // ciao
Console.WriteLine(prodotto(3, 4));  // 12
Console.WriteLine(assoluto(-9));    // 9

Una lambda non ha un tipo da sola: il tipo viene inferito dal contesto di destinazione. In Func<int, bool> pari = x => x % 2 == 0; è il compilatore a dedurre che x è un int e che il risultato deve essere bool.

Suggerimento

I tipi espliciti dei parametri sono utili quando l’inferenza non è immediata o ci sono overload ambigui. Nei casi semplici lasciali inferire: il codice resta più pulito.

3. Lambda e delegate

Una lambda viene quasi sempre convertita in un delegate (o in un expression tree, vedi sezione 5). È una shorthand sintattica del vecchio delegate anonimo:

Func<int, int> doppio1 = delegate (int x) { return x * 2; };
Func<int, int> doppio2 = x => x * 2; // equivalente, più compatto

3.1 Func, Action, Predicate e delegate personalizzati

  • Action<T...>: nessun valore di ritorno;
  • Func<T..., TResult>: restituisce un valore (l’ultimo tipo generico è il ritorno);
  • Predicate<T>: restituisce bool.
Action<string> log = messaggio => Console.WriteLine($"LOG: {messaggio}");
Func<decimal, decimal, decimal> sconto = (prezzo, perc) => prezzo * (1 - perc);
Predicate<int> positivo = n => n > 0;

log("Operazione completata");          // LOG: Operazione completata
Console.WriteLine(sconto(100m, 0.15m)); // 85,00
Console.WriteLine(positivo(42));        // True

Puoi assegnare una lambda anche a un delegate dichiarato da te, utile quando vuoi dare un nome semantico alla firma:

public delegate decimal CalcoloPrezzo(decimal imponibile, decimal iva);

CalcoloPrezzo totale = (imponibile, iva) => imponibile + iva;
Console.WriteLine(totale(100m, 22m)); // 122

3.2 Lambda come callback

Molti metodi ricevono un comportamento come parametro: il chiamante lo definisce inline con una lambda.

static void EseguiOperazione(int valore, Action<int> callback)
{
    Console.WriteLine("Elaborazione...");
    callback(valore);
}

EseguiOperazione(10, x => Console.WriteLine($"Risultato: {x * 3}"));
// Elaborazione...
// Risultato: 30

Un esempio più realistico: lo stesso metodo di ordinamento, con criteri diversi passati come lambda.

var parole = new List<string> { "banana", "uva", "mela", "albicocca" };

parole.Sort((a, b) => a.Length.CompareTo(b.Length));
Console.WriteLine(string.Join(", ", parole)); // uva, mela, banana, albicocca

parole.Sort((a, b) => string.Compare(a, b, StringComparison.Ordinal));
Console.WriteLine(string.Join(", ", parole)); // albicocca, banana, mela, uva

4. Closures

Una closure si verifica quando una lambda cattura variabili del suo outer scope:

int fattore = 3;
Func<int, int> moltiplica = x => x * fattore;

Console.WriteLine(moltiplica(10)); // 30

Viene catturata la variabile, non il suo valore al momento della creazione: se la variabile cambia, la lambda vede il nuovo valore.

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

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

fattore = 5;
Console.WriteLine(moltiplica(10)); // 50, non 30!

Nota

Internamente il compilatore genera una classe nascosta (display class) con un campo per ogni variabile catturata e un metodo con il corpo della lambda. Per questo una variabile catturata vive almeno quanto il delegate che la usa, anche dopo la fine del metodo in cui è stata dichiarata.

4.1 Captured variable trap nei loop

Il problema classico compare nei cicli for:

var azioni = new List<Action>();

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

foreach (var azione in azioni)
    azione();
// Output: 3 3 3 (non 0 1 2)

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

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

Attenzione

Da C# 5 il foreach crea una nuova variabile a ogni iterazione, quindi non ha più questo problema. Con for (e con variabili mutate a mano) la trappola esiste ancora: se vuoi “congelare” un valore, copialo in una variabile locale.

Le closure sono utilissime, ma possono anche tenere in vita oggetti grandi più del previsto e introdurre allocazioni aggiuntive (vedi sezione 8).

5. Delegate vs expression tree

La stessa lambda può diventare due cose molto diverse, a seconda del tipo di destinazione:

Func<Prodotto, bool> filtroInMemoria = p => p.Prezzo > 100;
Expression<Func<Prodotto, bool>> filtroTraducibile = p => p.Prezzo > 100;
  • Func<...>: codice eseguibile dal runtime .NET; tipico di IEnumerable<T> e LINQ to Objects.
  • Expression<Func<...>>: una struttura dati ad albero che descrive il codice; un provider la legge e la traduce (SQL, API remote…). Tipico di IQueryable<T> ed EF Core.

Un expression tree si può ispezionare e, se serve, compilare in un delegate:

using System.Linq.Expressions;

Expression<Func<int, bool>> expr = x => x % 2 == 0;

Console.WriteLine(expr.Body);     // ((x % 2) == 0)
Console.WriteLine(expr.NodeType); // Lambda

Func<int, bool> pari = expr.Compile();
Console.WriteLine(pari(4));       // True

Con EF Core, se db.Prodotti è un IQueryable<Prodotto>, la lambda non viene eseguita su ogni record ma tradotta in SQL:

var query = db.Prodotti.Where(p => p.Prezzo > 100);
// SELECT * FROM Prodotti WHERE Prezzo > 100

Attenzione

Solo le expression lambda possono diventare expression tree: una statement lambda (con blocco { }) non compila se assegnata a Expression<...>. Inoltre, se dentro la lambda chiami metodi C# che il provider non sa tradurre, EF Core lancia un’eccezione o sposta il lavoro lato client, con impatto sulle prestazioni.

6. Lambda con LINQ

LINQ è il terreno naturale delle lambda. Useremo questa collezione:

public class Studente
{
    public string Nome { get; set; } = "";
    public int Eta { get; set; }
    public string Corso { get; set; } = "";
    public List<int> Voti { get; set; } = new();
}

var studenti = new List<Studente>
{
    new() { Nome = "Anna",  Eta = 21, Corso = "Informatica", Voti = new() { 28, 30, 27 } },
    new() { Nome = "Luca",  Eta = 19, Corso = "Matematica",  Voti = new() { 18, 24, 21 } },
    new() { Nome = "Marta", Eta = 23, Corso = "Informatica", Voti = new() { 30, 29, 30 } },
    new() { Nome = "Paolo", Eta = 22, Corso = "Fisica",      Voti = new() { 25, 26, 24 } }
};

6.1 Where, Select, OrderBy

Le lambda descrivono l’intera pipeline: filtro, proiezione, ordinamento.

var risultati = studenti
    .Where(s => s.Corso == "Informatica")
    .Select(s => new { s.Nome, Media = s.Voti.Average() })
    .OrderByDescending(s => s.Media)
    .ToList();

foreach (var r in risultati)
    Console.WriteLine($"{r.Nome}: {r.Media:F1}");
// Marta: 29,7
// Anna: 28,3

6.2 GroupBy e Aggregate

La lambda di GroupBy sceglie la chiave di raggruppamento:

var perCorso = studenti
    .GroupBy(s => s.Corso)
    .Select(g => new
    {
        Corso = g.Key,
        Numero = g.Count(),
        Media = g.SelectMany(s => s.Voti).Average()
    });

foreach (var g in perCorso)
    Console.WriteLine($"{g.Corso}: {g.Numero} studenti, media {g.Media:F1}");
// Informatica: 2 studenti, media 29,0
// Matematica: 1 studenti, media 21,0
// Fisica: 1 studenti, media 25,0

Aggregate riceve l’accumulatore corrente e l’elemento corrente, e produce il nuovo accumulatore:

int sommaVotiAnna = studenti
    .First(s => s.Nome == "Anna")
    .Voti
    .Aggregate(0, (acc, voto) => acc + voto);

Console.WriteLine(sommaVotiAnna); // 85

6.3 Query syntax vs method syntax

La query syntax viene tradotta dal compilatore in chiamate di metodo con lambda:

var q1 =
    from s in studenti
    where s.Eta >= 21
    orderby s.Nome
    select s.Nome;

// equivalente
var q2 = studenti
    .Where(s => s.Eta >= 21)
    .OrderBy(s => s.Nome)
    .Select(s => s.Nome);

Suggerimento

La query syntax è spesso più leggibile con join multipli; la method syntax è più uniforme e copre tutti gli operatori (Count, Aggregate, Take…). Nella pratica si usa soprattutto la method syntax.

6.4 Esempio con lambda annidate

public record RigaOrdine(string Prodotto, int Quantita, decimal PrezzoUnitario);
public record Ordine(string Cliente, string Citta, List<RigaOrdine> Righe);

var ordini = new List<Ordine>
{
    new("Alfa Srl",  "Milano", new() { new("Monitor", 2, 180m), new("Mouse", 5, 20m) }),
    new("Beta Spa",  "Roma",   new() { new("Notebook", 1, 1200m) }),
    new("Gamma Srl", "Milano", new() { new("Monitor", 1, 220m), new("Tastiera", 3, 45m) })
};

var report = ordini
    .Where(o => o.Citta == "Milano")
    .Select(o => new
    {
        o.Cliente,
        Totale = o.Righe.Sum(r => r.Quantita * r.PrezzoUnitario)
    })
    .OrderByDescending(x => x.Totale);

foreach (var r in report)
    Console.WriteLine($"{r.Cliente}: {r.Totale}");
// Alfa Srl: 460
// Gamma Srl: 355

Qui una lambda (r => r.Quantita * r.PrezzoUnitario) vive dentro un’altra lambda (o => new { ... }).

7. Lambda ricorsive e local functions

Una lambda non ha un nome, quindi non può richiamare sé stessa direttamente: serve una variabile che contenga il delegate.

Func<int, int>? fattoriale = null;
fattoriale = n => n <= 1 ? 1 : n * fattoriale!(n - 1);

Console.WriteLine(fattoriale(5)); // 120

Funziona, ma la local function è molto più naturale:

int Calcola(int valore)
{
    return Fattoriale(valore);

    static int Fattoriale(int n) => n <= 1 ? 1 : n * Fattoriale(n - 1);
}

Quando scegliere l’una o l’altra:

  • Local function: ricorsione, parametri ref/out/in, logica locale strutturata con un nome leggibile.
  • Lambda: callback inline, query LINQ, codice breve vicino al punto d’uso.
var nomi = new[] { "anna", "luca", "marta" };
var maiuscoli = nomi.Select(n => n.ToUpperInvariant()).ToList(); // ANNA, LUCA, MARTA

Suggerimento

Se una lambda supera 4-5 righe o contiene logica di business, estraila in una local function o in un metodo con un nome: la query LINQ torna leggibile e la logica diventa testabile.

8. Performance e static lambda

Una lambda senza capture (x => x * 2) è praticamente gratuita: il delegate viene creato una volta e riusato. Una lambda con capture richiede in genere una display class e un nuovo delegate ogni volta che la closure viene creata (non a ogni invocazione): più allocazioni e più pressione sul GC.

Con le static lambda (C# 9) dichiari che la lambda non deve catturare nulla, e il compilatore lo verifica:

var numeri = Enumerable.Range(1, 5);
var quadrati = numeri.Select(static n => n * n).ToList(); // OK

int offset = 10;
// var valori = numeri.Select(static n => n + offset).ToList();
// errore CS8820: una lambda static non può fare riferimento a 'offset'

Nota

Nel codice applicativo il costo delle closure è quasi sempre trascurabile. Diventa rilevante in librerie, hot path ed elaborazioni massive: lì static è un modo semplice per evitare catture accidentali.

9. Pattern funzionali

9.1 Currying e partial application

Il currying trasforma una funzione con più argomenti in una catena di funzioni a un argomento. La partial application fissa alcuni argomenti per ottenere una funzione più specializzata.

// currying
Func<int, Func<int, int>> sommaCurried = x => y => x + y;
var aggiungi10 = sommaCurried(10);
Console.WriteLine(aggiungi10(5)); // 15

// partial application
Func<decimal, decimal, decimal> applicaSconto = (prezzo, perc) => prezzo * (1 - perc);
Func<decimal, decimal> scontoBlackFriday = prezzo => applicaSconto(prezzo, 0.30m);
Console.WriteLine(scontoBlackFriday(200m)); // 140,00

9.2 Composizione e pipeline

La composizione (g∘f)(x)=g(f(x))(g \circ f)(x) = g(f(x)) si implementa restituendo una lambda:

static Func<TIn, TOut> Compose<TIn, TMid, TOut>(Func<TMid, TOut> g, Func<TIn, TMid> f)
    => x => g(f(x));

Func<int, int> doppio = x => x * 2;
Func<int, string> formatta = x => $"Valore: {x}";

var pipeline = Compose(formatta, doppio);
Console.WriteLine(pipeline(5)); // Valore: 10

Il C# non ha un pipeline operator, ma un extension method lo simula bene:

public static class FunctionalExtensions
{
    public static TResult Pipe<TSource, TResult>(this TSource value, Func<TSource, TResult> func)
        => func(value);
}

string output = 5
    .Pipe(x => x * 2)
    .Pipe(x => x + 1)
    .Pipe(x => $"Risultato finale: {x}");

Console.WriteLine(output); // Risultato finale: 11

10. Best practice

  • tieni le lambda brevi e focalizzate; se il corpo cresce, estrai in metodo o local function;
  • usa nomi espliciti per le variabili che contengono delegate;
  • nei loop for, crea una copia locale del valore da catturare;
  • usa static per impedire catture accidentali nei punti critici;
  • con IQueryable, scrivi lambda traducibili dal provider;
  • preferisci Func, Action, Predicate; usa delegate personalizzati quando la firma ha un significato di dominio.

Suggerimento

In ogni punto del codice chiediti che ruolo ha la lambda: codice eseguibile (delegate), struttura dati (expression tree) o closure che porta con sé parte dell’ambiente esterno. Capirlo evita la maggior parte degli errori.

11. Quiz

Mettiti alla prova

0/9 risposte

  1. Quale sintassi rappresenta correttamente una lambda expression in C#?

  2. Quale forma è una statement lambda?

  3. Una lambda in C# viene tipicamente convertita in:

  4. Cosa significa che una lambda crea una closure?

  5. Qual è il problema classico quando si cattura la variabile di un ciclo for?

  6. Qual è la differenza chiave tra Func<T, bool> ed Expression<Func<T, bool>>?

  7. Perché EF Core usa spesso Expression<Func<T, TResult>>?

  8. Quando una local function è spesso preferibile a una lambda?

  9. Cosa garantisce una static lambda?

Leggi il codice: cosa stampa? dov’è il bug?

Leggi il codice

0/4 risposte

  1. Cosa stampa questo codice?

    Cosa stampa?
    Func<int, int> doppio = x => x * 2;
    Func<int, int> piuUno = x => x + 1;
    
    Console.WriteLine(doppio(piuUno(3)));
    Console.WriteLine(piuUno(doppio(3)));
  2. Closure in un ciclo for: cosa stampa?

    Cosa stampa?
    var azioni = new List<Action>();
    for (int i = 0; i < 3; i++)
      azioni.Add(() => Console.Write(i + " "));
    
    foreach (var azione in azioni) azione();
  3. LINQ e esecuzione differita: cosa stampa?

    var lista = new List<int> { 1, 2, 3 };
    var query = lista.Where(x => x > 1);
    lista.Add(4);
    
    Console.WriteLine(query.Count());
  4. Vogliamo il quadrato dei numeri pari, ma il risultato è sbagliato. Clicca la riga con il bug.

    Trova il bug

    Clicca la riga che contiene l'errore.

    var numeri = new List<int> { 1, 2, 3, 4, 5, 6 };
    
    var quadratiPari = numeri
      .Where(n => n % 2 == 1)
      .Select(n => n * n);
    
    Console.WriteLine(string.Join(", ", quadratiPari)); // atteso: 4, 16, 36

12. Esercizi

12.1 Esercizio

Scenario: Stai sviluppando un pannello amministrativo per un e-commerce e vuoi permettere agli operatori di applicare filtri dinamici ai prodotti.

Consegna:

  1. Crea una classe Prodotto con proprietà Nome, Categoria, Prezzo, Disponibile.
  2. Crea una lista di almeno 8 prodotti.
  3. Implementa un metodo FiltraProdotti(List<Prodotto> prodotti, Func<Prodotto, bool> filtro).
  4. Usa il metodo con tre lambda diverse:
    • prodotti disponibili;
    • prodotti con prezzo maggiore di 100;
    • prodotti della categoria “Monitor” con prezzo inferiore a 300.
  5. Stampa il risultato di ogni filtro.

Obiettivo didattico: usare le lambda come callback riutilizzabili e capire il ruolo di Func<T, bool> nei filtri dinamici.

12.2 Esercizio

Scenario: Vuoi costruire un piccolo modulo analytics per analizzare gli ordini di un gestionale.

Consegna:

  1. Crea le classi (o record) Ordine e RigaOrdine.
  2. Ogni ordine deve avere Cliente, Citta e una lista di righe con Prodotto, Quantita, PrezzoUnitario.
  3. Usa LINQ con lambda per ottenere:
    • il totale di ogni ordine;
    • gli ordini della città “Milano”;
    • il fatturato totale per cliente;
    • il prodotto più venduto in quantità.
  4. Riscrivi almeno una delle query usando query syntax e confrontala con la method syntax.

Obiettivo didattico: consolidare Where, Select, GroupBy, SelectMany e Aggregate in uno scenario realistico.

12.3 Esercizio

Scenario: Stai implementando un motore di regole per assegnare sconti a clienti premium.

Consegna:

  1. Usa Func<decimal, decimal> per rappresentare una trasformazione di prezzo.
  2. Implementa tre lambda:
    • sconto del 10%;
    • sconto del 20%;
    • sconto del 10% seguito da IVA al 22%.
  3. Crea una utility Compose per comporre due funzioni.
  4. Mostra come ottenere una pipeline di trasformazioni da applicare a un prezzo iniziale.
  5. Indica nel codice dove stai facendo currying, partial application o composizione.

Obiettivo didattico: applicare pattern funzionali reali con lambda annidate e composizione di funzioni.

12.4 Esercizio

Scenario: Hai una coda di job da eseguire in background e vuoi registrare callback diverse per successo ed errore.

Consegna:

  1. Crea una classe JobRunner con un metodo Esegui(string nomeJob, Action<string> onSuccess, Action<string> onError).
  2. Simula l’esecuzione di più job, facendo fallire almeno uno.
  3. Usa lambda inline come callback per:
    • scrivere su console;
    • incrementare contatori esterni;
    • salvare i job falliti in una lista.
  4. Riproduci volontariamente un caso di closure in un ciclo for e osserva il problema.
  5. Correggi il bug introducendo una variabile temporanea locale nel loop.

Obiettivo didattico: comprendere callback, closure, lifetime delle variabili catturate e bug classici nei loop.