Programmazione Scientifica++: Lezione 16

Downcasting (dynamic_cast) e Template

Docente

Il Problema Inverso: L’esigenza del Downcasting

Siete a metà del vostro codice. Estraete l’ultimo elemento del vettore della Rete Neurale.

Layer* ptr = net.back(); // Ritorna un Layer*

Voi sapete (o pensate di sapere) che quell’ultimo strato è un DenseLayer. E avete bisogno disperato di chiamare la funzione getWeightsMatrix() per aggiornare i pesi. Ma la funzione getWeightsMatrix() non fa parte dell’interfaccia generale Layer! Appartiene esclusivamente a DenseLayer.

Se fate ptr->getWeightsMatrix(); il compilatore si rifiuta (la Classe Base non ce l’ha).

Il Rifiuto del Compilatore

Dobbiamo ritrasformare (fare Cast) del nostro puntatore generico Layer* in un puntatore specifico DenseLayer* (Cast verso il basso, o Downcasting).

Proviamoci:

Layer* ptr = net.back();

DenseLayer* dense_ptr = ptr; // ERRORE DI COMPILAZIONE! <1>
  1. invalid conversion from Layer* to DenseLayer*.

Perché il compilatore si rifiuta? Perché non ha la certezza matematica. Lui vede solo un Layer*. Cosa succede se in realtà in RAM quell’oggetto è un DropoutLayer? Voi finireste per chiamare getWeightsMatrix() su un oggetto che non ha pesi! Il programma esploderebbe (Segmentation Fault).

Il Downcasting Statico (static_cast)

Possiamo far tacere il compilatore, mettendogli “una mano sulla bocca” e forzandolo.

// "Fidati di me, so cosa sto facendo"
1DenseLayer* dense_ptr = static_cast<DenseLayer*>(ptr);

dense_ptr->getWeightsMatrix();
1
static_cast esegue la trasformazione ciecamente, in fase di compilazione. Zero overhead.

Il problema: Se vi siete sbagliati, e ptr era un DropoutLayer, il C++ farà finta di nulla, tratterà la memoria del Dropout come se fosse un Dense, e avrete appena creato un bug catastrofico invisibile che corromperà i dati.

Il Detective a Run-Time: dynamic_cast

Per gestire il Downcasting in totale sicurezza, il C++ fornisce un Cast “intelligente”: il dynamic_cast.

A differenza degli altri cast, questo non si fida di voi. Mette in pausa il programma a Run-Time (mentre è in esecuzione), scende in RAM, apre l’oggetto misterioso puntato da Layer*, e ne ispeziona la carta d’identità genetica.

// "Controlla tu se questo è davvero un DenseLayer"
1DenseLayer* dense_ptr = dynamic_cast<DenseLayer*>(ptr);
1
Se è davvero un DenseLayer (o una sua classe figlia), restituirà il puntatore corretto. Se ha ispezionato la RAM e ha scoperto che è un DropoutLayerRESTITUIRA’ UN nullptr (Puntatore Nullo)!

Come gestire la Verità Scomoda (nullptr)

Avendo la certezza che dynamic_cast restituisce un puntatore nullo se il tipo non corrisponde, possiamo (anzi, dobbiamo) usare un blocco if di protezione!

Layer* ptr = net.back();

// Il detective ispeziona la memoria
DenseLayer* dense = dynamic_cast<DenseLayer*>(ptr); 

1if (dense != nullptr) {
    std::cout << "Sì! È un DenseLayer. Aggiorno i pesi.\n";
    dense->getWeightsMatrix(); 
2} else {
    std::cout << "Falso allarme. Era un altro tipo di Layer.\n";
}
1
Il Downcasting ha avuto successo. È un puntatore valido e sicuro.
2
Il Downcasting è fallito. Gestiamo la cosa senza far crashare nulla.

Il RTTI (Run-Time Type Information)

Come fa il C++ a “ispezionare la RAM” e sapere di che tipo è davvero un oggetto polimorfico?

Il compilatore inietta di nascosto in ogni classe polimorfica (quelle con almeno un metodo virtual) una struttura dati chiamata RTTI (Run-Time Type Information).

Note

Il dynamic_cast funziona ESCLUSIVAMENTE su classi che hanno almeno un metodo virtual (solitamente il distruttore lo è). Se provate a usarlo su classi normali prive di RTTI, vi darà errore di compilazione.

Costo Computazionale del dynamic_cast

L’ispezione delle tabelle RTTI a runtime non è gratis. Richiede cicli di CPU per scorrere l’albero di ereditarietà (immaginate cosa succede se avete 10 livelli di parentela!).

Important

Mentre l’Upcasting e il polimorfismo virtual standard sono istantanei (\(O(1)\)), il dynamic_cast porta con sé un overhead prestazionale visibile se inserito all’interno di loop molto stretti (come quello dell’addestramento della Rete Neurale su milioni di iterazioni).

Usarlo una volta ogni tanto va benissimo. Usarlo un milione di volte al secondo è una pessima idea.

L’Anti-Pattern: L’abuso del Detective

L’errore più tragico che commettono i novizi (provenienti da Java o Python) è sfruttare il dynamic_cast per eludere il Polimorfismo, creando le famigerate “Catene di Type-Checking”.

// CODICE SPAZZATURA DA NON SCRIVERE MAI!
for (int i = 0; i < net.size(); i++) {
    if ( dynamic_cast<DenseLayer*>(net[i]) ) {
        // Applica moltiplicazione matriciale
    } 
    else if ( dynamic_cast<ActivationLayer*>(net[i]) ) {
        // Applica funzione non lineare
    }
    else if ( dynamic_cast<DropoutLayer*>(net[i]) ) {
        // Applica Dropout
    }
}

Violazione del Principio SOLID (Open-Closed)

Perché il codice della slide precedente è un Anti-Pattern aberrante? Perché viola il Principio Open-Closed dell’Ingegneria del Software (Una classe deve essere “aperta alle estensioni, ma chiusa alle modifiche”).

Immaginate di consegnare il vostro framework a un utente, che decide di creare un suo nuovo layer personalizzato MyCustomLayer.

Come fa il vostro ciclo for (scritto mesi fa) a sapere di dover fare un else if su MyCustomLayer? Non può! L’utente dovrebbe modificare il vostro codice sorgente originale per aggiungere l’else if. È un disastro architetturale.

La Soluzione: Fidarsi del Polimorfismo

L’Object-Oriented Programming (OOP) è nato apposta per distruggere le catene di if/else sui tipi.

Dite all’oggetto COSA fare, non chiedetegli CHI è.

// ARCHITETTURA PULITA E PERFETTA
for (int i = 0; i < net.size(); i++) {
1    net[i]->forward();
}
1
Sfruttiamo il Late Binding. Se domani qualcuno inventa MyCustomLayer, implementerà il suo forward(), e il nostro ciclo lo chiamerà perfettamente senza cambiare di una singola virgola il nostro codice sorgente originale!

Quando è lecito usare dynamic_cast?

Se il Polimorfismo fa tutto, allora quando serve fare un Downcasting col dynamic_cast?

Si usa solo nei casi di asimmetria logica. Ovvero quando volete estrarre un’informazione specifica da un oggetto, informazione che è concettualmente incompatibile con l’interfaccia della Classe Base, ed estranea al flusso polimorfico primario.

Un esempio perfetto è l’estrazione di statistiche (quanti neuroni sono stati droppati dal DropoutLayer?), per cui la Classe Base Layer non dovrebbe logicamente avere un metodo.

Esercizio Pratico: Il conteggio dei Dropout (10 min)

Vediamo un uso sensato e asimmetrico del Downcasting! Ti forniamo questa infrastruttura:

class Layer {
public:
    virtual ~Layer() {} // Indispensabile per RTTI!
};
class DenseLayer : public Layer {};
class DropoutLayer : public Layer {};
  1. Crea il main, fai un vector<Layer*> net e riempilo a piacere (es. 2 Dense e 3 Dropout).
  2. Fai un ciclo for per contare esattamente quanti DropoutLayer ci sono nella rete, e quanti Layer non lo sono.
  3. Suggerimento: Usa il dynamic_cast<DropoutLayer*>(net[i]) e una variabile contatore.

Esercizio: Soluzione

#include <iostream>
#include <vector>

int main() {
    std::vector<Layer*> net;
    net.push_back(new DenseLayer());
    net.push_back(new DropoutLayer());
    net.push_back(new DenseLayer());
    
    int dropout_count = 0;
    
    for (Layer* l : net) { // Range-based for loop
        // Provo a far calzare la scarpa. Ci entra?
1        DropoutLayer* drop = dynamic_cast<DropoutLayer*>(l);
        
        if (drop != nullptr) {
            dropout_count++;
        }
    }
    
    std::cout << "La rete ha " << dropout_count << " layer di Dropout.\n";
    // ... delete finali omessi ...
}
1
Ecco l’uso perfetto del dynamic_cast! Un’ispezione sicura al volo.

Tipi Rigidi: Una Maledizione?

Fino ad ora vi abbiamo insegnato che il C++ è un linguaggio fortemente tipizzato (Strongly Typed). Questo è un bene enorme per le performance, perché il compilatore sa esattamente quanti byte allocare in RAM per ogni variabile (int = 4 byte, double = 8 byte).

Ma questa rigidità ha un costo elevatissimo in termini di “verbosità” del codice. Immaginate di voler scrivere una semplicissima funzione matematica che restituisce il massimo tra due numeri.

Se vi passo due int, mi ridate l’int maggiore. Se vi passo due double, mi ridate il double maggiore. Come lo implementiamo?

Il problema dell’Overloading Infinito

L’unica soluzione che conoscete ad oggi è l’Overloading delle funzioni (stesso nome, parametri diversi). Dovreste scrivere questo:

// Versione per gli interi
int trovaMax(int a, int b) { 
    return (a > b) ? a : b; 
}

// Versione per i numeri a virgola mobile
double trovaMax(double a, double b) { 
    return (a > b) ? a : b; 
}

// Versione per i float...
float trovaMax(float a, float b) { 
    return (a > b) ? a : b; 
}

Un incubo di Manutenzione

Cosa succede se c’è un bug matematico nella vostra funzione trovaMax? O se decidete di migliorarne l’efficienza logica?

Siete costretti a correggere il bug in tutte e tre le funzioni. E se avete 10 tipi di dato diversi, dovrete correggere 10 funzioni. Questo è l’esatto opposto della buona ingegneria del software.

Quello che ci servirebbe è un modo per dire al C++: “Ignora il tipo di dato. Prendi la roba A, confrontala con la roba B, e restituiscimi la più grande, qualunque cosa essa sia”.

La Programmazione Generica

Questo concetto si chiama Programmazione Generica. Invece di scrivere codice per uno specifico tipo (come int o DenseLayer), scriviamo codice logico generico che funziona per qualsiasi tipo che rispetti certe regole.

Nel linguaggio C, questo si otteneva faticosamente usando i puntatori vuoti void* e i cast espliciti, perdendo totalmente la sicurezza del compilatore e rischiando crash disastrosi (Segmentation Fault).

Il C++ risolve il problema in modo magistrale ed elegante tramite i Template.

I Template in C++

Un Template (dall’inglese Sagoma o Stampo) è una specie di “ricetta” che diamo al compilatore. Gli diciamo: “Ecco il codice. Il tipo di dato esatto te lo dirò solo all’ultimo momento, quando deciderò di usare la funzione”.

Il compilatore C++, in background, prenderà il nostro stampo e creerà automaticamente tutte le funzioni di Overloading (int, double, ecc.) di cui abbiamo bisogno. Noi ne scriviamo una sola, lui ne genera 100!

La sintassi template <typename T>

Ecco come si scrive la nostra funzione in versione Template:

#include <iostream>

// Diciamo al compilatore che T è un Segnaposto per un Tipo.
1template <typename T>
2T trovaMax(T a, T b) {
    return (a > b) ? a : b;
}
1
Si può usare la parola chiave typename oppure class (sono identiche in questo contesto). La lettera T è lo standard universale per indicare il “Type”.
2
Abbiamo sostituito tutti gli int con il nostro segnaposto magico T!

Il “Segnaposto” T

Come si usa questa funzione prodigiosa nel main? Dobbiamo “riempire” il segnaposto T passando il vero tipo di dato tra parentesi angolari < >:

int main() {
    // 1. Dico al compilatore: "Genera al volo la versione per int!"
1    int m1 = trovaMax<int>(5, 10);
    
    // 2. Dico al compilatore: "Ora genera la versione per double!"
2    double m2 = trovaMax<double>(3.14, 2.71);
    
    return 0;
}
1
Questa singola riga forza il compilatore a “sostituire” mentalmente tutte le T con int.
2
Qui forza la sostituzione di tutte le T con double.

Deduzione Automatica del Tipo

In realtà, i compilatori moderni sono estremamente intelligenti. Nella maggior parte dei casi per le funzioni (ma non per le classi), il compilatore riesce a dedurre da solo il tipo di dato T semplicemente guardando cosa gli passate come argomento!

int main() {
    // Il compilatore vede due interi (5 e 10).
    // Capisce da solo che T deve essere 'int'!
    int m1 = trovaMax(5, 10); 
    
    // Vede due double. Capisce che T deve essere 'double'!
    double m2 = trovaMax(3.14, 2.71); 
    
    return 0;
}

Scrivere le parentesi angolari <int> diventa quindi facoltativo per le funzioni!

Attenzione ai Contratti Nascosti

Warning

Un template è potentissimo, ma non fa miracoli. Funziona solo se il tipo T supporta le operazioni matematiche o logiche richieste all’interno del codice del template!

Cosa succede se chiamate trovaMax passandogli due oggetti della vostra Rete Neurale? trovaMax( DenseLayer("L1"), DenseLayer("L2") ) ?

Il compilatore andrà su tutte le furie e lancerà errore. Perché? Perché dentro trovaMax abbiamo scritto la condizione (a > b). La classe DenseLayer non ha l’operatore > sovrascritto! Il concetto di “Layer maggiore di un altro” non ha senso.

Classi Generiche (Class Templates)

Proprio come possiamo creare funzioni generiche, possiamo creare Intere Classi Generiche.

L’esempio più celebre al mondo di Classe Template l’avete usata in ogni singola lezione finora: std::vector<T>.

I programmatori del C++ non hanno scritto mille file vettori diversi (intVector, doubleVector, LayerPointerVector). Ne hanno scritto uno solo (un Template) e vi hanno lasciato l’onore di inserire il tipo nelle parentesi angolari.

Scrivere una Classe Template

Immaginiamo di voler creare una classe “Scatola” che può contenere un singolo dato di qualsiasi tipo.

template <typename T>
class Scatola {
private:
1    T contenuto_;

public:
2    Scatola(T valore) : contenuto_(valore) {}
    
    T estrai() const { return contenuto_; }
};
1
La variabile membro è del tipo “Segnaposto” T.
2
Il costruttore accetta un valore del tipo T.

L’Istanziazione della Classe

Come si usa nel main? Per le classi, le parentesi angolari sono Obbligatorie al momento della dichiarazione:

int main() {
    // Una scatola che contiene interi
    Scatola<int> scatolaNumeri(42); 
    
    // Una scatola che contiene stringhe!
    Scatola<std::string> scatolaParole("Ciao Rete Neurale");
    
    // Una scatola che contiene oggetti Custom!
    Scatola<DenseLayer> scatolaLayer( DenseLayer("Hidden") );
    
    return 0;
}

Un solo frammento di codice, infinite possibilità!