Programmazione Scientifica++: Lezione 12

Ereditarietà, Polimorfismo e Classi Astratte

Docente

Argomenti di Oggi

  • Ereditarietà (Inheritance)
    • La relazione “IS-A” ed evitare i copia-incolla
  • Upcasting
    • Trattare classi specifiche come entità generiche
  • Polimorfismo e virtual
    • Late binding: chiamare la funzione giusta al momento giusto
  • Il Bug Fatale
    • Perché i distruttori virtual salvano la vita
  • Classi Astratte (Interfacce)
    • Costringere i programmatori a rispettare i Contratti

L’Infrastruttura della Rete Neurale

Nelle 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).

Il problema della Duplicazione del Codice

Immaginate di programmare in puro linguaggio C, o senza usare l’approccio Object-Oriented. Dovreste creare tre struct diverse:

struct DenseLayer {
    std::string name;
    // ...
};

struct ActivationLayer {
    std::string name;
    // ...
};

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!

La Soluzione: L’Ereditarietà

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.

La relazione “IS-A” (È UN)

L’Ereditarietà modella rigorosamente la relazione logica “È UN” (IS-A).

  • Un Cane È UN Animale.
  • Un Cerchio È UNA Forma Geometrica.
  • Un DenseLayer È UN Layer.

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).

Anatomia di una Classe Base

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"; 
    }
};
1
Notate qualcosa di nuovo? La parola chiave protected! A cosa serve?

La visibilità protected

Fino 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!

Anatomia della Classe Derivata

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";
    }
};
1
L’ereditarietà public significa che le funzioni pubbliche del padre resteranno pubbliche nel figlio.
2
Se name_ nel Padre fosse stato private, questa riga darebbe errore di compilazione!

Sinergia tra Classi (Codice)

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;
}
1
La classe DenseLayer non contiene nessuna funzione chiamata printName(). Eppure compila e funziona, perché l’ha “ereditata gratis” dal Padre!

Costruire a Catena (Constructors)

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)
}
1
Prima di far qualsiasi cosa, invoco il costruttore del Padre Layer(name), delegando a lui il lavoro di settare il nome!

La regola dell’Initialization List

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).

Un Layer è un Layer

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;
}
1
Questa operazione (Layer* = DenseLayer*) è lecita, silenziosa e automatica. Non richiede alcun Cast, perché un Figlio è sempre compatibile col ruolo del Padre.

Upcasting: Il superpotere dei Puntatori

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!

Costruire l’Array Neurale (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));
    
    // ...
}
1
Il vettore non sa cosa ci sia veramente dentro quegli indirizzi di RAM. Vede solo puntatori generici a Layer. È bellissimo.

Il ciclo vitale della Rete Neurale

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();
}
1
Semplice, pulito. Che sia un DenseLayer o un ActivationLayer, ognuno calcolerà la propria matematica in totale autonomia. Giusto?

Un problema invisibile e fatale

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().

Il Limite del Binding Statico

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”.

Polimorfismo: La Magia di virtual

Per 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";
    }
};
1
La parola virtual informa il C++ che i Figli potrebbero voler sovrascrivere questo comportamento.

Sovrascrivere con sicurezza (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";
    }
};
1
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!”.

L’Esecuzione Perfetta

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();
}
1
Quando l’indice è 0, il C++ “scende” in RAM, scopre che c’è un 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!

La Trappola Mortale della Memoria

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];
}
1
Sappiamo che delete fa due cose: dealloca i byte e chiama il Distruttore.

L’inganno del delete

Di 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.

Distruttori Virtuali: La Soluzione

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";
    }
};
1
Ora 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!

La Regola d’Oro dell’OOP in C++

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.

Classi Astratte (Interfacce)

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.

Le Funzioni Pure Virtuali (= 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;
};
1
Stiamo dicendo al compilatore: “Questo metodo NON HA IMPLEMENTAZIONE. Esiste solo di nome.”

I Contratti Architetturali

Appena inserite quel = 0, succedono due cose meravigliose per la stabilità del software:

  1. Provate a fare Layer l; nel main? Errore fatale del compilatore. Non si possono istanziare classi astratte.
  2. Volete creare 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()”.

Esercizio: Il Contratto della Loss (15 min)

Mettiamo in pratica questi concetti cruciali!

  1. Crea un file Loss.cpp. Definisci una Classe Astratta Base LossFunction.
  2. Al suo interno crea un Distruttore Virtuale.
  3. Crea un metodo Puro Virtuale virtual double compute(double y_true, double y_pred) = 0;.
  4. Crea una classe derivata MSE (Mean Squared Error).
  5. Se non ricordi, il MSE per due numeri è semplicemente \((y_{pred} - y_{true})^2\).
  6. Crea un’altra classe derivata AbsoluteError che calcola \(|y_{pred} - y_{true}|\) (usa std::abs).
  7. Nel 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!