CodeGym /Corsi /C++ SELF /Pure virtual (= 0) e classi astratte

Pure virtual (= 0) e classi astratte

C++ SELF
Livello 52, Lezione 0
Disponibile

1. Perché serve un tipo astratto

Quando il programma diventa più di un paio di file e qualche funzione, all'improvviso si scopre che "conoscere la classe concreta" è un lusso. A volte il codice client vuole semplicemente "qualcosa che sappia stampare una task" o "qualcosa che sappia mostrare un messaggio", e non gli interessa per niente come questo venga fatto. Situazione simile nella vita: non è importante per voi quale corriere consegnerà la pizza — l'importante è che la pizza arrivi.

Immaginiamo di avere una semplice applicazione console educativa chiamata Tasky (un tracker di task fittizio). Prima potevamo stampare le task direttamente con std::cout. Ora però vogliamo due modalità di output: "semplice testo" e "bella con cornice". Non vogliamo riscrivere tutto il codice per due varianti né disseminare if in tutto il progetto.

Creiamo un piccolo modello di task (ce l'avevamo già prima, ma serve il contesto):

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Ora la domanda logica: come scrivere una funzione "stampa task" in modo che funzioni con diversi stili di stampa — senza sapere però quale stile concreto viene scelto?

2. Pure virtual e astrazione

Pure virtual: = 0 — non significa "assegnare zero"

La sintassi = 0 nella dichiarazione di un metodo sembra come se stessimo "assegnando zero a una funzione" (che suona come magia nera del C++). In realtà è una forma speciale di scrittura: la pure virtual function — "metodo senza implementazione nella classe base, obbligatorio da ridefinire".

I documenti di standardizzazione usano il termine specifico pure-specifier per questa parte della dichiarazione. Quindi non è un "trucco casuale del compilatore", ma una costruzione ufficiale del linguaggio.

Un semplice "interfaccia" per stampare una task può essere così:

#include <iostream>

struct ITaskPrinter {
    virtual ~ITaskPrinter() = default;
    virtual void print(const Task& t) const = 0; // pure virtual
};

I punti chiave sono questi.

Abbiamo scritto virtual void print(...) const = 0; — questo significa: il tipo base promette che tutti i derivati avranno il metodo print, ma non dice come stampare. E sì, = 0 — non è un valore né un "puntatore nullo", è parte della sintassi per dichiarare una funzione virtuale.

Perché non si può creare una classe astratta

Quando in una classe compare almeno una funzione pure virtual, la classe diventa astratta. Questo significa: non si può creare un oggetto di questo tipo direttamente. Ha senso: se un tipo ha un "metodo obbligatorio senza implementazione", cosa dovrebbe fare l'oggetto quando quel metodo viene chiamato? Alzare le spalle? Tornare "404: implementazione non trovata"? Il compilatore risolve il problema in modo radicale: "non creiamo".

Ecco una dimostrazione "a livello sensoriale":

int main() {
    // ITaskPrinter p; // Errore di compilazione: classe astratta
}

Tuttavia una classe astratta può essere usata tramite riferimento o puntatore al tipo base — proprio come facevamo prima con il polimorfismo. Quindi "l'oggetto del tipo base non si può creare", ma "riferimento/puntatore al tipo base" va bene, perché in realtà lì ci sarà un oggetto del derivato.

Piccola nota: i dettagli e le sfumature del controllo dei "abstract class types" non sono solo materia didattica: queste cose vengono discusse e fissate anche a livello del WG21 (il committee del C++).

3. Implementare l'interfaccia nell'esempio Tasky

Implementazione: il derivato è obbligato a ridefinire = 0

Ora la parte più pratica: facciamo due printer. Uno stampa "normale", l'altro con "cornice". Iniziamo dal semplice:

#include <iostream>

struct PlainTaskPrinter : ITaskPrinter {
    void print(const Task& t) const override {
        std::cout << "#" << t.id << ": " << t.title << "\n";
    }
};

Il secondo — "a cornice" (mini-versione, senza ASCII-art per metà schermo):

#include <iostream>

struct BoxTaskPrinter : ITaskPrinter {
    void print(const Task& t) const override {
        std::cout << "[#" << t.id << "] " << t.title << (t.done ? " (done)\n" : "\n");
    }
};

Nota bene: abbiamo messo override. È la nostra cintura di sicurezza: il compilatore confermerà che abbiamo davvero ridefinito il metodo della base e non inventato un metodo "simile" (problema classico — dimenticare const o scambiare i parametri).

Ora scriviamo una funzione che stampa tutte le task, senza sapere quale printer concreto:

#include <vector>

void print_all(const std::vector<Task>& tasks, const ITaskPrinter& printer) {
    for (const auto& t : tasks) {
        printer.print(t);
    }
}

E montiamo una mini-demo:

#include <vector>

int main() {
    std::vector<Task> tasks = {
        {1, "Write lecture about abstract classes", false},
        {2, "Drink tea (compulsory)", true},
    };

    PlainTaskPrinter plain;
    BoxTaskPrinter box;

    print_all(tasks, plain);
    // #1: Write lecture about abstract classes
    // #2: Drink tea (compulsory)

    print_all(tasks, box);
    // [#1] Write lecture about abstract classes
    // [#2] Drink tea (compulsory) (done)
}

Questo è il senso principale dell'interfaccia/classe astratta: il codice client dipende dalle capacità (print), non da una classe concreta PlainTaskPrinter o BoxTaskPrinter.

L'astrazione "si eredita"

Situazione molto comune per i principianti: "Mi sono derivato... eppure è ancora astratto... il compilatore si lamenta... che sfortuna". Non è un bug, è logica.

Se il derivato non ha implementato tutte le pure virtual, anche lui diventa astratto. Per esempio:

struct BrokenPrinter : ITaskPrinter {
    // void print(...) non implementato => la classe è ancora astratta
};

E di nuovo:

int main() {
    // BrokenPrinter bp; // Errore di compilazione: ancora astratta
}

Questo comportamento è, in realtà, una tutela per il vostro futuro. Il compilatore sembra dire: "Hai promesso di implementare il contratto. Non l'hai fatto — non lascio creare oggetti che poi esplodono al primo chiamata".

override: assicurazione contro il "quasi lo stesso metodo"

Nella pratica l'errore più doloroso con i metodi virtuali è pensare di aver overridato, ma aver creato in realtà un altra firma. In quel caso la chiamata virtuale va alla versione base (o non compila se la base era pure virtual), e voi restate a guardare lo schermo come traditi.

Esempio tipico: si dimentica la const sul metodo, e quello già è un metodo diverso.

struct AlmostPrinter : ITaskPrinter {
    void print(const Task& t) override { // <-- manca const, ci sarà errore grazie a override
        std::cout << t.title << "\n";
    }
};

Qui override è il nostro eroe. Senza override il compilatore avrebbe potuto tacere, creando un nuovo metodo print che non override l'interfaccia. Con override il compilatore dice chiaramente: "amico, non hai override nulla".

Regola pratica: ogni metodo che credi di override deve avere override. È come allacciarsi la cintura in auto: si può non farlo, ma le probabilità sono contro di te.

Distruttore virtuale nell'interfaccia

Nelle classi astratte usate polimorficamente (tramite Base* / Base&), è fondamentale che il distruttore della base sia virtuale. Altrimenti cancellare un oggetto derivato tramite un puntatore alla base può portare al fatto che il distruttore del derivato non venga chiamato, con perdita di risorse o comportamenti corrotti.

Abbiamo già usato la forma corretta:

struct ITaskPrinter {
    virtual ~ITaskPrinter() = default;
    virtual void print(const Task& t) const = 0;
};

Perché = default è una buona scelta qui? Perché non ci serve logica manuale, vogliamo solo la "virtualità" e un comportamento corretto. Non è una "implementazione speciale", ma una "configurazione corretta della classe".

Anche se ora non stiamo cancellando oggetti con delete manualmente (e va benissimo così), la regola rimane: base polimorfica — distruttore virtuale. Fa parte dell'igiene di base del C++.

4. "Interfaccia" in C++ come stile

In alcuni linguaggi esiste la keyword interface. In C++ non c'è una parola separata, e il ruolo dell'interfaccia è solitamente svolto da una classe astratta, spesso senza campi dati, con distruttore virtuale e un insieme di pure virtual methods.

È importante capire una sottile idea: una classe astratta può avere campi e anche metodi con implementazione. Ma quando diciamo "interfaccia" in senso didattico, di solito intendiamo "tipo che riguarda solo comportamento". Quindi tenere dentro all'interfaccia una std::vector<Task> "per sicurezza" è come portarsi una pentola in tasca: teoricamente possibile, ma gli altri inizieranno a fare domande.

Utile tenere a mente questa mini-tabella:

Concetto Come appare nel codice Cosa significa
Funzione virtuale
virtual void f();
Il tipo base ha un'implementazione, il derivato può override
Pure virtual
virtual void f() = 0;
Non c'è implementazione nella base, il derivato deve override
Classe astratta c'è almeno un = 0 Non si può creare un oggetto direttamente
"Interfaccia" (stile) classe astratta senza dati Il tipo descrive "cosa sa fare", non "cosa conserva"

E un altro schema per non confondere chi è chi:

classDiagram
    class ITaskPrinter {
        <<abstract>>
        +print(Task) const = 0
        +~ITaskPrinter()
    }

    class PlainTaskPrinter {
        +print(Task) const
    }

    class BoxTaskPrinter {
        +print(Task) const
    }

    ITaskPrinter <|-- PlainTaskPrinter
    ITaskPrinter <|-- BoxTaskPrinter

5. Errori tipici

Errore №1: interpretare = 0 come "lì viene memorizzato zero" o "valore di default".
Di solito questo nasce dalla somiglianza visiva con l'assegnamento. Ma una pure virtual non ha "valore": è un marcatore che indica "non c'è implementazione in questa classe". Tenendo questo a mente è più facile leggere le dichiarazioni: = 0 — non riguarda i dati, ma l'obbligo del derivato.

Errore №2: tentare di creare l'oggetto dell'interfaccia direttamente.
Il principiante scrive ITaskPrinter p; e si sorprende per l'errore del compilatore. La classe astratta è "un tipo per comunicare", non una "cosa pronta". Bisogna creare una classe concreta derivata e usare ITaskPrinter& / ITaskPrinter*.

Errore №3: dimenticare di implementare una pure virtual e ottenere "la classe è ancora astratta".
Questo è particolarmente insidioso quando l'interfaccia cresce a più metodi e ne implementate due su tre. Il codice sembra "quasi pronto", ma non è possibile creare l'oggetto. Buona abitudine: aggiungere metodi all'interfaccia con cautela e sistemare subito tutte le implementazioni, altrimenti vi create un mini-apocalisse di build.

Errore №4: non mettere override e non override accidentalmente il metodo.
Il caso più frequente è la discrepanza su const, il tipo del parametro o anche un errore di battitura nel nome. Senza override il compilatore può tacere, e otterrete comportamenti strani (o l'impossibilità di creare l'oggetto se la base era pure virtual). Con override ricevete un errore chiaro nel punto del problema — subito, quando è "caldo".

Errore №5: dimenticare il distruttore virtuale nella base polimorfica.
A volte sembra "non importante": "tanto è un'interfaccia, non conservo nulla". Ma la regola del distruttore virtuale non dipende dal fatto che voi conserviate dati, ma da come distruggete l'oggetto tramite il tipo base. Quindi virtual ~I() = default; — non è un ornamento, è una protezione contro brutte sorprese.

1
Compito
C++ SELF, livello 52, lezione 0
Bloccato
Due saluti
Due saluti
1
Compito
C++ SELF, livello 52, lezione 0
Bloccato
Somma delle aree
Somma delle aree
1
Compito
C++ SELF, livello 52, lezione 0
Bloccato
Formatter della chat
Formatter della chat
1
Compito
C++ SELF, livello 52, lezione 0
Bloccato
Stampa note
Stampa note
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION