Sostieni AppuntiFacili con una piccola donazione su PayPal
Dona con PayPalIn C#, quando una classe deriva da un’altra, possono succedere tre cose diverse:
virtual / override): la classe derivata sostituisce davvero il comportamento della base nel modello polimorfico.new): la classe derivata nasconde un membro della base, ma non entra nella stessa catena di dispatch virtuale.La distinzione cruciale è tra:
Con override, vince il tipo dinamico. Con new, vince il tipo statico.
public class Animale
{
public virtual string FaiVerso() => "...";
}
public class Cane : Animale
{
public override string FaiVerso() => "Bau!";
}
public class Lupo : Animale
{
public new string FaiVerso() => "Auuuu";
}
Animale a1 = new Cane();
Animale a2 = new Lupo();
Lupo l = new Lupo();
Console.WriteLine(a1.FaiVerso()); // Bau!
Console.WriteLine(a2.FaiVerso()); // ...
Console.WriteLine(l.FaiVerso()); // Auuuu
Nel secondo caso l’oggetto è Lupo, ma la chiamata passa dalla variabile Animale, quindi il metodo nascosto con new non partecipa al polimorfismo della base.
graph TD
A[Variabile di tipo Base] --> B{Membro virtual/override?}
B -->|Sì| C[Conta il tipo reale dell'oggetto]
B -->|No, oppure hidden con new| D[Conta il tipo statico della variabile]
Un metodo virtual definisce un comportamento ereditabile e ridefinibile. Un metodo override non crea un metodo indipendente: riempie lo stesso slot virtuale della classe base con un’implementazione più specifica.
public class Documento
{
public virtual string Esporta()
{
return "Documento generico";
}
}
public class Pdf : Documento
{
public override string Esporta()
{
return "Esportazione PDF";
}
}
public class Word : Documento
{
public override string Esporta()
{
return "Esportazione Word";
}
}
Documento[] documenti =
{
new Pdf(),
new Word(),
new Documento()
};
foreach (var doc in documenti)
{
Console.WriteLine(doc.Esporta());
}
Il compilatore controlla che Esporta esista sul tipo statico Documento; il CLR sceglie poi l’implementazione concreta in base al tipo reale dell’istanza.
Quando fai override, mantieni la stessa firma logica del metodo base:
Questo è il motivo per cui override è adatto al polimorfismo vero: tutte le classi derivate vengono trattate come sostituti della base.
classDiagram
Documento <|-- Pdf
Documento <|-- Word
class Documento {
+virtual Esporta() string
}
class Pdf {
+override Esporta() string
}
class Word {
+override Esporta() string
}
Dietro virtual / override c’è un meccanismo di runtime spesso descritto come vtable (virtual method table). Nel CLR il termine tecnico più vicino è MethodTable del tipo, ma a livello didattico si può pensare a una vtable.
Ogni tipo concreto caricato dal CLR possiede una struttura dati che contiene, tra le altre cose, i riferimenti ai metodi virtuali risolvibili su quel tipo. In forma semplificata:
override riusa lo slot della base;new crea un membro distinto e non sostituisce lo slot esistente.Formalmente, se il metodo virtuale occupa lo slot , per un tipo possiamo immaginare:
Se Base.M() è virtuale e Derivata.M() è override, allora:
Se invece Derivata.M() usa new, in generale:
oppure, più precisamente, il metodo nascosto non sostituisce affatto lo slot della base.
public class Base
{
public virtual void A() { }
public virtual void B() { }
}
public class Derivata : Base
{
public override void A() { }
public new void B() { }
}
Possiamo immaginare la situazione così:
graph TD
subgraph Base MethodTable
B0[slot 0 -> Base.A]
B1[slot 1 -> Base.B]
end
subgraph Derivata MethodTable
D0[slot 0 -> Derivata.A]
D1[slot 1 -> Base.B]
D2[metodo separato -> Derivata.B hidden con new]
end
La chiamata virtuale a B passando da una reference Base continuerà a usare slot 1, che in Derivata punta ancora a Base.B, perché new non ha fatto override di quello slot.
A livello IL ci sono due istruzioni molto importanti:
call: chiamata diretta al metodo specificato;callvirt: chiamata virtuale, che usa l’istanza per trovare la destinazione a runtime.Esempio concettuale:
Base x = new Derivata();
x.A(); // virtual
x.B(); // non virtual oppure hidden
IL semplificato:
ldloc x
callvirt instance void Base::A()
ldloc x
call instance void Base::B()
Attenzione a una sottigliezza importante: il compilatore C# emette spesso callvirt anche per metodi di istanza non virtuali, perché così ottiene anche il controllo di null. Quindi:
callvirt significa vero dispatch virtuale;Dal punto di vista concettuale:
Quindi, nella pratica moderna del CLR, la ricerca non richiede di “scansionare” tutta la gerarchia a ogni chiamata: la gerarchia viene consolidata nella MethodTable del tipo, e la chiamata usa direttamente lo slot già risolto.
Quando scrivi obj.Metodo(), la risoluzione avviene in due fasi diverse: una a compile time, una a runtime.
Il compilatore parte dal tipo statico della variabile:
Base obj = new Derivata();
obj.Metodo();
Il compilatore vede solo che obj è di tipo Base, quindi controlla:
Metodo in Base?Se Metodo non esiste su Base, la chiamata non compila, anche se l’oggetto reale è Derivata.
Se il metodo selezionato è virtuale, il CLR usa il tipo dinamico dell’oggetto.
Concettualmente la regola è:
Di nuovo: concettualmente si parla di “salire o scendere nella gerarchia”, ma l’implementazione reale usa slot già materializzati nella MethodTable del tipo.
public class A
{
public virtual string Nome() => "A";
}
public class B : A
{
public override string Nome() => "B";
}
public class C : B
{
}
A x = new C();
Console.WriteLine(x.Nome()); // "B"
C non ridefinisce Nome, quindi nello slot virtuale eredita il puntatore a B.Nome.
Quando in un override scrivi base.Nome(), non stai dicendo “scegli a runtime il più derivato”. Stai dicendo:
public class Veicolo
{
public virtual string Descrizione() => "Veicolo";
}
public class Auto : Veicolo
{
public override string Descrizione() => base.Descrizione() + " -> Auto";
}
public class Sportiva : Auto
{
public override string Descrizione() => base.Descrizione() + " -> Sportiva";
}
base.Descrizione() in Sportiva chiama Auto.Descrizione, non “qualunque implementazione più derivata”.
new indica esplicitamente che stai nascondendo un membro ereditato. Non stai facendo override. Stai creando un nuovo punto di accesso con lo stesso nome.
public class Forma
{
public string Descrizione()
{
return "Sono una forma";
}
}
public class Cerchio : Forma
{
public new string Descrizione()
{
return "Sono un cerchio";
}
}
Cerchio c = new Cerchio();
Forma f = c;
Console.WriteLine(c.Descrizione()); // Sono un cerchio
Console.WriteLine(f.Descrizione()); // Sono una forma
Se in una classe derivata dichiari un membro con lo stesso nome e la stessa firma di uno della base ma senza scrivere new, il compilatore segnala un warning simile a:
Il membro nasconde un membro ereditato; usare la parola chiave new se l’occultamento era intenzionale.
Perché esiste questo warning?
override ma ha dimenticato virtual nella base o override nella derivata;new è meno intuitivo nel codice polimorfico;In altre parole, il warning serve a farti dichiarare apertamente l’intenzione.
È possibile anche nascondere un metodo virtuale:
public class Base
{
public virtual void Stampa() => Console.WriteLine("Base");
}
public class Derivata : Base
{
public new void Stampa() => Console.WriteLine("Derivata");
}
Base b = new Derivata();
Derivata d = new Derivata();
b.Stampa(); // Base
d.Stampa(); // Derivata
Questo è uno dei casi più pericolosi: il metodo della derivata sembra “sostituire” quello base, ma in realtà vive fuori dalla catena di override.
Con le interfacce la storia si complica. Una classe base può già implementare un’interfaccia, e una classe derivata può decidere di:
override se il membro base è virtuale;new insieme alla nuova dichiarazione dell’interfaccia.Esempio:
public interface IFormatter
{
string Format();
}
public class BaseFormatter : IFormatter
{
public string Format() => "Base";
}
public class JsonFormatter : BaseFormatter, IFormatter
{
public new string Format() => "Json";
}
JsonFormatter jf = new JsonFormatter();
BaseFormatter bf = jf;
IFormatter f = jf;
Console.WriteLine(jf.Format()); // Json
Console.WriteLine(bf.Format()); // Base
Console.WriteLine(f.Format()); // Json
La parte sorprendente è l’ultima riga: ripetendo IFormatter nella lista delle interfacce, la classe derivata re-mappa l’interfaccia al proprio metodo Format, anche se quel metodo usa new.
Se invece la derivata non re-dichiara l’interfaccia, in molti casi eredita la mappatura della base.
Usa new solo quando vuoi davvero uno di questi effetti:
Se vuoi polimorfismo normale, new è quasi sempre la scelta sbagliata.
Un metodo abstract occupa uno slot virtuale ma non ha corpo. Le classi concrete derivate devono completarlo.
public abstract class Forma
{
public abstract double Area();
public virtual string Tipo() => "Forma generica";
}
public class Rettangolo : Forma
{
public double Base { get; }
public double Altezza { get; }
public Rettangolo(double @base, double altezza)
{
Base = @base;
Altezza = altezza;
}
public override double Area() => Base * Altezza;
}
abstract è quindi “più forte” di virtual: non solo permette l’override, ma lo rende obbligatorio prima di poter istanziare il tipo concreto.
Un metodo può essere virtuale nella base, overridden in una derivata, e poi chiuso con sealed override.
public class Animale
{
public virtual string FaiVerso() => "...";
}
public class Cane : Animale
{
public sealed override string FaiVerso() => "Bau!";
}
Lo slot rimane lo stesso, ma non può più essere ridefinito nelle classi successive.
Da C# 9 in poi, un override può restituire un tipo più derivato del tipo di ritorno del metodo base, purché sia compatibile per sostituzione.
public class Documento
{
public virtual Stream ApriStream()
{
return Stream.Null;
}
}
public class DocumentoInMemoria : Documento
{
public override MemoryStream ApriStream()
{
return new MemoryStream();
}
}
Qui MemoryStream deriva da Stream, quindi la sostituzione è lecita. Il chiamante che vede un Documento continua a ricevere almeno uno Stream; il chiamante che conosce DocumentoInMemoria può beneficiare del tipo più specifico.
Un pattern classico che sfrutta abstract e virtual è il Template Method: la classe base definisce lo scheletro dell’algoritmo, mentre le derivate personalizzano alcuni passi.
public abstract class ReportGenerator
{
public string Genera()
{
string header = CreaHeader();
string body = CreaBody();
string footer = CreaFooter();
return header + "\n" + body + "\n" + footer;
}
protected virtual string CreaHeader() => "Header standard";
protected abstract string CreaBody();
protected virtual string CreaFooter() => "Footer standard";
}
public class SalesReportGenerator : ReportGenerator
{
protected override string CreaHeader() => "Header vendite";
protected override string CreaBody() => "Corpo report vendite";
}
Qui il metodo Genera non dovrebbe essere ridefinito liberamente: rappresenta il flusso dell’algoritmo. Le estensioni controllate avvengono tramite i metodi virtuali/astratti.
flowchart TD
A[Genera] --> B[CreaHeader virtual]
B --> C[CreaBody abstract]
C --> D[CreaFooter virtual]
In una gerarchia di ereditarietà, il corpo del costruttore della base viene eseguito prima del corpo del costruttore della derivata.
In forma semplificata:
public class Base
{
public Base()
{
Console.WriteLine("Costruttore Base");
}
}
public class Derivata : Base
{
public Derivata()
{
Console.WriteLine("Costruttore Derivata");
}
}
Output:
Costruttore Base
Costruttore Derivata
sequenceDiagram
participant CLR
participant B as Base
participant D as Derivata
CLR->>B: Esegue costruttore Base
B-->>CLR: Base completata
CLR->>D: Esegue costruttore Derivata
D-->>CLR: Derivata completata
Questo è un gotcha molto serio:
public class Base
{
public Base()
{
Inizializza();
}
protected virtual void Inizializza()
{
Console.WriteLine("Base");
}
}
public class Derivata : Base
{
private string nome = "Pronto";
protected override void Inizializza()
{
Console.WriteLine(nome.Length);
}
}
Sembra innocuo, ma durante l’esecuzione del costruttore di Base, la parte Derivata potrebbe non essere ancora completamente inizializzata. In casi reali, campi, dipendenze o invarianti della derivata possono essere ancora in stato non valido.
Regola pratica:
Sia le classi astratte sia le interfacce possono descrivere un contratto, ma servono a scopi diversi.
Da C# 8, un’interfaccia può avere implementazioni di default:
public interface ILogger
{
void Log(string message);
void LogWarning(string message)
{
Log("[WARN] " + message);
}
}
Questo riduce la necessità di classi base solo per condividere piccoli pezzi di comportamento.
Però attenzione:
In pratica:
Una chiamata non virtuale è, concettualmente, più semplice:
Una chiamata virtuale richiede invece un livello di indirezione:
In forma ideale:
Questo comporta in genere:
Nei programmi reali, la differenza è spesso trascurabile rispetto a:
Quindi non bisogna evitare virtual per paura prematura delle prestazioni.
Il JIT del .NET runtime può spesso devirtualizzare una chiamata, cioè trasformarla in pratica in una chiamata diretta quando riesce a dimostrare il target effettivo.
Esempi comuni:
sealed oppure la classe è sealed;Quindi il quadro reale è:
virtual non significa automaticamente “lento”;non-virtual non significa automaticamente “più veloce in modo misurabile”;Anche il dispatch tramite interfaccia può essere più costoso di una chiamata diretta, ma il runtime moderno usa cache e ottimizzazioni. Dal punto di vista del design, l’interfaccia va scelta per il modello di astrazione, non come micro-ottimizzazione.
Il Liskov Substitution Principle afferma, in forma informale, che un oggetto di una classe derivata deve poter essere usato al posto di un oggetto della classe base senza rompere la correttezza del programma.
Una formulazione compatta è:
dove:
S indica il sottotipo;T indica il supertipo;P indica un programma che usa oggetti di tipo T.Un override corretto non dovrebbe:
Esempio di cattivo override:
public class FileRepository
{
public virtual void Save(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("value");
}
}
public class ReadOnlyRepository : FileRepository
{
public override void Save(string value)
{
throw new NotSupportedException();
}
}
Se il contratto di FileRepository promette che Save salva dati validi, la derivata ReadOnlyRepository rompe il principio di sostituzione: il chiamante che si aspetta un FileRepository funzionante riceve un oggetto che non rispetta più il contratto operativo.
Per questo motivo virtual non è solo sintassi: è un impegno di design.
Il dispatch virtuale classico usa una sola dimensione dinamica: il tipo dell’oggetto su cui chiami il metodo. Ma a volte vuoi scegliere il comportamento in base a due tipi runtime.
Esempio tipico: una forma geometrica che deve essere elaborata da un visitor specializzato.
public interface IShapeVisitor
{
void Visit(Circle circle);
void Visit(Rectangle rectangle);
}
public abstract class Shape
{
public abstract void Accept(IShapeVisitor visitor);
}
public class Circle : Shape
{
public override void Accept(IShapeVisitor visitor)
{
visitor.Visit(this);
}
}
public class Rectangle : Shape
{
public override void Accept(IShapeVisitor visitor)
{
visitor.Visit(this);
}
}
Qui succede un doppio passaggio:
shape.Accept(visitor) usa dispatch virtuale sul tipo reale di shape;Accept, la chiamata visitor.Visit(this) seleziona l’overload corretto in base al tipo statico noto di this in quella classe concreta.Questo pattern simula il double dispatch ed è il cuore del Visitor pattern.
sequenceDiagram
participant Client
participant S as Shape
participant C as Circle
participant V as Visitor
Client->>S: Accept(visitor)
S-->>C: dispatch virtuale a Circle.Accept
C->>V: Visit(this)
new ha senso solo in casi mirati:
Se stai modellando comportamento sostituibile, usa:
virtual nella base;override nella derivata;sealed override quando vuoi chiudere ulteriormente la gerarchia.| Tema | virtual / override | new |
|---|---|---|
| Catena polimorfica | Sì | No |
| Risoluzione principale | Tipo dinamico dell’oggetto | Tipo statico della variabile |
| Slot vtable | Riutilizzato | Non sostituito |
| Compatibilità con LSP | Attesa e raccomandata | Spesso problematica |
| Prestazioni | Indirette ma spesso ottimizzabili | Simili a un normale metodo non virtuale |
| Uso tipico | Estensibilità corretta | Compatibilità o occultamento intenzionale |
virtual / override.new, fallo solo con un motivo preciso e documentabile.virtual.Cosa fa la keyword virtual su un metodo?
Cosa fa la keyword override su un metodo?
Con virtual/override, il metodo chiamato dipende da:
Con new (method hiding), il metodo chiamato dipende da:
Dato: Base b = new Derivata(); con 'new' su Derivata, quale metodo viene chiamato?
Un metodo abstract:
sealed su un metodo override fa:
Come si chiama l'implementazione del metodo della classe base da una classe derivata?
Una classe abstract può essere istanziata direttamente?
Quale keyword è raccomandata per il polimorfismo dinamico in C#?
Scenario: Modella una gerarchia di veicoli che calcolano il costo del carburante in modo diverso.
Consegna:
Veicolo con metodo virtual decimal CalcolaCostoCarburante(double km).Auto, Camion, Moto che fanno override con formule diverse.Veicolo[] con istanze miste e chiama CalcolaCostoCarburante su ognuna.Obiettivo: vedere il polimorfismo virtual/override in azione con un array di tipo base.
Scenario: Capire perché new può portare a comportamenti inaspettati.
Consegna:
Notifica con metodo string GetTipo() che restituisce “Generica”.NotificaEmail : Notifica con new string GetTipo() che restituisce “Email”.List<Notifica> con oggetti NotificaEmail.GetTipo() su ogni elemento e osserva il risultato (dovrebbe sempre essere “Generica”).override invece di new e confronta.Obiettivo: capire la differenza pratica tra method hiding e method overriding.
Scenario: Costruisci un sistema di calcolo area per forme geometriche.
Consegna:
abstract Forma con metodo abstract double CalcolaArea() e metodo concreto virtual string Descrizione().Cerchio, Rettangolo, Triangolo con i rispettivi calcoli.Cerchio, aggiungi sealed override su Descrizione().Descrizione() in una classe che estende Cerchio.Forma[] per calcolare l’area totale di tutte le forme.Obiettivo: combinare abstract, virtual, override e sealed in un esempio reale.
Prenota una lezione