CodeGym /Corsi /C++ SELF /Costruttori deleganti: eliminare la duplicazione

Costruttori deleganti: eliminare la duplicazione

C++ SELF
Livello 49, Lezione 0
Disponibile

1. Introduzione

Quando una classe diventa anche solo un po' utile, succede che non è comodo crearla «in un solo formato». L'utilizzatore della classe (anche voi dopo una settimana) vuole ora creare un oggetto con tutti i dati, ora con i minimi, ora un oggetto "vuoto" di riserva. E qui viene voglia di scrivere 3–4 costruttori… e comincia il copy-paste.

Immaginate di scrivere una classe Task per uno scheduler da linea di comando (il nostro progetto didattico): un task ha un titolo e una priorità. Un costruttore è la «forma completa», il secondo è «titolo, priorità di default», il terzo è «task vuoto». Se in ciascuno di essi ripetete manualmente gli stessi controlli e assegnazioni, prima o poi si disallineeranno. Non è filosofia, è statistica.

Per sentire il problema, guardiamo la versione «naive»:

#include <string>

class Task {
    std::string title_;
    int priority_;
public:
    Task(std::string title, int priority) {
        if (title.empty()) title = "untitled";
        if (priority < 1) priority = 1;
        if (priority > 5) priority = 5;

        title_ = title;
        priority_ = priority;
    }

    Task(std::string title) {
        if (title.empty()) title = "untitled";
        int priority = 3;

        title_ = title;
        priority_ = priority;
    }
};

Il codice non è ancora «orrendo», ma somiglia sospettosamente a copy-paste. Ora immaginate di aver aggiunto una terza regola: «rimuovere gli spazi ai bordi» o «vietare titoli più lunghi di 80». L'avete aggiunta in un costruttore, l'avete dimenticata nell'altro… ed è il caos.

2. Costruttori deleganti: idea e ordine di esecuzione

Idea in parole semplici

Un costruttore delegante è un modo per dire al compilatore: «voglio creare l'oggetto in questo modo, mentre un altro costruttore è solo un comodo involucro». Quindi un costruttore diventa quello principale (centrale), e gli altri sono sottili «alias» che reindirizzano la chiamata al principale.

Per sensazione è molto simile a una buona abitudine nelle funzioni: non spargere la logica in 5 posti, ma avere una funzione che fa il lavoro e alcune piccole funzioni che preparano gli argomenti e la chiamano. Qui parliamo di costruttori.

La sintassi della delegazione è così:

ClassName(args_for_this_ctor) : ClassName(args_for_other_ctor) {
    // corpo (eseguito DOPO il costruttore delegato)
}

Sì, un costruttore chiama un altro costruttore della stessa classe. E no, non è ricorsione «a serpente che si morde la coda» — se fatto correttamente, la catena termina nel costruttore «principale» che inizializza effettivamente i campi.

Regola principale e ordine di esecuzione

Quando vedete per la prima volta la delegazione è facile immaginare una scena sbagliata: come se entrambi i costruttori «insieme» inizializzassero i campi. In realtà il modello è più semplice e rigoroso: esattamente un costruttore è responsabile della creazione dei campi, e l'altro si limita a reindirizzare la chiamata.

Un costruttore delegante ha un comportamento chiave: prima viene eseguito il costruttore verso cui abbiamo delegato (è quello che inizializza l'oggetto), e solo dopo viene eseguito il corpo del costruttore delegante (se presente).

Ecco uno schema compatto:

flowchart TD
    A["Task(title)"] --> B[": Task(title, 3)"]
    B --> C["Task(title, priority) inizializza i campi"]
    C --> D["Corpo di Task(title) (se presente)"]

Se ricordate questa immagine, eviterete metà degli errori tipici.

3. Esempi: Rectangle e Task

Rectangle: il quadrato come caso particolare del rettangolo

Prima di toccare il nostro progetto, riscaldiamoci con un esempio che non richiede contesto. Un rettangolo ha larghezza e altezza. Il caso particolare è il quadrato, dove larghezza e altezza sono uguali. La cosa più stupida è duplicare l'inizializzazione in due costruttori. La cosa migliore è delegare.


class Rectangle {
    int w_;
    int h_;
public:
    Rectangle(int w, int h) : w_(w), h_(h) {}

    explicit Rectangle(int side) : Rectangle(side, side) {}
};

Nota due aspetti.

Primo, il costruttore Rectangle(int side) non inizializza w_ e h_ direttamente — delega al «principale» Rectangle(int w, int h).

Secondo, il codice è molto leggibile: si vede subito l'intenzione dell'autore — «il quadrato si crea come rettangolo con lati side, side». È come un commento, ma il compilatore assicura anche che non rompiate la logica.

Esempio pratico: classe Task per un TaskTracker da console

Ora rendiamo l'esempio più vicino alla realtà: un task nella nostra ipotetica applicazione TaskTracker a riga di comando. Anche se nel vostro corso il progetto ha un nome diverso — non importa: conta l'idea «modello + creazione corretta».

Supponiamo che il task abbia un titolo e una priorità da 1 a 5. Il nostro obiettivo è fare in modo che ogni task dopo la creazione sia valido: titolo non vuoto, priorità nel range.

Versione senza delegazione

Prima il modo «come spesso si fa»:

#include <string>

class Task {
    std::string title_;
    int priority_;
public:
    Task(std::string title, int priority) {
        if (title.empty()) title = "untitled";
        if (priority < 1) priority = 1;
        if (priority > 5) priority = 5;

        title_ = title;
        priority_ = priority;
    }

    Task(std::string title) {
        if (title.empty()) title = "untitled";
        int priority = 3;

        title_ = title;
        priority_ = priority;
    }
};

Il problema non è solo il copy-paste. C'è anche un problema «silenzioso»: i campi vengono prima creati «come capita» e poi li assegniamo. Per un int non è grave, ma per tipi più complessi (stringhe, vettori) perdiamo parte del senso dell'inizializzazione: l'oggetto è già stato creato e noi lo «vestiamo» dopo.

Costruttore principale + delegazione

Facciamo il costruttore principale che prende tutto, normalizza e inizializza i campi. Gli altri delegano.

#include <string>
#include <utility>

class Task {
    std::string title_;
    int priority_;

    static int clamp_priority(int p) {
        if (p < 1) return 1;
        if (p > 5) return 5;
        return p;
    }

    static std::string normalize_title(std::string t) {
        if (t.empty()) return "untitled";
        return t;
    }

public:
    Task(std::string title, int priority)
        : title_(normalize_title(std::move(title)))
        , priority_(clamp_priority(priority))
    {}

    explicit Task(std::string title)
        : Task(std::move(title), 3)
    {}

    Task()
        : Task("untitled", 3)
    {}
};

Ci sono diverse belle cose qui, ma la principale è che le regole di validità vivono in un unico posto: nel costruttore principale e nei suoi piccoli helper. Volete cambiare il range delle priorità a 1..10? Modificate clamp_priority e tutta l'applicazione si comporterà allo stesso modo.

Un'altra cosa utile: i costruttori deleganti qui sono «sottili». Non cercano di ripetere nel corpo ciò che farà il costruttore principale. Si limitano a preparare gli argomenti.

Controllo rapido in main()

Aggiungiamo un piccolo main() per vedere che tutto funziona.

#include <iostream>
#include <string>

int main() {
    Task a;                   // title="untitled", priority=3
    Task b{"", 100};          // title="untitled", priority=5
    Task c{"Read a book"};    // title="Read a book", priority=3

    std::cout << "Tasks created.\n"; // Tasks created.
}

Per ora non stampiamo i campi (non abbiamo ancora operator<< per la classe — lo vedremo nelle prossime lezioni), ma è importante che la creazione non fallisca e non richieda un «secondo passo» di configurazione.

Corpo del costruttore delegante: quando serve

Nella maggior parte dei casi un costruttore delegante dovrebbe essere «quasi vuoto». Ma a volte si vuole aggiungere una piccola logica dopo la delegazione — per esempio logging (a scopo didattico) o una semplice diagnostica.

È importante capire: nel costruttore delegante il corpo viene eseguito dopo che il costruttore principale ha fatto il suo lavoro e l'oggetto è già inizializzato. Questo significa che nel corpo potete accedere ai campi in sicurezza: sono già in uno stato consistente.

Per esempio:

#include <iostream>
#include <string>
#include <utility>

class Task {
    std::string title_;
    int priority_;
public:
    Task(std::string title, int priority)
        : title_(title.empty() ? "untitled" : std::move(title))
        , priority_(priority < 1 ? 1 : (priority > 5 ? 5 : priority))
    {}

    Task(std::string title)
        : Task(std::move(title), 3)
    {
        std::cout << "Task created with default priority.\n";
        // Task created with default priority.
    }
};

In produzione reale non stampereste dal costruttore sulla console (l'utente non deve sapere che avete delegato internamente), ma per didattica è un buon modo per sentire l'ordine di esecuzione.

Delegazione e invarianti: stesse regole senza disallineamenti

Il costruttore è il punto d'ingresso in uno stato valido. Ma se avete 3 costruttori e ognuno normalizza i dati «a modo suo», in pratica avete non una classe ma tre versioni diverse della stessa entità. Poi beccate bug che sembrano magia: «perché il task creato con Task() si comporta diversamente rispetto a quello creato con Task(title)?»

La delegazione cura questo obbligandovi a scegliere un unico luogo dove definire le regole. Anche se non siete fan della teoria, è pratico: meno posti da modificare — meno probabilità di dimenticanze.

Piccola tabella «prima / dopo»:

Approccio Cosa succede Rischi
Più costruttori «indipendenti» Ognuno con i propri controlli e assegnazioni Disallineamento regole, copy-paste, difficile da modificare
Costruttore principale + delegazione Un costruttore definisce le regole, gli altri preparano i parametri Codice più corto, modifiche in un unico posto

4. Limitazioni e anti-esempi

I costruttori deleganti sono potenti, ma non «magici». Hanno alcune regole, che è meglio accettare come «leggi della fisica» piuttosto che cercare di aggirarle.

Un costruttore delegante non può contemporaneamente delegare a un altro costruttore e inizializzare separatamente dei campi nella lista di inizializzazione. In pratica non si può scrivere «delego e nel frattempo aggiusto un campo». La logica è: se deleghi, passi totalmente la responsabilità dell'inizializzazione.

Un altro punto importante: la delegazione deve avere una destinazione finale. Se create una catena che gira in tondo, il compilatore non «capirà cosa intendevate» e vi segnalerà l'errore.

Brutto esempio (da non fare):

class Bad {
public:
    Bad() : Bad(0) {}     // delega
    Bad(int) : Bad() {}   // delega indietro (ciclo)
};

È concettualmente simile a «vado al negozio per il pane, torno a casa per la lista e ho lasciato la lista al negozio». Storia interessante, ma pane non ne avrete.

5. Errori tipici

Errore №1: il copy-paste dei controlli rimane, solo ora è “ben impacchettato”.
A volte lo studente scrive costruttori deleganti ma continua a lasciare controlli e normalizzazioni in ciascuno «per sicurezza». Otteniamo due problemi insieme: c'è delegazione e il disallineamento è ancora possibile. Buono stile: le regole stanno in un unico costruttore principale, gli altri solo preparano argomenti e passano avanti.

Errore №2: tentativo di “pre-inizializzare” i campi nella lista di inizializzazione del costruttore delegante.
Dopo l'introduzione della delegazione nasce la tentazione di scrivere: Task() : Task("untitled", 3), priority_(5) {} — «tanto cambio solo la priorità». Non è permesso: o deleghi o inizializzi i campi direttamente. Se serve cambiare valori, modificate gli argomenti della delegazione o fatelo nel corpo (se ha senso e non rompe gli invarianti).

Errore №3: ciclo di delegazione.
Il ciclo può essere evidente (come nell'esempio Bad), oppure nascosto in una catena di tre costruttori. Il compilatore non lo approverà. Regola pratica: la catena di delegazione deve avere una «fermata finale» — il costruttore che inizializza davvero i campi e non delega oltre.

Errore №4: tentare di eseguire logica importante «prima della delegazione».
A volte si vuole «calcolare qualcosa prima e poi delegare». Ma la delegazione è scritta nella lista di inizializzazione, che viene eseguita prima del corpo del costruttore. Quindi, se dovete preparare parametri, fatelo con funzioni static / helper (come normalize_title), oppure riprogettate il costruttore principale affinché riceva dati già pronti.

Errore №5: trasformare la delegazione in una “torta a strati” piena di effetti collaterali.
Se nei costruttori deleganti cominciano ad apparire stampe, scritture su file, modifiche di oggetti esterni e altri effetti, l'ordine delle chiamate diventa troppo importante e il codice fragile. A scopo didattico std::cout è accettabile, ma come abitudine di progettazione è meglio mantenere i costruttori deleganti il più possibile sottili e prevedibili.

1
Compito
C++ SELF, livello 49, lezione 0
Bloccato
Widget quadrato
Widget quadrato
1
Compito
C++ SELF, livello 49, lezione 0
Bloccato
Template del libro
Template del libro
1
Compito
C++ SELF, livello 49, lezione 0
Bloccato
Calendario intelligente
Calendario intelligente
1
Compito
C++ SELF, livello 49, lezione 0
Bloccato
Profilo del principiante
Profilo del principiante
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION