
Polimorfismo in C#: la guida per scrivere codice flessibile
Scritto da Marco Morello il 25 agosto 2025
💡 TL;DR: Il polimorfismo permette di trattare oggetti diversi in modo uniforme, rendendo il codice adattabile e facile da estendere. Questa guida ti mostra come usare
virtual
,override
e interfacce per passare da codice rigido a software che evolve con te.
Nell'ultimo articolo abbiamo messo le basi dell'ereditarietà, quel fantastico meccanismo che ci permette di dire
"un'Auto
è un Veicolo
". Se te lo sei perso, ti consiglio
di dargli un'occhiata qui, perché oggi costruiremo su quelle fondamenta.
Avere una gerarchia è un ottimo punto di partenza, ma la vera svolta arriva quando ci poniamo una domanda: come possiamo trattare oggetti diversi (una Ferrari e una Ducati) in modo uniforme, come se fossero tutti semplici "veicoli"? Questa domanda è il cuore di quasi ogni applicazione complessa.
La risposta è una parola che all'inizio suona complicata, ma che in realtà nasconde un concetto di una potenza disarmante: polimorfismo. Fidati, una volta che ti entra in testa, il tuo modo di scrivere codice non solo cambierà, ma farà un salto di qualità verso la professionalità.
Per rendere tutto più concreto, ho preparato un piccolo progetto su GitHub dove potrai trovare tutti gli esempi di codice di questo articolo e degli altri dedicati ai pilastri della OOP. Ti invito a clonarlo e a sperimentare mentre leggi: PrincipiOOP_CSharp su GitHub.
🚨 Il problema: un garage pieno di motori diversi
Torniamo al nostro garage. Grazie all'ereditarietà, possiamo creare una lista di Veicolo
e metterci
dentro di tutto: una Auto
, una Moto
, e così via. Questo è già un grande vantaggio.
var garage = new List<Veicolo>
{
new Auto("Ferrari", "SF90"),
new Moto("Ducati", "Panigale V4")
};
foreach (var veicolo in garage)
{
veicolo.MostraInfo();
}
Se eseguiamo questo codice, l'output è corretto, ma un po' deludente. È come guardare due mezzi fantastici
e dire solo "sono due veicoli". Il nostro codice vede solo la loro natura comune, la loro "etichetta" di Veicolo
.
Info Veicolo: Ferrari SF90
Info Veicolo: Ducati Panigale V4
Il programma esegue sempre il metodo MostraInfo()
della classe base Veicolo
, ignorando
che il primo oggetto è un'auto con le sue porte e il secondo una moto con la sua cilindrata. Il nostro codice è rigido;
per ogni veicolo
nel ciclo, l'unica versione di MostraInfo()
che conosce è quella definita
nella classe base. Come facciamo a dirgli di essere un po' più specifico e di riconoscere le peculiarità di ciascuno?
💪 La soluzione: dare voce alle specializzazioni con `virtual` e `override`
Il polimorfismo in C# si realizza con una coppia di parole chiave che lavorano in perfetta sintonia. Pensa a loro come a un dialogo tra la classe base e le sue figlie, un dialogo che introduce flessibilità.
virtual
: la classe base usa questa parola per dire: "ecco il mio metodo standard, ma se qualcuna di voi, classi figlie, pensa di poter fare di meglio, si faccia avanti! Vi do il permesso di ridefinire questo comportamento".override
: la classe figlia risponde: "sfida accettata! Raccogliamo il permesso e forniamo la nostra versione personalizzata di quel metodo, perché noi sappiamo come farlo meglio per il nostro caso specifico".
Vediamo come funziona. Per prima cosa, andiamo nella classe Veicolo
e rendiamo il nostro metodo "aperto"
alla personalizzazione.
// File: Veicolo.cs
public class Veicolo
{
// ... proprietà e costruttore ...
// Aggiungiamo 'virtual' per dire che questo metodo può essere "sovrascritto"
public virtual void MostraInfo()
{
Console.WriteLine($"Info Veicolo: {Marca} {Modello}");
}
}
Ora, andiamo nelle classi Auto
e Moto
e forniamo la nostra implementazione specifica,
sfruttando il permesso che ci è stato accordato.
// File: Auto.cs
public class Auto : Veicolo
{
// ...
public override void MostraInfo()
{
Console.WriteLine($"Info AUTO: {Marca} {Modello}, {NumeroPorte} porte.");
}
}
// File: Moto.cs
public class Moto : Veicolo
{
// ...
public override void MostraInfo()
{
Console.WriteLine($"Info MOTO: {Marca} {Modello}, cilindrata {Cilindrata}cc.");
}
}
Adesso rieseguiamo lo stesso, identico ciclo foreach
di prima. Guarda cosa succede:
Info AUTO: Ferrari SF90, 4 porte.
Info MOTO: Ducati Panigale V4, cilindrata 1000cc.
Magia! Ma cosa è successo esattamente? Il programma, durante l'esecuzione (a runtime), per ogni elemento della lista
non si ferma più a guardare l'etichetta Veicolo
. Fa un passo in più: controlla il tipo reale dell'oggetto
in memoria (Auto
o Moto
) e, se trova una versione specializzata (override
) del metodo,
esegue quella. Altrimenti, ripiega sulla versione di base (virtual
). Questo è il polimorfismo in azione:
scrivere codice generico che si adatta a comportamenti specifici.
💡 Pro Tip #1: Bloccare la catena con `sealed`
E se volessimo che la personalizzazione della
Moto
fosse quella definitiva? A volte, specialmente in gerarchie complesse, potremmo voler impedire ulteriori modifiche. Possiamo usare la parola chiavesealed
sull'override per "sigillare" il metodo. È un modo per dire: "la gerarchia di personalizzazione per questo metodo finisce qui. Chiunque erediterà daMoto
dovrà usare questa versione diMostraInfo
".public sealed override void MostraInfo() { /* ... */ }
È un piccolo strumento di controllo che dimostra una profonda comprensione del design delle classi.
💡 Pro Tip #2: Composizione sull'Ereditarietà, un mantra da architetto
L'ereditarietà è potente, ma anche pericolosa. Crea un legame molto forte tra le classi: se modifichi la classe base, rischi di rompere tutte le sue figlie. Un architetto software, prima di usare l'ereditarietà, si fa sempre una domanda: "è una relazione 'is-a' (è un) o 'has-a' (ha un)?".
Un'
Auto
è unVeicolo
, quindi l'ereditarietà ha senso. Ma un'Auto
ha unMotore
. Non scriveremmo maiclass Auto : Motore
. Invece, creeremmo una classeMotore
separata e la inseriremmo dentroAuto
. Questa si chiama composizione.Privilegiare la composizione sull'ereditarietà porta a un codice più flessibile, fatto di tanti piccoli pezzi indipendenti e riutilizzabili che possono essere "assemblati" a piacimento. Ricordatelo: è uno dei segreti per un design di qualità.
🚀 Il livello successivo: le interfacce in uno scenario reale e robusto
Virtual
e override
sono perfetti, ma a volte abbiamo bisogno di ancora più flessibilità.
Ed è qui che le interfacce diventano le nostre migliori amiche.
Parliamo di qualcosa di molto concreto: un sistema di notifiche. Nel mondo reale, non basta inviare una notifica;
dobbiamo sapere se l'invio è andato a buon fine e perché è fallito. Un semplice bool
di ritorno è troppo limitato,
non ci dice nulla sulla natura del problema.
1. Definiamo un contratto più intelligente
Miglioriamo il nostro contratto. Invece di un semplice bool
, il metodo Invia
restituirà
un oggetto RisultatoInvio
che ci darà tutte le informazioni necessarie per reagire in modo appropriato.
public class RisultatoInvio
{
public bool Successo { get; set; }
public string MessaggioErrore { get; set; }
}
public interface INotifica
{
RisultatoInvio Invia();
}
Questo piccolo cambiamento è fondamentale: trasforma il nostro sistema da uno che dice solo "sì/no" a uno che "dialoga" e fornisce contesto.
2. Creiamo classi concrete e robuste
Ora le nostre classi implementeranno il nuovo contratto, gestendo anche i possibili fallimenti e restituendo un risultato parlante.
public class NotificaEmail : INotifica
{
private readonly string _destinatario;
public NotificaEmail(string destinatario) { _destinatario = destinatario; }
public RisultatoInvio Invia()
{
Console.WriteLine($"Invio Email a {_destinatario}...");
// Simuliamo un errore di validazione
if (string.IsNullOrEmpty(_destinatario) || !_destinatario.Contains("@"))
{
return new RisultatoInvio { Successo = false, MessaggioErrore = "Destinatario email non valido." };
}
// Qui ci sarebbe la logica reale che potrebbe fallire per altri motivi (server non raggiungibile, etc.)
return new RisultatoInvio { Successo = true };
}
}
public class NotificaSms : INotifica
{
private readonly string _numero;
public NotificaSms(string numero) { _numero = numero; }
public RisultatoInvio Invia()
{
Console.WriteLine($"Invio SMS al numero {_numero}...");
// Qui la logica reale potrebbe fallire per credito insufficiente, numero inesistente, etc.
return new RisultatoInvio { Successo = true };
}
}
3. Costruiamo un servizio che reagisce e gestisce casi specifici
Il nostro ServizioNotifiche
ora può controllare l'esito di ogni invio e agire di conseguenza.
Ma non solo: possiamo usare il pattern matching per eseguire logiche speciali solo per alcuni
tipi di notifica, senza rinunciare alla flessibilità del ciclo generico.
public class ServizioNotifiche
{
public void InviaTutte(List<INotifica> notificheDaInviare)
{
foreach (var notifica in notificheDaInviare)
{
var risultato = notifica.Invia();
if (!risultato.Successo)
{
// Ora possiamo loggare un errore significativo!
Console.WriteLine($"ERRORE: Invio fallito. Causa: {risultato.MessaggioErrore}");
// Potremmo inserire la notifica in una coda per un nuovo tentativo
continue; // Passa alla notifica successiva
}
// Pattern Matching: se la notifica è un SMS, fai qualcosa di specifico.
// Questo è un modo pulito per gestire le eccezioni alla regola generale.
if (notifica is NotificaSms)
{
Console.WriteLine("LOG: Registrato costo per invio SMS.");
}
}
}
}
Questo approccio è potente: abbiamo un ciclo principale che tratta tutte le notifiche allo stesso modo,
fidandosi del contratto INotifica
. Allo stesso tempo, abbiamo la possibilità di "sbirciare"
il tipo reale quando serve, per gestire eccezioni o logiche particolari in modo pulito ed elegante.
💡 Pro Tip #3: un assaggio di Dependency Injection
Il modo in cui abbiamo "passato" la lista di notifiche al servizio dall'esterno è la base di un pattern fondamentale: la Dependency Injection (DI).
Invece di lasciare che una classe si crei da sola gli oggetti di cui ha bisogno (le sue "dipendenze"), glieli forniamo dall'esterno. È come dire al nostro
ServizioNotifiche
: "non preoccuparti di come vengono create le notifiche, il tuo unico compito è inviarle. A crearle ci pensa qualcun altro". Questo rende il codice incredibilmente più flessibile, modulare e facile da testare.È un argomento vasto e cruciale. Se vuoi iniziare a esplorarlo, la documentazione ufficiale di Microsoft è il punto di partenza migliore: Dependency Injection in ASP.NET Core.
🎯 Conclusione: il tuo codice ora ha una marcia in più
Capire e padroneggiare il polimorfismo è ciò che ti permette di passare dallo scrivere codice che funziona allo scrivere codice che dura nel tempo. Ti consente di gestire la complessità e di aggiungere nuove funzionalità senza dover riscrivere tutto da capo, riducendo il rischio di introdurre bug.
Abbiamo visto come virtual
e override
ci aiutano a specializzare il comportamento e
come le interfacce
, unite a un design robusto, ci permettono di disaccoppiare i componenti del nostro sistema,
rendendoli pronti al cambiamento.
Questo è il potere di scrivere codice flessibile. È un cambio di mentalità che ti avvicina, passo dopo passo, a pensare come un vero architetto del software.
Non dimenticare di dare un'occhiata al codice sorgente completo su GitHub, dove puoi vedere tutti questi concetti in azione e sperimentare in prima persona: Progetto PrincipiOOP_CSharp.
Buono studio e buon codice!