Programmazione Scientifica++: Lezione 17

Template negli Header e Smart Pointers

Docente

L’Errore Fatale del .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. Perché?

Perché i Template spaccano il linker?

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.

La soluzione: Tutto nell’Header (.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.

I veri problemi della RAM (Ripasso)

Archiviamo i Template (per un attimo) e torniamo ai grandi problemi del C++.

void calcolaReteNeurale() {
    Layer* dl = new DenseLayer("Hidden", 128); // RAM ALLOCATA <1>
    
    dl->forward(); // Faccio girare la rete...
    
    delete dl; // RAM PULITA <2>
}
  1. Allocazione manuale in Heap (magazzino dinamico).
  2. De-allocazione vitale.

Tutto sembra perfetto. Ma è codice estremamente fragile!

Il “Return Prematuro” (Memory Leak Invisibile)

Cosa succede se il calcolo fallisce e usciamo in anticipo?

void calcolaReteNeurale(bool errore_critico) {
    Layer* dl = new DenseLayer("Hidden", 128);
    
    if (errore_critico) {
        std::cout << "Abort! Uscita emergenza!\n";
        return; // DISASTRO CATASTROFICO! <1>
    }
    
    dl->forward();
    delete dl; // Questa riga non verrà mai raggiunta.
}
  1. La funzione termina. Lo stack viene distrutto, distruggendo il puntatore dl. Ma le gigantesche matrici di pesi nell’Heap restano lì, “orfane” (Dangling Memory). Memory Leak Irreversibile.

Il Paradigma RAII (C++ Storico)

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!

La Rivoluzione del C++ Moderno (C++11)

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.

Sintassi di Base e std::make_unique

Dimenticate 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}
1
Alloca in Heap un DenseLayer e lo affida alla custodia dell’unique_ptr dl.
2
Se faccio un return anticipato, NON SUCCEDE NULLA DI MALE!
3
Qualunque sia il percorso, a fine scope dl muore e porta con sé il DenseLayer!

L’Uccisione Automatica (Distruttore silente)

Come fa un banalissimo Template a sapere quando fare delete? La struttura interna di unique_ptr è (estremamente semplificata) questa:

// Come è fatto dentro (pseudo-codice)
template <typename T>
class unique_ptr {
private:
    T* ptr; // Il vero raw pointer nascosto!
public:
    // ...
    ~unique_ptr() {
        delete ptr; // IL VERO SEGRETO E' QUI.
    }
};

Sfrutta il determinismo infallibile del distruttore dello Stack!

La Trasmissione della Proprietà: std::move

Poiché è 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.

Aggiorniamo la Rete Neurale!

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.

Dal Vecchio al Nuovo (Raw vs Smart)

Ecco la rivoluzione, affiancata in stile colonne:

Vecchio Codice C++98

class Sequential {
private:
    std::vector<Layer*> net;

public:
    void add(Layer* l) {
        net.push_back(l);
    }
    
    // OBBLIGO MORTALE
    ~Sequential() {
        for(int i=0; i<net.size(); i++)
            delete net[i];
    }
};

Nuovo Codice C++14

#include <memory>

class Sequential {
private:
    std::vector<std::unique_ptr<Layer>> net;

public:
    void add(std::unique_ptr<Layer> l) {
        net.push_back(std::move(l));
    }
    
    // NESSUN DISTRUTTORE!
};
  1. Array Polimorfico Eterogeneo Infrangibile.
  2. Uso std::move per cedere la proprietà del Layer al Vettore. Il Vettore ora ne è l’unico Padrone.

Il Vantaggio Definitivo: Nessun Distruttore

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!

Cenni al Fratello Maggiore: 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!

Esercizio: Il Vettore Intelligente (10 min)

Mettiamo in pratica questo concetto supremo.

  1. Crea un file SmartNet.cpp. Ricordati l’include <memory>.
  2. Dichiara una classe vuota Base Layer e un figlio DenseLayer. Metti dei distruttori rumorosi (cout << "Morto!\n";). Ricordati il Distruttore Virtuale!!!
  3. Nel main, crea un std::vector<std::unique_ptr<Layer>> rete;.
  4. Inserisci tre DenseLayer nella rete usando il magico std::make_unique<DenseLayer>() abbinato a std::move().
  5. Non scrivere nessun 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.