Ereditarieta, interfacce e polimorfismo in C#

Ereditarieta, interfacce e polimorfismo in C#

Con questo articolo completiamo il percorso introduttivo affrontando i tre pilastri che rendono la programmazione orientata agli oggetti davvero potente: l'ereditarietà, che consente di costruire nuove classi a partire da classi esistenti; le interfacce, che definiscono contratti indipendenti dall'implementazione; e il polimorfismo, che permette di trattare oggetti diversi in modo uniforme. Insieme, questi meccanismi consentono di scrivere codice flessibile, riutilizzabile e facile da estendere.

L'ereditarietà

L'ereditarietà permette a una classe, detta derivata, di acquisire i membri di un'altra classe, detta base. La classe derivata riutilizza ed estende ciò che la base fornisce, aggiungendo o specializzando comportamenti. In C# l'ereditarietà si indica con i due punti dopo il nome della classe derivata.

// Classe base
class Animal
{
    public string Name { get; set; }

    public void Eat()
    {
        Console.WriteLine($"{Name} sta mangiando");
    }
}

// Classe derivata: eredita Name ed Eat, aggiunge Bark
class Dog : Animal
{
    public void Bark()
    {
        Console.WriteLine($"{Name} abbaia");
    }
}

var dog = new Dog { Name = "Fido" };
dog.Eat();  // metodo ereditato dalla base
dog.Bark(); // metodo proprio della derivata

Quando la classe derivata definisce un proprio costruttore, può richiamare quello della base con la parola chiave base, passandogli gli argomenti necessari a inizializzare la parte ereditata.

class Animal
{
    public string Name { get; }

    public Animal(string name)
    {
        Name = name;
    }
}

class Dog : Animal
{
    public string Breed { get; }

    // Richiama il costruttore della base con base(...)
    public Dog(string name, string breed) : base(name)
    {
        Breed = breed;
    }
}

Metodi virtuali e override

Il vero potere dell'ereditarietà emerge quando una classe derivata ridefinisce il comportamento di un metodo della base. La classe base marca il metodo come virtual, dichiarando che può essere ridefinito, e la derivata lo ridefinisce con override.

class Shape
{
    // Il metodo virtuale puo essere ridefinito
    public virtual double Area()
    {
        return 0;
    }
}

class Circle : Shape
{
    public double Radius { get; set; }

    // Ridefinizione del comportamento della base
    public override double Area()
    {
        return Math.PI * Radius * Radius;
    }
}

class Square : Shape
{
    public double Side { get; set; }

    public override double Area()
    {
        return Side * Side;
    }
}

Il polimorfismo

Il polimorfismo è la capacità di trattare oggetti di tipi diversi attraverso un tipo comune, invocando il comportamento corretto in base al tipo reale dell'oggetto. Poiché Circle e Square sono entrambi Shape, possiamo raccoglierli in un'unica collezione e chiamare Area su ciascuno: verrà eseguita la versione specifica di ogni tipo.

// Una collezione di forme eterogenee trattate come Shape
var shapes = new List<Shape>
{
    new Circle { Radius = 2 },
    new Square { Side = 3 }
};

foreach (Shape shape in shapes)
{
    // Viene invocata l'implementazione corretta per ogni oggetto
    Console.WriteLine($"Area: {shape.Area():F2}");
}

Questo è il cuore del polimorfismo: il codice che itera sulle forme non ha bisogno di sapere di quale tipo concreto si tratti. Aggiungere una nuova forma, ad esempio un triangolo, non richiede di modificare il ciclo, ma solo di creare una nuova classe derivata.

Le classi astratte

Nell'esempio precedente, un oggetto Shape generico non ha un'area sensata: esistono solo forme concrete. Per esprimere questo concetto si usa una classe astratta, che non può essere istanziata direttamente e può contenere metodi astratti, cioè privi di implementazione, che le classi derivate sono obbligate a fornire.

// Classe astratta: non puo essere istanziata
abstract class Shape
{
    // Metodo astratto: nessun corpo, va implementato dalle derivate
    public abstract double Area();

    // Una classe astratta puo anche avere metodi concreti
    public void Describe()
    {
        Console.WriteLine($"Questa forma ha un'area di {Area():F2}");
    }
}

class Circle : Shape
{
    public double Radius { get; set; }

    public override double Area() => Math.PI * Radius * Radius;
}

Le interfacce

Un'interfaccia è un contratto puro: definisce quali membri un tipo deve offrire, senza specificare come. A differenza dell'ereditarietà, che stabilisce una relazione "è un tipo di", le interfacce esprimono una capacità, un "è in grado di". Una classe può ereditare da una sola classe base ma può implementare quante interfacce vuole, superando così il limite dell'ereditarietà singola.

// Un contratto: chi lo implementa sa disegnarsi
interface IDrawable
{
    void Draw();
}

// Un altro contratto: chi lo implementa ha un'area
interface IMeasurable
{
    double Area();
}

// Una classe puo implementare piu interfacce
class Circle : IDrawable, IMeasurable
{
    public double Radius { get; set; }

    public void Draw()
    {
        Console.WriteLine("Disegno un cerchio");
    }

    public double Area() => Math.PI * Radius * Radius;
}

Le interfacce sono onnipresenti nella libreria standard di .NET: molte funzionalità, come l'ordinamento o l'iterazione delle collezioni, si basano su interfacce standard come IComparable o IEnumerable. Programmare verso un'interfaccia anziché verso una classe concreta rende il codice più disaccoppiato e più facile da testare e sostituire.

Controllo dei tipi e pattern matching

Quando si lavora con oggetti trattati tramite un tipo base o un'interfaccia, a volte occorre verificare il tipo reale. L'operatore is controlla il tipo e, nella sua forma moderna, può contestualmente assegnare l'oggetto convertito a una variabile. L'operatore as tenta la conversione restituendo null in caso di fallimento.

Shape shape = new Circle { Radius = 5 };

// L'operatore is con pattern verifica e converte in un colpo solo
if (shape is Circle circle)
{
    Console.WriteLine($"Raggio: {circle.Radius}");
}

// Il pattern matching in una switch expression
string description = shape switch
{
    Circle c => $"Cerchio di raggio {c.Radius}",
    Square s => $"Quadrato di lato {s.Side}",
    _ => "Forma sconosciuta"
};

Sigillare una classe

A volte si desidera impedire che una classe venga ulteriormente estesa. La parola chiave sealed blocca l'ereditarietà, comunicando che quella classe è pensata per essere usata così com'è. Analogamente, si può sigillare un singolo metodo ridefinito per impedirne ulteriori override nelle classi più in basso nella gerarchia.

// Nessuna classe potra derivare da FinalService
sealed class FinalService
{
    public void Run()
    {
        Console.WriteLine("Servizio in esecuzione");
    }
}

Conclusione

Con questo articolo abbiamo completato le fondamenta della programmazione orientata agli oggetti in C#. Abbiamo visto l'ereditarietà e il richiamo del costruttore base, la ridefinizione dei comportamenti con virtual e override, il polimorfismo che tratta oggetti diversi in modo uniforme, le classi astratte che impongono un'implementazione e le interfacce che definiscono contratti superando l'ereditarietà singola. Abbiamo infine esaminato il controllo dei tipi con is, as e il pattern matching, e la parola chiave sealed per bloccare l'estensione.

Questa serie introduttiva ci ha portati dalle basi dell'ecosistema .NET fino ai concetti centrali dell'OOP. Da qui si aprono molte strade: la gestione delle eccezioni, i generics avanzati, LINQ, la programmazione asincrona con async e await e i tanti framework che poggiano su C#. Le fondamenta costruite in questi sette articoli sono il punto di partenza solido per esplorare tutto ciò che il linguaggio ha da offrire.