Template negli Header e Smart Pointers
.cpp (Bug del Linking)Warning
IL PIÙ GRANDE ERRORE CHE POTETE FARE CON I TEMPLATE!
Fin dalla prima lezione vi abbiamo fatto il lavaggio del cervello: “Separate l’interfaccia nel file .hpp e l’implementazione nel file .cpp!”.
I Template VIOLANO questa regola assoluta.
Se provate a dichiarare template <typename T> class Scatola; nel .hpp e scrivete l’implementazione dei suoi metodi nel .cpp… avrete l’errore di Linker peggiore della vostra carriera: Undefined Reference to Scatola
Ricordate la metafora dello “stampo”? I file .cpp vengono compilati separatamente e indipendentemente (Translation Units).
Quando il compilatore processa Scatola.cpp, non vede nessun Scatola<int> o Scatola<double> nel main. Vede solo lo stampo con la T. Poiché non ha la minima idea di quali tipi l’utente userà nel main, non genera alcun codice binario!
Poi arriva il file main.cpp, che dice “Voglio usare Scatola<int>!”. Il linker cerca il binario, ma il file Scatola.o è completamente vuoto. Disastro totale.
.hpp)Perché un Template funzioni, chiunque lo usi (il main.cpp) deve poter vedere l’intero codice sorgente dell’implementazione in fase di compilazione, per poterselo “stampare” su misura (es. per int).
Tip
Regola Universale per i Template: L’intera definizione della classe E l’implementazione di tutti i suoi metodi DEBBONO risiedere esclusivamente all’interno del file .hpp. Niente file .cpp.
Se aprite la libreria standard <vector>, troverete file Header da migliaia di righe contenenti tutto il codice sorgente.
Archiviamo i Template (per un attimo) e torniamo ai grandi problemi del C++.
Tutto sembra perfetto. Ma è codice estremamente fragile!
Cosa succede se il calcolo fallisce e usciamo in anticipo?
dl. Ma le gigantesche matrici di pesi nell’Heap restano lì, “orfane” (Dangling Memory). Memory Leak Irreversibile.I creatori del C++ (anni ’80) idearono il principio RAII (Resource Acquisition Is Initialization).
L’idea geniale: “Non legare l’allocazione al programmatore. Legala alla vita di un oggetto nello Stack!”. Gli oggetti nello Stack vengono sempre distrutti matematicamente quando la funzione termina (anche a causa di un return o di un’eccezione).
Se avvolgo il mio puntatore grezzo all’interno di un Oggetto-Sentinella che vive nello stack, il suo Distruttore verrà invocato infallibilmente, e lui farà il delete per me!
Quell’idea è rimasta teoria noiosa per decenni, finché il C++11 non ha cambiato le sorti dell’informatica introducendo nella libreria standard (l’header <memory>) i Puntatori Intelligenti (Smart Pointers).
Gli Smart Pointers usano esattamente i Template che abbiamo appena imparato per avvolgere puntatori a qualsiasi cosa (int, Layer, DenseLayer).
Sono classi agili e istantanee che si occupano di far morire le variabili in Heap al posto vostro. Nel C++ moderno, non si scrive MAI MAI MAI la parola delete.
std::unique_ptr<T> (Il Proprietario Unico)Il principe degli smart pointers è std::unique_ptr<T>.
Esso modella il concetto di Proprietà Unica ed Esclusiva (Unique Ownership). Se un unique_ptr punta a un’area di memoria Heap, lui (e solo lui) ne è il padrone. Nessun altro può possederla.
Quando il padrone “muore” (es. finisce lo scope della funzione), l’area di memoria che possedeva viene automaticamente distrutta. È la fine definitiva dei Memory Leaks.
std::make_uniqueDimenticate la parola magica new che avete usato per tutto l’anno. Da oggi, usate il super-costruttore template std::make_unique<Tipo>(argomenti).
#include <memory>
void calcolaReteNeuraleSicura(bool errore_critico) {
// 1. Addio ai raw pointers! Benvenuto unique_ptr!
1 std::unique_ptr<Layer> dl = std::make_unique<DenseLayer>("Hidden");
// 2. Si usa in modo IDENTICO ai vecchi puntatori
dl->forward(); // Usa l'operatore freccia ->
2 if (errore_critico) return;
3}dl.
return anticipato, NON SUCCEDE NULLA DI MALE!
dl muore e porta con sé il DenseLayer!
Come fa un banalissimo Template a sapere quando fare delete? La struttura interna di unique_ptr è (estremamente semplificata) questa:
Sfrutta il determinismo infallibile del distruttore dello Stack!
std::movePoiché è a Proprietà Unica, un unique_ptr NON PUÒ ESSERE COPIATO.
Se provate a fare unique_ptr<Layer> B = A; il compilatore va in errore fatale. Se si potesse copiare, due oggetti farebbero un delete della stessa RAM (Double Free crash).
Ma potete trasferire la proprietà a qualcun altro (come passare un testimone). Per farlo usate std::move(). Il padrone originale verrà svuotato (impostato a nullptr), e il nuovo padrone assumerà il comando totale.
Adesso abbiamo tutti i tasselli del puzzle. Nella Lezione 12 avevamo costruito la rete come un std::vector<Layer*> di puntatori grezzi — la stessa idea del vostro Sequential attuale: puntatori nudi e un distruttore scritto a mano.
Eravamo costretti a scrivere a mano un pericoloso Distruttore della Rete Neurale, per iterare in un loop il vettore e fare delete di ogni singolo Layer uno ad uno, pregando che nessuno avesse scordato un distruttore virtual.
Cancelliamo tutto. Usiamo il C++ Moderno.
Ecco la rivoluzione, affiancata in stile colonne:
Vecchio Codice C++98
std::move per cedere la proprietà del Layer al Vettore. Il Vettore ora ne è l’unico Padrone.Perché nel “Nuovo Codice” la Rete Neurale non ha il Distruttore?
Quando la Rete Neurale muore nel main (perché il programma finisce o perché esce dallo scope), i suoi membri privati muoiono. Il membro privato net (uno std::vector) muore e svuota le sue celle in automatico.
Le sue celle contengono degli std::unique_ptr<Layer>. Quando le celle si svuotano, l’unique_ptr intercetta la morte ed esegue il delete fisico sull’oggetto in RAM.
Una gigantesca, bellissima, automatica reazione a catena che svuota l’intera VRAM e RAM senza un singolo delete da parte vostra!
shared_ptr<T>A titolo puramente accademico, esiste un altro smart pointer nella libreria <memory>, ovvero std::shared_ptr<T>.
A differenza di quello unico, permette di avere Proprietari Multipli. Mantiene un “Reference Counter” (Contatore di Riferimenti). Ogni volta che viene copiato (A = B;), il contatore sale. Ogni volta che una delle copie muore, il contatore scende. L’oggetto fisico nell’Heap verrà distrutto dal delete soltanto quando l’ultimo shared pointer rimasto raggiungerà la sua morte (Contatore a 0).
Lento e costoso in termini di CPU, evitatelo in Machine Learning a meno che non abbiate un reale e contorto problema di multi-threading!
Mettiamo in pratica questo concetto supremo.
SmartNet.cpp. Ricordati l’include <memory>.Layer e un figlio DenseLayer. Metti dei distruttori rumorosi (cout << "Morto!\n";). Ricordati il Distruttore Virtuale!!!main, crea un std::vector<std::unique_ptr<Layer>> rete;.DenseLayer nella rete usando il magico std::make_unique<DenseLayer>() abbinato a std::move().delete nel main! Esegui il programma e goditi i cout che confermano che tutti e tre i Layer sono stati magicamente distrutti e le perdite di memoria azzerate.