1. Introduzione
Se state appena iniziando, e facile pensare: «Beh, nel costruttore si puo pur sempre scrivere field_ = value; — e basta». A volte si puo. Ma e una semplificazione pericolosa. Il costruttore viene eseguito quando l'oggetto sta ancora nascendo, e in C++ esiste un ordine preciso: prima vengono creati i campi, e solo dopo viene eseguito il corpo del costruttore. La lista di inizializzazione (: field_(value)) e il modo per creare subito i campi con i valori corretti, non per crearli «come capita» e poi spostarci dentro un valore con un'assegnazione.
Immaginate di aver ordinato una pizza. L'inizializzazione e quando la pizza arriva gia con formaggio e condimento. L'assegnazione e quando vi portano un impasto vuoto e voi dite: «Ah, adesso ci metto tutto io sopra». A volte si puo, ma perche soffrire e perche state pagando la consegna, allora.
Mini-schema «cosa succede durante la creazione di un oggetto»
flowchart TD
A[Si riserva spazio per l'oggetto] --> B["Vengono inizializzati i campi (e le parti base)"]
B --> C[Viene eseguito il corpo del costruttore]
C --> D[L'oggetto e pronto per l'uso]
Il punto chiave qui e: i campi vengono inizializzati prima del corpo del costruttore. Per questo la lista di inizializzazione non e «zucchero sintattico», ma un meccanismo che lavora nel momento giusto.
2. Sintassi della lista di inizializzazione
Quando vedete per la prima volta un costruttore con la lista di inizializzazione, sembra quasi che qualcuno abbia messo per caso due punti e virgole. In realta e tutto piuttosto lineare: dopo i parametri del costruttore c'e il due punti, e poi l'elenco di come inizializzare ciascun campo. Ogni parte field_(...) significa: «crea il campo field_ subito con questo valore». Si tratta proprio della creazione del campo, non di «glielo assegniamo dopo».
Cominciamo a costruire il nostro mini-progetto: un semplice modello di task per un tracker CLI delle cose da fare (qualcosa come «il Todo piu piccolo possibile», che non vi promette una vita nuova ogni lunedi).
Esempio 1: costruttore corretto di Task con lista di inizializzazione
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
, done_(false)
{}
};
Qui e importante cogliere il senso: id_, title_, done_ non “ricevono valori”, ma vengono creati subito con i valori necessari.
Tra poco aggiungeremo dei metodi e stamperemo il task, ma prima fissiamo questo punto: questo e lo «stile corretto» non perche sia piu bello. Bensi perche e piu corretto nel significato.
3. Inizializzazione e assegnazione nel costruttore
Con i tipi semplici (int, bool) spesso sembra che non ci sia differenza. E in effetti, in casi molto semplici, l'ottimizzatore puo «eliminare» il superfluo. Ma non appena i campi diventano piu “vivi” (per esempio std::string, std::vector), la differenza smette di essere filosofica e diventa pratica: o create subito il campo nel modo giusto, oppure lo create prima “vuoto” e poi lo costringete a riconfigurarsi.
Un'idea importante: se nel corpo del costruttore scrivete title_ = title;, a quel punto title_ e gia stato creato in qualche modo. Di solito come stringa vuota. E voi state compiendo una seconda operazione: l'assegnazione. Cioe due passi invece di uno.
Esempio 2: «funziona, ma non e quello che vogliamo»
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title) {
id_ = id; // assegnazione
title_ = title; // assegnazione (la stringa era gia stata creata)
done_ = false; // assegnazione
}
};
Questo codice viene compilato e funzionera. Ma e come se diceste al compilatore: «Creami l'oggetto in qualche modo, poi lo ricostruisco dall'interno». Per i campi semplici non e sempre critico. Ma tra poco vedremo casi in cui cosi non si puo fare affatto — ed e li che inizia la vera idea di «correttezza, non stile».
4. Quando la lista di inizializzazione e obbligatoria: const e riferimenti
Ora arriva il momento in cui C++ dira in modo molto netto: «No». Ed e un buon momento, perche qui il linguaggio vi protegge da un modello pericoloso del tipo «assegnero dopo». Esistono campi che non si possono prima creare “vuoti” e poi valorizzare con un'assegnazione. Gli esempi classici sono i campi const e i riferimenti.
Il motivo e semplice: const significa «questo valore non puo essere cambiato dopo la creazione». E un riferimento non puo essere “rilegato” a un altro oggetto: deve riferirsi a qualcosa fin dal primo istante.
Esempio 3: campo const — solo lista di inizializzazione
#include <string>
class Task {
const int id_;
std::string title_;
public:
Task(int id, const std::string& title)
: id_(id) // deve stare qui
, title_(title)
{}
};
Se provate a scrivere id_ = id; nel corpo del costruttore, il compilatore protestera giustamente: a id_ non si puo assegnare nulla, perche e const.
Nei documenti dello standard esiste una terminologia separata per l'inizializzazione dei membri, con riferimenti ai “member initializer” e al fatto che essi si applichino a specifici membri non statici (direct). Anche se non leggiamo lo standard come un romanzo prima di dormire, e utile sapere che non si tratta di un «consiglio», ma di una parte delle regole rigorose del linguaggio.
Esempio 4: campo-riferimento
#include <string>
class TaskView {
const std::string& title_ref_;
public:
TaskView(const std::string& title)
: title_ref_(title)
{}
};
Vi avverto subito: conservare riferimenti come campi e quasi sempre «ghiaccio sottile», perche bisogna controllare la durata di vita dell'oggetto a cui ci si riferisce. Ma come esempio didattico mostra perfettamente la regola: un riferimento non si puo prima «creare vuoto» e poi assegnare.
5. Costruttore e invarianti: stato valido fin da subito
Finora abbiamo parlato soprattutto di meccanica. Ora passiamo al significato. Se progettate una classe, il suo costruttore e il luogo in cui dovete garantire la correttezza minima: l'oggetto non deve poter nascere “rotto”. E la lista di inizializzazione vi aiuta proprio in questo, perche imposta i valori iniziali esattamente nel punto in cui devono comparire.
Immaginiamo una regola della nostra applicazione: un task non puo avere un id negativo. Possiamo “normalizzare” i dati in ingresso — per esempio, se arriva un valore negativo, trasformarlo in 0. Non e la logica di business perfetta, ma come esempio di invariante va benissimo.
Esempio 5: normalizzazione nella lista di inizializzazione
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id < 0 ? 0 : id)
, title_(title)
, done_(false)
{}
};
Qui mostriamo esplicitamente: l'oggetto non puo nascere con id_ = -50. Non lasciamo la questione a «poi qualcuno controllera». Questo e il modello adulto: il costruttore e il punto d'ingresso della validita.
6. Default member initializer e lista di inizializzazione
Mentre costruivamo Task, potreste aver notato che molti campi hanno valori predefiniti piuttosto ovvi. Per esempio, done_ ha senso inizializzarlo a false. In C++ si possono impostare valori predefiniti direttamente nella dichiarazione del campo. In questo modo il costruttore diventa piu semplice e la classe piu chiara: leggete letteralmente i campi e vedete subito lo “stato normale” dell'oggetto.
E importante capire la priorita: se un campo non e indicato nella lista di inizializzazione, allora viene usato il valore predefinito del campo (se esiste). Questo permette di non duplicare la stessa cosa in ogni costruttore.
Esempio 6: valori predefiniti dei campi
#include <string>
class Task {
int id_ = 0;
std::string title_ = "untitled";
bool done_ = false;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
{}
};
In questa variante done_ non viene menzionato nella lista di inizializzazione, perche ha gia un valore ragionevole “predefinito”. Un codice del genere e piu facile da mantenere: meno ripetizioni, meno possibilita di dimenticare di inizializzare un campo.
7. Mini-utilizzo in main
Fin qui abbiamo scritto classi «nel vuoto». Ma per la mente di chi inizia e importante vedere che l'oggetto viene davvero creato, vive davvero e i metodi funzionano davvero. Per questo aggiungiamo una piccola funzionalita: lettura dei campi tramite metodi const e stampa del task. Questo prosegue la linea del giorno precedente: incapsulamento e interfaccia minima.
La lista di inizializzazione qui resta il protagonista principale: e proprio lei a fare in modo che Task compaia sempre in uno stato prevedibile.
Esempio 7: Task con metodi e stampa
#include <iostream>
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
, done_(false)
{}
int id() const { return id_; }
bool done() const { return done_; }
void mark_done() { done_ = true; }
void print() const {
std::cout << "#" << id_ << " " << title_
<< (done_ ? " [done]" : " [todo]") << '\n';
// per esempio: #1 Buy milk [todo]
}
};
int main() {
Task t{1, "Buy milk"};
t.print(); // #1 Buy milk [todo]
t.mark_done();
t.print(); // #1 Buy milk [done]
}
Notate quanto diventa tranquilla la logica di main: creiamo l'oggetto, ed e gia subito pronto all'uso. E un «main sottile» in miniatura: piu logica di inizializzazione viene nascosta nel costruttore, meno caos c'e nel codice attorno.
8. Sfumature utili
Mem-initializer — una parte separata del costruttore
A volte chi inizia percepisce la lista di inizializzazione come una «continuazione del corpo» oppure come un «posto in cui si puo scrivere codice normale». E meglio pensarla diversamente: il costruttore ha due parti — «di inizializzazione» ed «eseguibile». La lista di inizializzazione e la parte che dice come creare i membri. Il corpo e la parte che dice cosa fare dopo la creazione.
Nella terminologia dello standard potreste incontrare formulazioni su mem-initializer e sugli identificatori dei membri inizializzati. Non stiamo studiando lo standard riga per riga, ma e utile sapere che la sintassi qui non e casuale: e un meccanismo separato del linguaggio, non un «trucco di stile».
La conclusione pratica e molto semplice: se volete impostare i valori iniziali dei campi, fatelo nella lista di inizializzazione (oppure tramite i valori predefiniti dei campi). Se volete eseguire azioni che devono avvenire dopo l'inizializzazione (per esempio stampare un messaggio, calcolare qualcosa ecc.) — allora questa e gia materia del corpo del costruttore.
Tabella-promemoria: quando usare cosa
Per non tenere tutto a mente, e utile fissare una volta la differenza sotto forma di una piccola tabella. Non sostituisce la comprensione, ma aiuta benissimo quando dopo una settimana riaprite il codice e pensate: «Allora... perche qui c'e un due punti, e perche non posso semplicemente assegnare?».
| Cosa fate | Dove scriverlo | Perche |
|---|---|---|
| Impostate i valori iniziali dei campi | Lista di inizializzazione : field_(...) oppure valori predefiniti del campo | E il momento in cui il campo viene realmente creato |
| Volete «cambiare lo stato dopo» | Nel corpo del costruttore (oppure tramite metodi) con assegnazione | Qui si lavora gia con un oggetto esistente |
| Il campo e const oppure un riferimento | Solo lista di inizializzazione (oppure valore predefinito del campo) | Non possono essere “completati” con un'assegnazione successiva |
| Bisogna garantire un invariante durante la creazione | Lista di inizializzazione (a volte + controllo nel corpo) | L'oggetto deve nascere valido |
Fate caso a questo: «valori predefiniti del campo» e «lista di inizializzazione» non sono concorrenti. Di solito lavorano insieme: i valori predefiniti impostano la «norma abituale», mentre il costruttore precisa il valore quando l'utente ha passato dei parametri.
9. Errori tipici
Errore n. 1: confondere inizializzazione e assegnazione e scrivere tutto nel corpo del costruttore.
All'inizio sembra logico: «il costruttore viene eseguito, quindi si puo assegnare». Ma nel momento in cui il corpo del costruttore parte, i campi sono gia stati creati. Per int questo spesso passa inosservato, ma per std::string e altri tipi complessi ottenete lavoro extra. Per const e riferimenti otterrete addirittura un errore di compilazione.
Errore n. 2: considerare la lista di inizializzazione uno “stile” che si puo ignorare.
Questa e una delle impostazioni mentali piu dannose. La lista di inizializzazione non serve per la bellezza del codice e nemmeno per lo «snobismo da programmatore». Serve perche in C++ esiste un ordine rigoroso di creazione dell'oggetto, e alcuni campi possono essere impostati correttamente solo nel momento della loro creazione.
Errore n. 3: cercare di garantire la validita dell'oggetto “dall'esterno”, invece che dentro il costruttore.
Se la classe permette di creare oggetti “rotti”, inevitabilmente avrete bug: da qualche parte ci si dimentica di chiamare un setter, da qualche altra si confonde l'ordine delle chiamate, altrove si mette l'oggetto in un contenitore e ci si dimentica di “completarne la configurazione”. Il costruttore e la lista di inizializzazione permettono di fare in modo che l'oggetto sia corretto subito dopo Task t{...};
Errore n. 4: rendere i campi const/riferimenti e poi stupirsi che “non si possa assegnare”.
Non e un capriccio del compilatore, ma la logica del linguaggio: const e i riferimenti devono ricevere un valore durante l'inizializzazione. A volte questo costringe a progettare con piu cura il modello dei dati — ed e anche un vantaggio: il linguaggio ci suggerisce gentilmente che la «vita dell'oggetto» comincia nel costruttore, non dopo.
GO TO FULL VERSION