CodeGym /Corsi /C# SELF /Differenza tra interfacce e classi astratte

Differenza tra interfacce e classi astratte

C# SELF
Livello 23 , Lezione 1
Disponibile

1. Due facce della stessa medaglia

Abbiamo già approfondito abbastanza il concetto di astrazione e abbiamo visto due dei suoi strumenti più potenti in C#: classi astratte e interfacce. Probabilmente hai già la sensazione che siano simili, perché entrambe ci permettono di definire lo "scheletro" di un comportamento che poi dovranno implementare le classi concrete. Ma fidati, è come paragonare un martello e un cacciavite: entrambi sono strumenti, ma per scopi diversi.

Partiamo dalla domanda principale: perché ci servono due strumenti per lo stesso compito? Nella programmazione, come nella vita, raramente qualcosa esiste "tanto per". Se ci sono due strumenti simili, vuol dire che hanno punti di forza unici e ambiti di applicazione diversi.

Facciamo subito una tabella comparativa, così ti fai un’idea generale. È una specie di promemoria che ti aiuterà a cogliere i punti più importanti.

Caratteristica Classe astratta Interfaccia
Creazione istanza Non si può creare direttamente (
new AbstractClass()
).
Non si può creare direttamente (
new IMyInterface()
).
Implementazione membri Può avere:
– Metodi/proprietà completamente implementati.
– Metodi/proprietà astratti (senza implementazione).
– Campi, costruttori, metodi statici.
Può avere metodi con implementazione di default, membri statici astratti e non astratti.
Non può avere campi di istanza e costruttori.
Modificatori di accesso Può avere qualsiasi (public, protected, internal, private). Tutti i membri dell’interfaccia sono di default public. I modificatori di accesso non si specificano (eccezioni: metodi con implementazione di default e membri statici).
Ereditarietà Una classe può ereditare solo da una classe astratta. Una classe può implementare molte interfacce.
Tipo di relazione "È un" (is-a). Definisce il tipo base e la parte comune della gerarchia. "Può fare" (has-a o can-do). Definisce un contratto di comportamento/capacità.
Stato Può mantenere stato (campi di istanza). Può avere campi statici, ma non campi di istanza.
Estensione Si possono aggiungere nuovi metodi implementati senza cambiare i discendenti. Si possono aggiungere metodi con implementazione di default senza rompere i discendenti.

Che ne dici, interessante? Vediamo questi punti più nel dettaglio.

2. Ereditarietà multipla

Questa è forse la differenza più fondamentale e facile da ricordare. In C#, come in molti altri linguaggi (tipo Java), una classe può ereditare solo da una classe genitore. Che sia una classe normale o astratta, non importa: una e una sola! Questo serve per evitare il famoso "problema del diamante" (Diamond Problem), quando ereditando da più classi nasce l’ambiguità su quale metodo usare se hanno lo stesso nome.

Ma invece una classe può implementare tutte le interfacce che vuole! Immagina che la tua classe sia una persona. Può essere "studente" (eredita dalla classe Student), ma allo stesso tempo "sa cucinare" (ICookable), "sa guidare" (IDriveable) e "sa cantare" (ISingable). È super flessibile!


abstract class Animal
{
    public string Name;
    public abstract void MakeSound();
    public void Eat() => Console.WriteLine($"{Name} mangia.");
}

interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }

class Duck : Animal, IFlyable, ISwimable
{
    public double MaxFlyingAltitude => 1000;
    public override void MakeSound() => Console.WriteLine($"{Name} starnazza!");
    public void Fly() => Console.WriteLine($"{Name} vola!");
    public void Swim() => Console.WriteLine($"{Name} nuota!");
}

class Program
{
    static void Main()
    {
        var duck = new Duck { Name = "Donald" };
        duck.MakeSound();
        duck.Eat();
        duck.Fly();
        duck.Swim();

        // Lavoriamo con l’oggetto tramite diversi tipi:
        Animal a = duck;  a.Eat();
        IFlyable f = duck; f.Fly();
        ISwimable s = duck; s.Swim();
    }
}

Se la tua classe deve essere parte di una gerarchia (tipo Dog è un Animal), usa l’ereditarietà da una classe (astratta o normale). Se la classe deve avere una capacità (tipo Dog può correre, Cat può correre), ma queste capacità non sono legate a una gerarchia rigida, usa le interfacce. Questo è il vero vantaggio delle interfacce: permettono di creare contratti per classi molto diverse e non collegate tra loro.

3. Dove vivono i dati e dove solo le promesse?

Le classi astratte possono contenere di tutto:

  • Metodi e proprietà normali (non astratti) con implementazione completa.
  • Metodi e proprietà astratti senza implementazione (quelli che poi sovrascriviamo).
  • Campi (variabili di istanza) che tengono lo stato dell’oggetto.
  • Costruttori, usati per inizializzare lo stato.
  • Persino metodi e proprietà statiche.
  • E ovviamente possono avere qualsiasi modificatore di accesso: public, protected, private ecc.

Questo rende la classe astratta uno strumento potente per definire comportamento parzialmente implementato e stato comune per tutti i suoi discendenti.


abstract class Employee
{
    public string FirstName, LastName;
    public decimal Salary { get; protected set; }
    public Employee(string first, string last) { FirstName = first; LastName = last; }
    public void GetPaid(decimal sum)
    {
        Salary += sum;
        Console.WriteLine($"{FirstName} {LastName} ha ricevuto {sum:C}. Stipendio: {Salary:C}");
    }
    public abstract void PerformWork();
    public abstract void TakeBreak();
}

class Developer : Employee
{
    public Developer(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} scrive codice.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} beve caffè.");
}

class Tester : Employee
{
    public Tester(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} cerca bug.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} gioca a calcio.");
}

class Program
{
    static void Main()
    {
        Employee[] team = {
            new Developer("Ivan", "Petrov"),
            new Tester("Maria", "Sidorova")
        };

        foreach (var emp in team)
        {
            Console.WriteLine($"\n--- Giornata lavorativa per {emp.FirstName} {emp.LastName} ---");
            emp.PerformWork();
            emp.GetPaid(2000);
            emp.TakeBreak();
        }
    }
}

Le interfacce – sono tutta un’altra storia. Fino a C# 8 potevano contenere solo dichiarazioni di metodi, proprietà, indicizzatori ed eventi. Niente campi, niente costruttori, niente implementazione dei metodi! Solo la "firma" del metodo senza corpo. E tutti i membri erano pubblici di default (anche se non scrivevi public). Questo garantiva che l’interfaccia fosse un contratto puro, senza dettagli di implementazione o stato nascosto.

Da C# 8 in poi, le interfacce sono diventate un po’ più "cicciotte" e possono avere metodi con implementazione di default (Default Interface Methods) e membri statici. Questo serve per poter aggiungere nuovi metodi alle interfacce già esistenti senza "rompere" milioni di righe di codice che le implementano. Ma anche con queste novità, le interfacce non possono avere campi di istanza e costruttori. Questa è una limitazione chiave per mantenere le interfacce come "contratti di comportamento" e non "contenitori di stato".


interface ISaveable
{
    void Save(string file); 
    bool IsDirty { get; }
}

interface ILoadable
{
    void Load(string file);
}

class GameProgress : ISaveable, ILoadable
{
    public int Level { get; set; }
    public string PlayerName { get; set; }
    bool _isDirty = true;
    public bool IsDirty => _isDirty;

    public GameProgress(string name, int level)
    {
        PlayerName = name; Level = level;
    }
    public void Save(string file)
    {
        Console.WriteLine($"Salva: {PlayerName}, livello {Level} -> {file}");
        _isDirty = false;
    }
    public void Load(string file)
    {
        Console.WriteLine($"Carica da {file}");
        PlayerName = "Nuovo giocatore"; Level = 5; _isDirty = true;
    }
    public void UpdateProgress(int newLevel)
    {
        Level = newLevel; _isDirty = true;
        Console.WriteLine($"Aggiornato a {Level}.");
    }
}

class Program
{
    static void Main()
    {
        var game = new GameProgress("Eroe", 1);
        game.UpdateProgress(3);

        ISaveable saver = game;
        if (saver.IsDirty) saver.Save("save.dat");

        ILoadable loader = game;
        loader.Load("save.dat");
        Console.WriteLine($"Dopo il caricamento: {game.PlayerName}, {game.Level}");
    }
}

Conclusione: Se ti serve una classe base che fornisce non solo un contratto ma anche codice già implementato (logica comune) o mantiene stato comune, scegli una classe astratta. Se ti serve solo una "checklist" o un "accordo" di comportamento, senza implementazione o stato, allora l’interfaccia è la scelta giusta.

4. Quando usare le interfacce?

Le interfacce sono perfette quando vuoi definire una capacità o un comportamento che possono avere oggetti completamente diversi e non collegati tra loro. Per esempio:

  • IDisposable: qualsiasi oggetto che deve essere "liberato" correttamente dopo l’uso (file, connessione di rete, database).
  • IEnumerable<T>: qualsiasi oggetto che può essere iterato in un ciclo foreach.
  • IComparable<T>: qualsiasi oggetto che può essere confrontato con un altro dello stesso tipo.

La cosa importante è che FileStream e SqlConnection possono implementare IDisposable, mentre List<T> e Dictionary<TKey, TValue> possono implementare IEnumerable<T>. Queste classi appartengono a gerarchie completamente diverse, ma hanno una capacità comune definita dall’interfaccia.

Esempio: Immagina un sistema dove hai Macchina e Aereo. Entrambi possono essere Veicolo (magari una classe astratta). Ma sia Macchina, sia Aereo, sia anche Barca (se la aggiungi) – possono muoversi. Questa capacità di "muoversi" è perfetta per un’interfaccia IMovable.


interface IMovable
{
    void Move(int distance);
}

class Car : IMovable
{
    public string Brand;
    public Car(string brand) => Brand = brand;
    public void Move(int d) => Console.WriteLine($"{Brand} percorre {d} km.");
}

class Airplane : IMovable
{
    public string Model;
    public Airplane(string model) => Model = model;
    public void Move(int d) => Console.WriteLine($"{Model} vola per {d} km.");
}

class Human : IMovable
{
    public string Name;
    public Human(string name) => Name = name;
    public void Move(int d) => Console.WriteLine($"{Name} cammina per {d} m.");
}

class Program
{
    static void Main()
    {
        IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("Artur") };
        foreach (var item in movers)
            item.Move(100);
    }
}

Vedi? Possiamo creare una lista di IMovable e chiamare il metodo Move() per ogni elemento, senza sapere se è una Car, un Airplane o un Human. Questa è la potenza del polimorfismo tramite interfacce.

5. Quando usare le classi astratte?

Le classi astratte sono ideali quando vuoi definire funzionalità base comune per un gruppo di classi strettamente collegate che sono varianti di qualcosa di comune. Forniscono codice già pronto che serve uguale a tutti i discendenti e obbligano i discendenti a implementare le parti specifiche.

Immagina di avere diversi tipi di conti bancari: SavingAccount (risparmio), CheckingAccount (corrente), CreditAccount (credito). Tutti sono BankAccount. Tutti hanno un saldo (Balance) e tutti possono essere ricaricati (Deposit). Ma le regole per il prelievo (Withdraw) sono diverse per ciascuno. Qui entra in gioco la classe astratta BankAccount!


abstract class BankAccount
{
    public string AccountNumber { get; }
    public decimal Balance { get; protected set; }
    public BankAccount(string acc) { AccountNumber = acc; }
    public void Deposit(decimal sum)
    {
        if (sum > 0)
        {
            Balance += sum;
            Console.WriteLine($"{AccountNumber}: +{sum:C}, Saldo: {Balance:C}");
        }
    }
    public abstract bool Withdraw(decimal sum);
}

class CheckingAccount : BankAccount
{
    public CheckingAccount(string acc) : base(acc) { }
    public override bool Withdraw(decimal sum)
    {
        if (Balance >= sum)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, Saldo: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: Fondi insufficienti");
        return false;
    }
}

class CreditAccount : BankAccount
{
    public decimal CreditLimit { get; }
    public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
    public override bool Withdraw(decimal sum)
    {
        if (Balance - sum >= -CreditLimit)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, Saldo: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: Limite superato");
        return false;
    }
}

class Program
{
    static void Main()
    {
        var checking = new CheckingAccount("12345");
        checking.Deposit(1000);
        checking.Withdraw(300);
        checking.Withdraw(800);

        Console.WriteLine("\n--- Conto di credito ---");
        var credit = new CreditAccount("67890", 500);
        credit.Deposit(200);
        credit.Withdraw(400);
        credit.Withdraw(400);
    }
}

Qui BankAccount dà a tutti i conti la logica comune di deposito (Deposit) e gestisce numero di conto e saldo. Ma la logica di prelievo (Withdraw) è diversa, quindi è astratta.

6. Quando lavorano insieme: la coppia perfetta

Il design più elegante e potente spesso combina classi astratte e interfacce. Una classe astratta può implementare una o più interfacce!

Immagina: hai abstract Animal che definisce le cose di base. Ma alcuni Animal possono essere IMovable, ICarnivore, IPredator e così via. Il tuo Animal può anche fornire un’implementazione base per IMovable (tipo il metodo Move(int speed)), ma poi classi concrete come Lion o Fish sovrascrivono questa implementazione per muoversi a modo loro.


public interface ISaveable
{
    void SaveState(string path);
    bool HasChanges { get; }
}

public class Vector3
{
    public float X, Y, Z;
    public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
    public override string ToString() => $"({X}, {Y}, {Z})";
}

public abstract class GameObject
{
    public string Id { get; }
    public Vector3 Position { get; }
    protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
    public abstract void Update();
    public void Destroy() => Console.WriteLine($"{Id} distrutto");
}

public class Player : GameObject, ISaveable
{
    public int Health { get; private set; }
    private bool _hasChanges = true;
    public bool HasChanges => _hasChanges;

    public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;

    public override void Update() =>
        Console.WriteLine($"{Id} aggiornato, HP: {Health}, Pos: {Position}");

    public void TakeDamage(int dmg)
    {
        Health -= dmg;
        _hasChanges = true;
        Console.WriteLine($"{Id} ha subito {dmg} danni. HP: {Health}");
    }

    public void SaveState(string path)
    {
        Console.WriteLine($"Salva {Id} (HP:{Health}) in {path}");
        _hasChanges = false;
    }
}

class GameEngine
{
    static void Main()
    {
        var player = new Player("P1", new Vector3(0, 0, 0), 100);
        player.Update();
        player.TakeDamage(20);

        ISaveable saver = player;
        if (saver.HasChanges) saver.SaveState("save.json");

        GameObject obj = player;
        obj.Update();
        player.Destroy();
    }
}

In questo esempio Player è un GameObject (perché eredita e usa i campi comuni Id, Position e il metodo Destroy). E allo stesso tempo Player può essere salvato (perché implementa l’interfaccia ISaveable). È un approccio super flessibile e potente!

7. Sfumature ed errori tipici

Tentare di creare un’istanza di una classe astratta:

Animal myAnimal = new Animal();
– errore tipico dei principianti. Ricorda: la classe astratta è un modello, non un oggetto pronto. Il compilatore te lo segnala subito.

Dimenticare di implementare i metodi astratti: Se erediti da una classe astratta e la tua classe non è astratta, devi sovrascrivere (override) tutti i metodi astratti della base. Altrimenti il compilatore si lamenta.

Tentare di aggiungere campi a un’interfaccia: Questo è uno di quei "non si può" che distinguono chiaramente le interfacce. Da C# 8+ ci sono i campi statici, ma i campi di istanza (cioè quelli che appartengono a un oggetto specifico) sono ancora vietati. L’interfaccia è un contratto di comportamento, non un contenitore di dati.

Confusione tra virtual e abstract:

  • Un metodo/proprietà virtual ha un’implementazione di default e può essere sovrascritto nei discendenti.
  • Un metodo/proprietà abstract non ha implementazione e deve essere sovrascritto nel primo discendente non astratto.
  • Usare virtual dove la logica è sempre diversa porta a scrivere implementazioni vuote e poi sovrascriverle. È un contratto meno chiaro rispetto a abstract.

Capire queste differenze e scegliere bene tra classe astratta e interfaccia è una skill chiave per ogni sviluppatore C#. Non è solo una questione di sintassi, ma di pensiero architetturale. Quando progetti un sistema, chiediti: "Queste classi hanno una gerarchia comune 'sono'?" e "Queste classi hanno una 'capacità' comune che serve a tipi non collegati tra loro?". Le risposte ti aiuteranno a scegliere bene.

Nelle prossime lezioni continueremo ad approfondire il mondo delle interfacce, studiando le funzionalità più avanzate che sono arrivate nelle ultime versioni di C#. Resta con noi!

2
Compito
C# SELF, livello 23, lezione 1
Bloccato
Interfaccia comune per un lettore multimediale
Interfaccia comune per un lettore multimediale
2
Compito
C# SELF, livello 23, lezione 1
Bloccato
Interfacce per oggetti con diverse abilità
Interfacce per oggetti con diverse abilità
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION