CodeGym /Corsi /C# SELF /Problemi con polimorfismo e astrazione

Problemi con polimorfismo e astrazione

C# SELF
Livello 25 , Lezione 3
Disponibile

1. Il polimorfismo non è sempre magia

Se guardi gli esempi base, il polimorfismo in C# sembra una figata: erediti, fai override, chiami tutto tramite il tipo base — e tutto funziona. Ma nella pratica ci sono delle sfumature. Vediamole più da vicino.

Nascondere i metodi e la parola chiave new

Immagina di avere una classe base e una derivata, entrambe con un metodo con lo stesso nome, ma senza la parola chiave override. Se nella classe derivata definisci di nuovo quel metodo, ma senza override, allora stai nascondendo l'implementazione del metodo base, non facendo override. Il compilatore, come una mamma premurosa, ti avviserà subito e ti suggerirà di mettere new in modo esplicito:

class Animal
{
    public void Speak()
    {
        Console.WriteLine("L'animale fa un suono.");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("Miao!");
    }
}

// Utilizzo
Animal animal = new Cat();
animal.Speak(); // Stampa: "L'animale fa un suono."

Wow! Anche se crei un oggetto di tipo Cat e lo metti in una variabile di tipo Animal, verrà chiamato il metodo originale della classe base. Perché? Perché il metodo non era dichiarato virtuale! Trappola numero uno: se vuoi usare il polimorfismo, non dimenticare le parole chiave virtual e override. Usa new solo se vuoi davvero nascondere e non fare override di un metodo (e questa cosa serve davvero di rado e solo per buoni motivi).

Chiamate ai costruttori e polimorfismo

Un'altra cosa poco ovvia: i costruttori non sono virtuali. Se nella classe base dichiari un costruttore, e nella derivata ne metti uno tuo, non saranno polimorfici. Ecco un esempio:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Costruttore Animal");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Costruttore Cat");
    }
}

// Utilizzo
Animal animal = new Cat();
// Stampa:
// Costruttore Animal
// Costruttore Cat

Ma se chiami metodi dal costruttore della classe base che possono essere overridati nella derivata, il risultato può essere inaspettato — il metodo virtuale verrà chiamato prima dell'inizializzazione dell'erede! Quindi, meglio non chiamare metodi virtuali/astratti nei costruttori.

Problema di "rottura" dell'incapsulamento con override

I metodi virtuali sono utili, ma se nella classe base hai pensato che un certo metodo si comporti in modo preciso, e poi quel metodo viene overridato nella derivata e rompe la logica — possono nascere bug strani.

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("L'animale mangia.");
    }

    public void Live()
    {
        Eat(); // Può chiamare qualsiasi versione overridata!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("Il gatto mangia il pesce.");
    }
}

Animal a = new Cat();
a.Live(); // Stampa "Il gatto mangia il pesce."

Se nella classe base Animal il metodo Eat() stampava "l'animale mangia", e poi nella derivata nel Eat() overridato aggiungi qualcosa di pericoloso, puoi rompere tutto il funzionamento della classe. Questo problema si chiama violazione del principio di sostituzione di Liskov (Liskov Substitution Principle, LSP). Quando progetti, pensa sempre se il comportamento delle classi derivate rimane logico rispetto alla base.

Casting e problemi di conversione dei tipi

Il polimorfismo ti permette di tenere oggetti diversi in un unico "contenitore": ad esempio, una lista del tipo base, dove ci possono essere sia cani che gatti (entrambi ereditano Animal). Ma se vuoi chiamare qualcosa di specifico:

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

Se ti dimentichi di controllare il tipo e fai un cast avventato, ti becchi un brutto errore InvalidCastException. A volte questo porta a troppe verifiche di tipo, complica il codice e indica che forse la tua progettazione va rivista.

2. Problemi con l'astrazione

L'astrazione è uno strumento top per semplificare la vita all'utente di un oggetto e limitare l'accesso allo stato interno. Ma anche qui ci sono delle trappole!

Esagerare con i livelli di astrazione (Over-Abstraction)

Alcuni sviluppatori alle prime armi (e non solo) si fanno prendere così tanto dal "vero OOP" che creano delle torte a strati di classi base, interfacce e livelli astratti. Alla fine, capirci qualcosa è difficile anche per chi l'ha scritto.

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("Miao!");
    }
}

Ma che senso ha una classe astratta intermedia se non aggiunge nulla? L'astrazione per l'astrazione complica la manutenzione e rende l'architettura un casino.

Gerarchia poco pensata

Pensa a cosa succede se metti un'azione in cima alla gerarchia, ma in realtà non va bene per tutti:

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("I gatti non volano!");
    }
}

Ti tocca riempire le classi figlie di metodi finti che lanciano eccezioni, oppure accettare che la tua interfaccia non rispecchia la realtà. Questo è il classico anti-pattern della "gerarchia sbagliata". In questi casi, meglio mettere questi metodi in interfacce separate (tipo IFlyable).

Consiglio: non cercare di fare un'astrazione che vada bene per tutto.

Problemi con classi astratte e cambiamenti alle API

Appena una classe astratta va in produzione e qualcuno ci eredita, ogni cambiamento diventa rischioso. Aggiungere un nuovo metodo astratto obbliga tutti gli eredi a implementarlo — altrimenti il codice non compila più. Questo rende difficile mantenere librerie e API pubbliche.

Proprio per questi casi sono state introdotte le interfacce con implementazione di default (Default Interface Methods — vedi lezione 116): ti permettono di estendere le interfacce senza dover cambiare subito tutto il codice esistente.

Violazione dell'incapsulamento con l'astrazione

Quando rendi una classe astratta, spesso devi dichiarare i suoi membri come protected, così le derivate possono accedervi. Questo spesso porta a far trapelare la logica interna che sarebbe meglio nascondere. Così gli eredi possono accedere a dati e operazioni che, se modificate, possono rompere l'integrità interna della classe base.

3. Esempi pratici di errori

Non pensare che queste cose succedano solo nei compiti a casa: vediamo esempi reali — anche i programmatori esperti ci cascano ogni tanto.

Esempio con metodi non dichiarati virtual

Supponiamo di estendere la nostra app didattica di logging (vedi Giorno 24). Abbiamo un logger base:

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // Scriviamo su file
        Console.WriteLine("Su file: " + message);
    }
}

// Utilizzo:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Aspettativa: "Su file: Hello!", realtà: "Hello!"

Chi ha fatto FileLogger pensava di aver fatto override del metodo, ma si è dimenticato di mettere override e non ha reso il metodo base virtuale. Così la chiamata va alla versione base.

Raccomandazione: metti sempre virtual sui metodi che vuoi overridare nella base e override nelle derivate.

Esempio di astrazione sbagliata: Animali "flessibili"

Restiamo sugli animali! Creiamo un'interfaccia IFlyable, così non costringiamo tutti gli animali a implementare il metodo Fly:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("L'uccello vola!");
}

class Cat
{
    // Il gatto non implementa IFlyable
}

Ora puoi scrivere una funzione che lavora con i "volanti", senza toccare i gatti:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("Questa bestia non sa volare.");
    }
}

Così non rovini l'architettura con metodi astratti finti.

Problemi con classi astratte "rigide" nell'estendibilità

Immagina di aver pubblicato una libreria con questa classe astratta:

public abstract class Creature
{
    public abstract void DoAction();
}

Gli utenti della tua libreria iniziano a creare le loro classi ereditando questa. Dopo un anno vuoi estendere l'API e aggiungi:

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // Nuovo metodo!
}

Ora tutte le classi degli utenti non compilano più, perché devono implementare il nuovo metodo astratto. Quindi, occhio quando progetti le astrazioni e, se puoi, preferisci le interfacce con metodi di default.

4. Consigli per evitare le trappole più comuni

Fai in modo che le tue app siano flessibili come ginnasti, ma non romperti le gambe!

  • Non abusare dell'ereditarietà: se puoi usare la composizione (inserire un oggetto in un altro), fallo.
  • Rendi i metodi virtuali solo se sei sicuro che vadano overridati.
  • Non dichiarare classi astratte inutili e non creare gerarchie "per il futuro".
  • Controlla la logica quando fai override dei metodi: non rompere gli invarianti (regole) delle classi base.
  • Non aggiungere nuovi metodi astratti a classi base e interfacce pubbliche dopo aver pubblicato la libreria.
  • Usa le interfacce per un collegamento debole tra le parti del programma.
  • Per estendere le API usa i Default Interface Methods.
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION