Skip to content

Metodi Generati dal Compilatore: Regola dei Cinque (e Zero)

Adesso disponi di tutto l’arsenale per dettare le leggi su ogni respiro dei tuoi oggetti in memoria. Ci sono cinque metodi speciali che governano il comportamento del movimento e della copia in C++:

  1. Il Distruttore (~Class())
  2. Il Copy Constructor (Class(const Class&))
  3. Il Move Constructor (Class(Class&&))
  4. Il Copy Assignment Operator (operator=(const Class&))
  5. Il Move Assignment Operator (operator=(Class&&))

Per risparmiarci fatica, i compilatori implementano implicitamente delle versioni “di default” (banalmente superficiali) di tutti questi 5 metodi, a patto che noi non li tocchiamo. Purtroppo, come abbiamo visto, la versione di default del Copy Constructor in presenza di risorse non triviali è disastrosa.

Ancora peggio, la mappa mentale di cosa il compilatore “tenga acceso” in background cambia drasticamente non appena implementiamo manualmente anche solo uno di questi metodi.

Se tu decidi, ad esempio, di dichiarare esplicitamente un Distruttore ~MyClass() personalizzato per la tua classe, il compilatore assumerà che stai maneggiando qualcosa di delicato (potenzialmente una memoria grezza) e per prudenza disabiliterà in tronco la generazione di default per il Move Constructor e il Move Assignment. Da quel momento in poi, ogni operazione di Move sulla tua classe “degraderà” (fallback) a una lenta operazione di Copia in background senza dirti niente!

A causa della tremenda complessità e interdipendenza tra queste regole di background, in C++ vige l’inossidabile Regola del Cinque (Rule of Five). Essa sentenzia in maniera chiara e lapidaria:

Se ti ritrovi a dover dichiarare o implementare esplicitamente ANCHE UNO SOLO dei 5 metodi per la gestione delle copie, dei move o della distruzione, allora SEI COSTRETTO a dichiarare TUTTI e cinque i restanti.

Essere espliciti e verbosi costa pochi minuti in battitura, ma salva il progetto da memory leaks subdoli causati dal fall-back delle auto-generazioni silenziose dei compilatori. Se non necessiti di scriverne il corpo personalizzato ma vuoi riattivare i comportamenti di base o annullarli del tutto, si usa la sintassi del moderno standard tramite default o delete:

struct Replicant {
// Esplicitiamo al compilatore cosa fare di preciso. Zero Ambiguità.
~Replicant() = default;
// Attiviamo esplicitamente le copie member-wise
Replicant(const Replicant&) = default;
Replicant& operator=(const Replicant&) = default;
// Attiviamo esplicitamente i Move (se disattivati tacitamente dal distruttore)
Replicant(Replicant&&) = default;
Replicant& operator=(Replicant&&) = default;
};

Nell’esempio in cui una classe modella qualcosa di intrinsecamente “Unico” (es. un gestore di log, un handler per i file aperti, o un Mutex), vorremo bandire le copie alla radice:

struct Highlander {
// Ne resterà solo uno! Disattiviamo in tronco la capacità del compilatore di farne copie.
Highlander(const Highlander&) = delete;
Highlander& operator=(const Highlander&) = delete;
};

Da quanto detto, deriva un’altra grande regola speculare che rappresenta il vertice zen di chi sa usare il C++ Moderno (Modern C++):

Se possibile, delega la gestione della memoria in toto alle classi della libreria standard che incapsulano le resource acquisition (ad esempio std::string, std::vector, e gli “Smart Pointers” come std::unique_ptr). Se lo fai, la tua classe non gestirà NESSUNA risorsa manuale con new/delete. Di conseguenza, non avrai bisogno di definire ESPLICITAMENTE NESSUNO dei 5 metodi.