1. Introduzione
Passando dalla teoria alla pratica, è naturale chiedersi: «Perché a me, sviluppatore .NET e C#, servono tutte queste tecniche di programmazione funzionale?»
È vero, C# non è un linguaggio puramente funzionale come F# o Haskell. Ma a partire dalla versione 3.0 e fino a C# 14, ha acquisito molti strumenti FP che possono migliorare notevolmente la qualità e l'espressività del codice.
Ecco dove funzionano particolarmente bene:
- Lavoro con le collezioni — LINQ, Map/Reduce, filtraggio, aggregazione, ordinamento e altra "magia" sui dati.
- Pure functions — meno bug dovuti allo stato e agli effetti collaterali, più semplice il debug.
- Higher-order functions — componenti generiche e riutilizzabili con cui è piacevole lavorare.
- Immutability in multithreading — uno dei principali presupposti per codice sicuro in scenari paralleli e asincroni.
- Function composition — rende la logica di business complessa concisa, leggibile e facile da testare.
Tabella: Confronto approccio OOP vs approccio FP in C#
| Compito | Imperativamente (OOP/vecchia scuola) | Funzionalmente (FP) |
|---|---|---|
| Filtrare una lista | |
|
| Trasformare una lista | |
|
| Cercare con criterio | |
|
| Aggregazione | |
|
| Cache | |
|
2. LINQ: la cosa più funzionale in C#
Se ti è sembrato che “programmazione funzionale” significhi lavorare con liste, filtri e vari .Where, .Select, .Aggregate, — congratulazioni: è proprio così! LINQ è la quintessenza della FP in C#.
Ripassiamo come funziona LINQ
LINQ opera sulle collezioni tramite catene di metodi che accettano funzioni come parametri (per esempio lambda). Per esempio:
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Prendiamo solo i numeri pari e raddoppiamoli
var result = numbers
.Where(x => x % 2 == 0)
.Select(x => x * 2);
foreach (var number in result)
Console.WriteLine(number);
Cosa succede qui?
- .Where — higher-order function: prende una funzione (x => x % 2 == 0) e restituisce un'altra collezione.
- .Select — prende anch'essa una funzione (x => x * 2).
- Non modifichiamo la collezione originale, otteniamo un nuovo risultato.
Questo stile è facile da leggere ed estendere (puoi appendere altri .OrderBy, .Take, .Distinct, ecc.).
Nota! Le espressioni lambda sono un modo comodo per creare un "delegate al volo". LINQ sarebbe impossibile senza il supporto alla FP in C#.
Schema: Elaborazione funzionale di una collezione
Collezione --> Where(x => bool) --> Select(x => y) --> Nuovo risultato
3. Composizione di funzioni e pipeline di elaborazione dati
La FP usa spesso la composizione: un'operazione complessa è costruita come catena di piccole funzioni, ognuna fa il suo lavoro.
Esempio: pipeline di trasformazione di una stringa
Stile mutabile (OOP):
string s = " hello world ";
s = s.Trim();
s = s.ToUpper();
s = s + "!";
Console.WriteLine(s); // HELLO WORLD!
Più "funzionale" — come pipeline di funzioni:
Func<string, string> trim = x => x.Trim();
Func<string, string> upper = x => x.ToUpper();
Func<string, string> addBang = x => x + "!";
// Composizione di funzioni — le applichiamo in sequenza
Func<string, string> pipeline = x => addBang(upper(trim(x)));
Console.WriteLine(pipeline(" hello world ")); // HELLO WORLD!
Semplice combinatore di composizione:
Func<T, R> Compose<T, U, R>(Func<T, U> f, Func<U, R> g) =>
x => g(f(x));
// E ora pipeline tramite Compose:
var pipeline2 = Compose(trim, upper);
pipeline2 = Compose(pipeline2, addBang);
Console.WriteLine(pipeline2(" hello again ")); // HELLO AGAIN!
4. Lavorare con l'immutabilità: protezione dai bug
L'immutability è il mattone fondamentale della FP. Non modifichiamo le strutture dati, ma ne ritorniamo di nuove. Questo è particolarmente importante nelle applicazioni multithread.
Esempio: "sbagliato" (mutabile)
List<int> numbers = new List<int> { 1, 2, 3 };
numbers[0] = 42;
Esempio: "corretto" (funzionale)
var numbers = new List<int> { 1, 2, 3 };
var newNumbers = numbers.Select((x, i) => i == 0 ? 42 : x).ToList();
Nel moderno C# ci sono collezioni ImmutableList<T> e altri tipi nello spazio dei nomi System.Collections.Immutable:
using System.Collections.Immutable;
var immutableNumbers = ImmutableList.Create(1, 2, 3);
var changed = immutableNumbers.SetItem(0, 42); // Ritorna una nuova lista!
5. Higher-order functions nella vita reale
Le higher-order functions sono un modo per scrivere componenti generiche senza tanti rami condizionali.
Esempio: filtro generico per utenti
class User
{
public string Name { get; set; }
public int Age { get; set; }
}
var users = new List<User>
{
new User { Name = "Vasya", Age = 26 },
new User { Name = "Katya", Age = 17 },
new User { Name = "Lyosha", Age = 35 }
};
List<User> FilterUsers(List<User> source, Predicate<User> predicate)
{
return source.Where(u => predicate(u)).ToList();
}
// Uso:
var adults = FilterUsers(users, u => u.Age >= 18);
var longNames = FilterUsers(users, u => u.Name.Length > 3);
6. Pattern matching e switch-expressions
Il moderno C# usa molto il pattern matching: le switch-expressions spesso rimpiazzano lunghe catene di if.
object value = 123;
string description = value switch
{
int i when i > 100 => "Numero grande",
string s when s.Length > 3 => "Stringa lunga",
null => "Valore vuoto",
_ => "Sconosciuto"
};
Console.WriteLine(description); // Numero grande
7. Memoization: caching dei risultati delle funzioni
La memoization è il caching del risultato di una funzione per argomenti uguali. In C# è facile implementarla da soli.
Func<int, int> SlowFib = null; // Funzione ricorsiva Fibonacci
var cache = new Dictionary<int, int>();
SlowFib = n =>
{
if (cache.ContainsKey(n))
return cache[n];
if (n <= 1)
cache[n] = n;
else
cache[n] = SlowFib(n - 1) + SlowFib(n - 2);
return cache[n];
};
Console.WriteLine(SlowFib(40)); // Fulmineo!
8. Currying e partial application
La partial application è il fissare parte degli argomenti di una funzione. In C# è comodo farla con le lambda.
Func<int, int, int> add = (a, b) => a + b;
// Fissiamo il primo argomento
Func<int, int> add10 = b => add(10, b);
Console.WriteLine(add10(5)); // 15
Console.WriteLine(add10(100)); // 110
9. Stile dichiarativo con le funzioni
Imperativamente:
var result = new List<int>();
foreach (var n in numbers)
{
if (n > 0)
result.Add(n * n);
}
Dichiarativamente:
var result = numbers
.Where(n => n > 0)
.Select(n => n * n)
.ToList();
10. Esercizio pratico
Implementiamo un modulo di filtraggio delle task in un "task manager per studenti".
Modello:
class StudentTask
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
public int Priority { get; set; }
}
Dati iniziali:
var tasks = new List<StudentTask>
{
new StudentTask { Title = "Fare i compiti", IsCompleted = false, Priority = 2 },
new StudentTask { Title = "Bere un caffè", IsCompleted = true, Priority = 3 },
new StudentTask { Title = "Guardare la lezione", IsCompleted = false, Priority = 1 }
};
Filtro generico:
List<StudentTask> FilterTasks(
List<StudentTask> all,
Predicate<StudentTask> predicate)
{
return all.Where(t => predicate(t)).ToList();
}
// Cerca task non completate con priority > 1
var importantTasks = FilterTasks(tasks, t => !t.IsCompleted && t.Priority > 1);
// Stampa risultato
foreach (var task in importantTasks)
Console.WriteLine(task.Title);
Combinatori di predicati:
Predicate<StudentTask> IsActive = t => !t.IsCompleted;
Predicate<StudentTask> IsHighPriority = t => t.Priority > 1;
// Uniamo più criteri, variante 1
var specialTasks = FilterTasks(tasks, t => IsActive(t) && IsHighPriority(t));
// Variante 2: funzione-combinatore di due predicati
Predicate<StudentTask> And(Predicate<StudentTask> a, Predicate<StudentTask> b) => t => a(t) && b(t);
var specialTasks2 = FilterTasks(tasks, And(IsActive, IsHighPriority));
11. Peculiarità e errori tipici nell'adottare lo stile funzionale in C#
Prima di tutto, ricorda che C# è un linguaggio fortemente tipizzato. A volte è necessario specificare i tipi esplicitamente, specialmente quando le funzioni ritornano delegate o lambda complesse. Altrimenti puoi ricevere errori di compilazione per tipi non dedotti.
Secondo, non passare funzioni con effetti collaterali dove si aspettano pure functions. Modificare variabili esterne rompe la prevedibilità. Cerca di avere funzioni che non mutano lo stato esterno al loro scope.
Terzo, fai attenzione al capture delle variabili esterne (closure capture), soprattutto in codice asincrono e multithread. Una variabile il cui valore cambia dopo essere stata catturata in una lambda (per esempio in LINQ) può portare a bug non ovvi.
Infine, un uso eccessivo dello stile FP può complicare la manutenzione per un team non abituato. Usa la FP dove semplifica davvero la soluzione, non solo per "bellezza".
GO TO FULL VERSION