Downcasting (dynamic_cast) e Template
Siete a metà del vostro codice. Estraete l’ultimo elemento del vettore della Rete Neurale.
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).
Dobbiamo ritrasformare (fare Cast) del nostro puntatore generico Layer* in un puntatore specifico DenseLayer* (Cast verso il basso, o Downcasting).
Proviamoci:
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).
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();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.
dynamic_castPer 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);DenseLayer (o una sua classe figlia), restituirà il puntatore corretto. Se ha ispezionato la RAM e ha scoperto che è un DropoutLayer… RESTITUIRA’ UN nullptr (Puntatore Nullo)!
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!
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.
dynamic_castL’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’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
}
}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.
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();
}MyCustomLayer, implementerà il suo forward(), e il nostro ciclo lo chiamerà perfettamente senza cambiare di una singola virgola il nostro codice sorgente originale!
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.
Vediamo un uso sensato e asimmetrico del Downcasting! Ti forniamo questa infrastruttura:
vector<Layer*> net e riempilo a piacere (es. 2 Dense e 3 Dropout).for per contare esattamente quanti DropoutLayer ci sono nella rete, e quanti Layer non lo sono.dynamic_cast<DropoutLayer*>(net[i]) e una variabile contatore.#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 ...
}dynamic_cast! Un’ispezione sicura al volo.
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?
L’unica soluzione che conoscete ad oggi è l’Overloading delle funzioni (stesso nome, parametri diversi). Dovreste scrivere questo:
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”.
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.
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!
template <typename T>Ecco come si scrive la nostra funzione in versione Template:
typename oppure class (sono identiche in questo contesto). La lettera T è lo standard universale per indicare il “Type”.
int con il nostro segnaposto magico T!
Come si usa questa funzione prodigiosa nel main? Dobbiamo “riempire” il segnaposto T passando il vero tipo di dato tra parentesi angolari < >:
T con int.
T con double.
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!
Scrivere le parentesi angolari <int> diventa quindi facoltativo per le funzioni!
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.
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.
Immaginiamo di voler creare una classe “Scatola” che può contenere un singolo dato di qualsiasi tipo.
T.
T.
Come si usa nel main? Per le classi, le parentesi angolari sono Obbligatorie al momento della dichiarazione:
Un solo frammento di codice, infinite possibilità!