Ereditarietà, Polimorfismo e Classi Astratte
virtual
virtual salvano la vitaNelle lezioni di Laboratorio, dovrete costruire da zero una Rete Neurale. Una rete è tipicamente composta da un susseguirsi di “Strati” (Layers).
Pensiamo a tre tipi di Layer comuni: 1. Dense Layer: Completamente connesso (moltiplica la matrice dei pesi). 2. Activation Layer: Applica una funzione non lineare (es. ReLU o Sigmoid). 3. Dropout Layer: Disattiva casualmente alcuni neuroni per evitare l’overfitting.
Cosa hanno in comune? Tutti hanno una funzione forward() (per propagare il segnale in avanti) e una funzione backward() (per l’addestramento).
Immaginate di programmare in puro linguaggio C, o senza usare l’approccio Object-Oriented. Dovreste creare tre struct diverse:
Per ognuna dovrete riscrivere le variabili del nome, gli ID, le funzioni per stamparli. È un festival del copia-incolla, la peggiore pratica possibile nell’Ingegneria del Software: se c’è un bug in una funzione condivisa, dovete ricordarvi di correggerlo ovunque!
Il C++ permette a una classe di ereditare (assorbire) tutte le caratteristiche di un’altra classe.
Creiamo una singola “Classe Padre” (o Classe Base), chiamata Layer, in cui scriviamo una volta sola tutto il codice in comune (come la variabile name o la funzione printName).
Poi creiamo le “Classi Figlie” (DenseLayer, ActivationLayer), dicendo al compilatore di partire dalle basi del Padre e aggiungere solo le formule matematiche specifiche.
L’Ereditarietà modella rigorosamente la relazione logica “È UN” (IS-A).
Se la frase ha senso in italiano, ha senso usare l’ereditarietà in C++. Se non ha senso (es. “Il Motore È UN’Auto”), allora state sbagliando! In quel caso dovrete usare la Composizione (l’Auto contiene un Motore come membro dato).
Vediamo come scrivere la Classe Padre della nostra Rete Neurale.
#include <iostream>
#include <string>
// LA CLASSE BASE (PADRE)
class Layer {
1protected:
std::string name_;
public:
Layer(const std::string& name) : name_(name) {}
void printName() const {
std::cout << "Layer: " << name_ << "\n";
}
};protected! A cosa serve?
protectedFino ad oggi conoscevamo due estremi: - private: Accessibile solo all’interno della classe stessa. Inviolabile. - public: Accessibile dal main o da qualsiasi altra parte del programma.
protected è la perfetta via di mezzo inventata per l’Ereditarietà. È come la “cassaforte di famiglia”: - Per il mondo esterno (il main) si comporta esattamente come private (è bloccata). - Ma le Classi Figlie avranno la chiave per aprirla e accedere ai dati del Padre!
Creiamo un Figlio. Notate la sintassi : public Layer vicino al nome della classe. Significa: “Eredita pubblicamente tutto ciò che c’è in Layer”.
// LA CLASSE DERIVATA (FIGLIA)
1class DenseLayer : public Layer {
private:
int num_neurons_;
public:
// Come gestiamo il costruttore? Lo vediamo tra poco!
DenseLayer(const std::string& name, int neurons);
void showDetails() const {
// POSSO LEGGERE name_ PERCHE' E' PROTECTED NEL PADRE!
2 std::cout << "Il layer " << name_ << " ha " << num_neurons_ << " neuroni.\n";
}
};public significa che le funzioni pubbliche del padre resteranno pubbliche nel figlio.
name_ nel Padre fosse stato private, questa riga darebbe errore di compilazione!
Ecco cosa accade nel main quando usiamo la Classe Figlia:
int main() {
DenseLayer den("Hidden1", 128);
// 1. Chiamo un metodo scritto dentro DenseLayer
den.showDetails();
// 2. Chiamo un metodo scritto dentro Layer!
1 den.printName();
return 0;
}DenseLayer non contiene nessuna funzione chiamata printName(). Eppure compila e funziona, perché l’ha “ereditata gratis” dal Padre!
Quando nasce un oggetto Figlio, deve prima nascere il Padre al suo interno. Quindi il Costruttore di DenseLayer non può inizializzare direttamente le variabili del Padre; deve chiamare il costruttore del Padre e passargli i dati!
Come fa? Usando la magica Initialization List (:):
// Implementazione del costruttore di DenseLayer
DenseLayer::DenseLayer(const std::string& name, int neurons)
1 : Layer(name), num_neurons_(neurons)
{
// Il corpo del costruttore figlio (spesso vuoto)
}Layer(name), delegando a lui il lavoro di settare il nome!
Important
È OBBLIGATORIO chiamare il costruttore della Classe Base nell’Initialization List del costruttore Figlio, a meno che la Classe Base non abbia un Costruttore di Default (quello vuoto).
Se omettete Layer(name) dalla lista, il compilatore cercherà disperatamente di chiamare il costruttore vuoto Layer(). Poiché non l’abbiamo scritto, il programma darà un Errore Fatale di compilazione.
Si costruisce sempre dalle fondamenta (Padre) verso il tetto (Figlio).
Torniamo alla logica pura. Se “Un DenseLayer È UN Layer”, allora posso prendere un puntatore a un Layer generico (Layer*), e dirgli di puntare al mio DenseLayer fisico in RAM!
int main() {
// 1. Creo l'oggetto specifico in memoria RAM
DenseLayer* dense_ptr = new DenseLayer("Hidden1", 128);
// 2. "UPCASTING": Uso un puntatore della classe base!
1 Layer* generic_ptr = dense_ptr;
generic_ptr->printName(); // Funziona! Stampa "Hidden1"
return 0;
}Layer* = DenseLayer*) è lecita, silenziosa e automatica. Non richiede alcun Cast, perché un Figlio è sempre compatibile col ruolo del Padre.
Perché dovremmo mai voler “nascondere” l’identità del nostro DenseLayer dentro un banale Layer* generico?
Pensateci: se ogni strato della rete neurale ha un tipo diverso (Dense, Conv, Sigmoid), come facciamo a metterli tutti in un unico Array (un std::vector) per far girare l’intera Rete con un semplice ciclo for?
Un vettore C++ esige elementi tutti dello stesso tipo. La soluzione è creare un array di puntatori al Padre!
std::vector)Questa è l’architettura verso cui convergerà il vostro framework. In laboratorio partirete però da un array C-style (ILayer**) gestito a mano con la Regola del Tre: serve a farvi toccare i problemi che std::vector e gli smart pointer risolvono da soli. Li vedremo nelle Lezioni 17 e 18, e li adotterete nel Laboratorio 10.
#include <vector>
int main() {
// Un Array Eterogeneo! (Puntatori alla Classe Base)
1 std::vector<Layer*> network;
// Inseriamo oggetti diversissimi tra loro
network.push_back(new DenseLayer("Dense_In", 128));
network.push_back(new ActivationLayer("Sigmoid_1"));
network.push_back(new DenseLayer("Dense_Out", 10));
// ...
}Layer. È bellissimo.
Ora possiamo propagare il segnale lungo l’intera Rete Neurale con tre righe di codice elegantissime!
// Pseudo-codice del Forward Pass
for (int i = 0; i < network.size(); ++i) {
1 network[i]->forward();
}C’è un dettaglio terrificante. Il compilatore C++ in questa riga: network[i]->forward();
Guarda il tipo del puntatore network[i]. Di che tipo è? È di tipo Layer*. Quindi il compilatore, di default, decide ciecamente di eseguire il codice della funzione Layer::forward() scritta nella Classe Padre!
Ignorerà totalmente il complicato calcolo matriciale che avevamo faticosamente scritto in DenseLayer::forward() o in ActivationLayer::forward().
Questo comportamento si chiama Binding Statico (Early Binding). Il C++ cerca di essere il più veloce possibile. Già in fase di compilazione, aggancia (“binda”) in modo indelebile la chiamata ptr->forward() al codice della classe dichiarata dal puntatore.
Ma per l’OOP questo è un disastro. Noi non vogliamo chiamare la funzione del Puntatore (il Padre). Noi vogliamo chiamare la funzione del vero oggetto fisico in RAM (il Figlio)!
Dobbiamo dire al compilatore: “Fermati. Non decidere adesso in base al puntatore. Aspetta che il programma sia in esecuzione, segui l’indirizzo in RAM, guarda cos’è veramente quell’oggetto, e chiama la sua specifica funzione”.
virtualPer abilitare questo comportamento (il Late Binding), basta aggiungere una singola, potentissima parola chiave davanti alla funzione della Classe Base: virtual.
class Layer {
protected:
std::string name_;
public:
Layer(const std::string& n) : name_(n) {}
// MAGIA: Questa funzione sarà risolta a Run-Time!
1 virtual void forward() {
std::cout << "Forward Base (non fa nulla)\n";
}
};virtual informa il C++ che i Figli potrebbero voler sovrascrivere questo comportamento.
override)Nelle Classi Figlie, riscriviamo la stessa esatta funzione, inserendo il nostro calcolo matematico specifico. È buona pratica (C++11 in poi) aggiungere alla fine la parola override.
class DenseLayer : public Layer {
public:
DenseLayer(const std::string& n) : Layer(n) {}
// SOVRASCRITTURA!
1 virtual void forward() override {
std::cout << "DenseLayer: Moltiplico Matrice Pesi * Input\n";
}
};override non è obbligatorio, ma se fate un errore di battitura (es. scrivete fowrard()), il compilatore vi bloccherà dicendovi: “Ehi, volevi fare un override ma nel padre non esiste nessuna funzione fowrard!”.
Ora il Polimorfismo (che in greco significa Molte Forme) entra in azione!
std::vector<Layer*> network;
network.push_back(new DenseLayer("L1"));
network.push_back(new ActivationLayer("L2"));
for (int i = 0; i < network.size(); ++i) {
1 network[i]->forward();
}DenseLayer e stampa “Moltiplico Matrice”. Quando l’indice è 1, scopre un ActivationLayer e chiama la sua logica. Abbiamo una singola istruzione che assume molte forme!
C’è un ultimo scoglio gigantesco nell’Ereditarietà, ed è la causa numero uno di leak di memoria in C++.
Immaginate che il programma sia finito, ed eseguiamo il delete del nostro vettore:
// Svuoto il vettore eliminando gli oggetti dalla RAM Heap
for (int i = 0; i < network.size(); ++i) {
1 delete network[i];
}delete fa due cose: dealloca i byte e chiama il Distruttore.
deleteDi che tipo è il puntatore network[i]? È di tipo Layer*. Il compilatore usa il binding statico e chiama il Distruttore del Padre (~Layer()).
Cosa fa il distruttore del figlio ~DenseLayer()? Magari conteneva un delete[] o della logica per svuotare i puntatori delle matrici di Eigen. QUEL DISTRUTTORE NON VERRA’ MAI CHIAMATO.
La classe Base morirà, la classe Figlia resterà a vagare in RAM per sempre.
La soluzione è identica a prima! Dobbiamo forzare il Late Binding anche per i Distruttori.
Basta aggiungere virtual davanti al distruttore della Classe Base.
class Layer {
public:
Layer(const std::string& n) : name_(n) {}
// SALVA LA VITA AI VOSTRI PROGRAMMI
1 virtual ~Layer() {
std::cout << "Distruggo Base\n";
}
};delete network[i]; chiamerà prima il distruttore reale del figlio (~DenseLayer()), e poi (automaticamente) risalirà l’albero per chiamare il distruttore del padre ~Layer(). Pulizia perfetta!
Warning
Regola Universale: Se una classe ha anche solo un metodo virtual, DEVE avere un distruttore virtual.
Ignorare questa regola significa condannare il vostro programma a consumare RAM all’infinito durante l’uso del polimorfismo.
C’è un’ultima anomalia semantica. Nella nostra infrastruttura, ha senso istanziare un DenseLayer. Ha senso istanziare un Layer generico usando new Layer("boh"); ?
Cosa farebbe la sua funzione forward()? Niente. È un non-senso architettonico. Layer è solo un concetto, un’etichetta astratta.
Vorremmo impedire legalmente a chiunque di istanziare oggetti di tipo Layer, lasciandolo esistere solo come base per le classi figlie.
= 0)Per trasformare una Classe in una Classe Astratta (in altri linguaggi nota come Interfaccia), basta dichiarare almeno un metodo come “Puro” mettendo un = 0 alla fine.
class Layer {
protected:
std::string name_;
public:
Layer(const std::string& n) : name_(n) {}
virtual ~Layer() {}
// FUNZIONE PURA VIRTUALE
1 virtual void forward() = 0;
};Appena inserite quel = 0, succedono due cose meravigliose per la stabilità del software:
Layer l; nel main? Errore fatale del compilatore. Non si possono istanziare classi astratte.class DenseLayer : public Layer? Bellissimo, ma se non implementate virtual void forward() override {} nel figlio, il compilatore vi bloccherà, dicendo che il figlio è diventato astratto a sua volta!Avete appena creato un Contratto: “Chiunque voglia far parte della mia Rete Neurale, è obbligato dalla legge a implementare una funzione forward() e una backward()”.
Mettiamo in pratica questi concetti cruciali!
Loss.cpp. Definisci una Classe Astratta Base LossFunction.virtual double compute(double y_true, double y_pred) = 0;.MSE (Mean Squared Error).AbsoluteError che calcola \(|y_{pred} - y_{true}|\) (usa std::abs).main, crea un std::vector<LossFunction*> e inseriscili entrambi. Usa un ciclo per stampare l’errore per i valori (true=1.0, pred=0.5). Svuota la RAM a fine programma!