1. Introduzione
Se stai iniziando, può sembrare che struct sia solo un insieme di campi e che il compilatore sia un insegnante severo che a volte si lamenta per un punto e virgola in più. Ma in realtà il compilatore è anche un aiutante: tenta di generare per te il codice di routine del ciclo di vita dell'oggetto. E finché il tipo è semplice — tutto funziona. Poi aggiungi un campo std::unique_ptr e improvvisamente «per qualche motivo non si può più copiare». Non è magia, sono regole.
Immagina che il tuo tipo sia un oggetto nell'inventario di un gioco. L'oggetto ha un ciclo di vita: viene creato, a volte copiato (per esempio in una lista), a volte sovrascritto, e poi distrutto. In C++ queste azioni corrispondono a speciali funzioni‑membro.
Quali special member functions esistono
Il termine special member functions è il nome ufficiale del gruppo di funzioni che descrivono la «vita quotidiana» di base di un oggetto. Nello standard queste cose sono distribuite nelle sezioni sui costruttori/copia/operazioni deleted, e molte regole «perché compila / perché non compila» si costruiscono intorno a esse. Ad esempio, nei materiali dello standard si possono incontrare riferimenti a sezioni come [class.{default,copy}.ctor].
In questa lezione ci concentriamo su quattro «special‑functions» chiave:
| Cosa fa | Come appare nel codice | Quando viene usata |
|---|---|---|
| Costruttore di default | |
T x;, T x{}; (se applicabile) |
| Distruttore | |
Chiamato automaticamente alla fine del lifetime |
| Costruttore di copia | |
T b = a; (creazione di una copia) |
| Assegnazione per copia | |
b = a; (sovrascrivere un oggetto esistente) |
Importante: in C++ esistono anche altre special‑functions (per esempio legate allo spostamento delle risorse), ma deliberatamente non le trattiamo per non trasformare la lezione in una serie di 12 stagioni.
2. Come il compilatore genera le special‑functions
Cosa significa «il compilatore le genera da solo»
Quando si dice «il compilatore genera», nella mente di un principiante spesso appare l'immagine di un nano che la notte completa il tuo main.cpp. Quasi così, solo senza la barba (anche se chissà cosa c'è dentro LLVM). In pratica significa: se tu non hai dichiarato una certa special‑function, il compilatore può dichiararla implicitamente per te e poi (se realmente necessaria) — definirla implicitamente, cioè generare il body.
Suona noioso, ma spiega molte «stranezze»:
- hai scritto il programma — tutto ok;
- hai aggiunto una riga in cui l'oggetto viene copiato — e improvvisamente il compilatore dà errore;
- perché proprio in quel momento il compilatore si è trovato a dover realmente generare la copia e ha scoperto un problema.
Un piccolo esempio: «la copia è stata attivata — le regole si manifestano»:
#include <iostream>
#include <string>
struct Note {
std::string title;
};
int main() {
Note a{"Hello"};
// Finché non copiamo — tutto tranquillo.
Note b = a; // il costruttore di copia è realmente necessario
std::cout << b.title << '\n'; // Hello
}
Costruttore di default: quando esiste e quando «sparisce»
Il costruttore di default (T()) è la possibilità di creare un oggetto «senza argomenti». Nelle prime settimane del corso abbiamo spesso fatto int x{}, std::string s;, std::vector<int> v; — e funzionava, perché questi tipi hanno uno stato di default sensato. Con i tipi user‑defined la storia è simile: il compilatore può fornire T(), ma non garantisce che compaia in ogni situazione.
Una regola intuitiva per cominciare: se il tuo struct sembra «solo un insieme di campi», e tutti i campi sanno essere creati correttamente da soli, allora anche il tuo tipo di solito potrà essere creato «vuoto».
Esempio: la nota ha id e title, entrambi con un buon valore iniziale.
#include <iostream>
#include <string>
struct Note {
int id{}; // 0
std::string title{}; // stringa vuota
};
int main() {
Note n; // costruttore di default
std::cout << n.id << " '" << n.title << "'\n"; // 0 ''
}
Ora la tipica «scomparsa del costruttore di default»: hai dichiarato qualsiasi costruttore con parametri e il «gratuito» Note() potrebbe non comparire più automaticamente. Non perché il compilatore sia cattivo, ma perché considera: «se stai scrivendo i costruttori tu stesso — stai chiaramente controllando la creazione degli oggetti».
#include <string>
#include <utility>
struct Note {
int id{};
std::string title{};
Note(int i, std::string t) : id(i), title(std::move(t)) {}
};
int main() {
// Note n; // potrebbe diventare un errore: non c'è il costruttore di default
Note n{1, "First"};
}
Per questa lezione è importante il concetto: la presenza/assenza di T() è parte dell'interfaccia del tipo, e il compilatore la verifica rigorosamente in fase di compilazione.
Distruttore: perché è sempre con te
Il distruttore (~T()) è il «punto di uscita» dell'oggetto dalla vita. Qui conviene passare da «io scrivo il codice» a «eseguo il programma nel tempo». Quando un oggetto esce dallo scope, il compilatore inserisce automaticamente la chiamata al distruttore. Perciò il distruttore è come la pulizia dopo una festa: puoi fingere che non esista, ma i rifiuti appariranno comunque.
Se non scrivi il distruttore, il compilatore di solito crea un distruttore «normale» che semplicemente distrugge i campi.
#include <iostream>
struct Trace {
~Trace() {
std::cout << "Trace destroyed\n";
}
};
int main() {
Trace t;
std::cout << "Work...\n"; // Work...
} // Trace destroyed
Punto chiave: anche quando non scrivi il distruttore, esso esiste comunque concettualmente, perché i campi devono essere distrutti. std::string deve liberare la sua memoria, std::vector la sua, std::unique_ptr deve rilasciare la risorsa, ecc.
Costruttore di copia: copia alla creazione
Nel linguaggio comune «copiare» significa «ottenere un secondo oggetto uguale». In C++ è importante distinguere due forme di copia, perché attivano special‑functions diverse. La prima forma è la copia alla creazione: l'oggetto non esiste ancora e lo creiamo come copia di un altro.
La sintassi sembra innocua, ma il concetto è fondamentale:
T b = a; // creare b come copia di a
Esempio con la «nota»:
#include <iostream>
#include <string>
struct Note {
int id{};
std::string title{};
};
int main() {
Note a{1, "Buy milk"};
Note b = a; // costruttore di copia
std::cout << b.id << " " << b.title << "\n"; // 1 Buy milk
}
Perché il compilatore può farlo per te? Perché applica la regola della «copia per campo»: ogni campo viene copiato come sa essere copiato.
Assegnazione per copia: «sovrascrivere un oggetto esistente»
La seconda forma di copia è la copia in un oggetto già esistente. Ossia l'oggetto b è già vivo, ha dei campi, e vogliamo riscriverli con i valori di a. È un'operazione diversa, con un contratto diverso e una special‑function diversa.
Sintassi:
b = a; // b esiste già
Mini‑esempio:
#include <iostream>
#include <string>
struct Note {
int id{};
std::string title{};
};
int main() {
Note a{1, "Buy milk"};
Note b{2, "Pay bills"};
b = a; // assegnazione per copia
std::cout << b.id << " " << b.title << "\n"; // 1 Buy milk
}
I principanti spesso vedono queste due righe (Note b = a; e b = a;) come «tanto è un uguale». Ma per il compilatore sono due strade diverse: una passa per il costruttore di copia, l'altra per l'operatore di assegnazione.
Comportamento per campo: «il compilatore fa quello che fanno i campi»
Ora ricapitoliamo il modello centrale della lezione. Quando il compilatore genera le special‑functions per il tuo tipo, quasi sempre lo fa per campo. Ossia non inventa una «logica di business» intelligente, ma svolge lavoro meccanico: per ogni campo chiama l'operazione corrispondente.
È simile a «preparare un panino seguendo la ricetta»: se hai pane e formaggio — verrà un panino. Se al posto del formaggio hai una pietra — il panino è formalmente assemblato, ma mangiarlo sarà strano o impossibile.
Esempio con un modello più realistico di una ipotetica applicazione "Notebook" (conserviamo note in un vector):
#include <string>
#include <vector>
struct Note {
int id{};
std::string title{};
};
struct Notebook {
std::vector<Note> notes{};
};
int main() {
Notebook nb1;
nb1.notes.push_back(Note{1, "First"});
Notebook nb2 = nb1; // copia per campo:
// viene copiato vector<Note>,
// dentro vengono copiati Note,
// dentro Note viene copiato string
}
Controllo di buon senso importante: questa copia funziona perché std::vector e std::string si comportano come valori normali — hanno definita correttamente la copia.
4. Quando le special‑functions diventano non disponibili
Operazione implicitamente deleted
Il risultato pratico più utile di questo argomento: il compilatore non si limita a «generare o non generare». Può generare una funzione in stato «non invocabile» — questo si chiama implicitly deleted (funzione implicitamente eliminata). Nelle discussioni e nei materiali dello standard questo emerge spesso: si sottolinea che esistono deleted special member functions, ed è parte del meccanismo formale del linguaggio.
Come appare allo sviluppatore: non hai scritto la copia, ma tentando di copiare ottieni un errore di compilazione.
L'esempio più didattico (e molto utile) è std::unique_ptr. Esprime ownership unica: un proprietario — una risorsa. Perciò non è copiabile. E se metti uno unique_ptr come campo, anche il tuo tipo diventa non copiabile «ereditariamente».
#include <memory>
struct Attachment {
std::unique_ptr<int> data{};
};
int main() {
Attachment a{std::make_unique<int>(42)};
// Attachment b = a; // errore di compilazione: copia proibita
}
Non è un bug, ma una protezione del modello: copiare significherebbe «due oggetti che possiedono la stessa risorsa», cosa che porta rapidamente a disastri (doppia deallocazione, dangling pointer ecc.). Non facciamo queste cose manualmente — e il compilatore non ce le permette.
Mini‑scenario: nota con attachment e «perché non si può copiare»
Colleghiamo la teoria a qualcosa che assomiglia a un programma reale. Immaginiamo di sviluppare una applicazione console "Notebook": ci sono note, e una nota può avere un attachment (per esempio un "file allegato", ma per ora senza file — solo un payload dimostrativo). Se l'allegato è una risorsa unica, è sensato conservarlo tramite std::unique_ptr.
Modelliamo:
#include <memory>
#include <string>
#include <utility>
struct Attachment {
std::string name{};
explicit Attachment(std::string n) : name(std::move(n)) {}
};
struct Note {
int id{};
std::string title{};
std::unique_ptr<Attachment> attachment{};
};
Ora l'effetto chiave: Note non è copiabile automaticamente, perché al suo interno c'è un unique_ptr. E questo in realtà aiuta: il compilatore ti costringe a riflettere su cosa significhi «copiare una nota con un attachment». L'allegato deve essere anch'esso copiato (cioè creare un nuovo Attachment)? Oppure nell'operazione di copia l'allegato va perso? O la copia è vietata dalla logica di business?
A questo punto è importante non cercare di «imbrogliare il linguaggio». Se il tipo è diventato non copiabile, di solito significa che stai conservando una risorsa unica e devi decidere onestamente quale sia il modello dati corretto.
Anche se non hai una risposta subito, l'errore di compilazione è comunque una diagnosi utile: «Ehi, autore, qui hai un'ambiguità semantica, riflettiamoci».
5. Errori tipici
Errore №1: confondere T b = a; e b = a; e pensare che sia lo stesso «uguale».
A livello di sintassi la differenza sembra cosmetica, ma a livello di meccanica del linguaggio sono due operazioni diverse. La prima crea un nuovo oggetto e richiede il costruttore di copia. La seconda sovrascrive un oggetto esistente e richiede l'operatore di assegnazione per copia. Se non distingui questi casi, poi ti meraviglierai «perché qui compila e lì no».
Errore №2: aspettarsi che il costruttore di default esista sempre.
Finché il tuo tipo è un «sacco di campi», il compilatore spesso ti regala T(). Ma se aggiungi un costruttore con parametri, la creazione senza argomenti può diventare non disponibile. Non è un «malfunzionamento del compilatore», ma parte del contratto del tipo: se un oggetto non può essere creato correttamente vuoto — il linguaggio non finge che sia possibile.
Errore №3: credere che «se il compilatore ha generato la copia, allora è logicamente corretta».
Il compilatore può verificare la correttezza tecnica (se i campi sono copiabili), ma non può capire il tuo significato di dominio. Per esempio, copiare un «identificatore unico» a volte è vietato per ragioni di business, anche se tecnicamente è solo un int. Quindi l'autogenerazione è un comodo default, ma non una garanzia di design corretto.
Errore №4: stupirsi che il tipo non sia più copiabile dopo aver aggiunto un campo std::unique_ptr.
È come stupirsi che una porta non si apra più dopo aver inserito la chiave nel verso sbagliato. unique_ptr è progettato per essere non copiabile, per proteggere l'ownership unica. Se diventa campo del tuo tipo, la copia dell'intero tipo diventa automaticamente proibita. Questo è uno dei rari casi in cui «errore di compilazione» previene in modo letterale un futuro errore di memoria.
Errore №5: cercare di curare il divieto di copia con «toppe», invece di chiarire il modello.
Quando la copia è proibita, la reazione peggiore è «ok, userò un puntatore grezzo» o «metto tutto in shared_ptr così copia». Spesso questo peggiora il design: perdi chiarezza sull'ownership e rischi leak o cicli di ownership. Meglio fermarsi e definire: cosa significa la copia dell'oggetto nel tuo dominio?
GO TO FULL VERSION