Programmazione Scientifica++: Lezione 15

Tipi Enumerati (enum class) e Upcasting

Docente

Argomenti di Oggi

  • Addio ai “Numeri Magici”
    • Leggibilità e Manutenibilità del Codice
  • Tipi Enumerati Moderni
    • Il potere e la sicurezza di enum class
  • L’Identità degli Oggetti
    • Upcasting: Promozione automatica
    • Downcasting: Retrocessione forzata
  • Il Detective a Run-Time
    • Ispezionare la RAM con dynamic_cast e RTTI
  • Design Architetturale
    • Perché l’abuso del Downcasting è un Anti-Pattern

Parametri rigidi nel Machine Learning

Quando costruiamo il nostro framework per la Rete Neurale, avremo bisogno di specificare molte configurazioni a parole.

Ad esempio, quale Funzione di Attivazione vogliamo per un certo DenseLayer? Quale algoritmo di Ottimizzazione vogliamo per l’addestramento (SGD, Adam, RMSprop)?

Il computer capisce solo numeri. Come traduciamo questi concetti “umani” in codice C++ efficiente senza appesantire la CPU con lenti controlli su stringhe di testo (es. if (act == "ReLU"))?

Il problema dei “Numeri Magici”

L’approccio da principianti (molto usato in C e Fortran) è mappare ogni parola a un numero intero.

// Legenda mentale del programmatore:
// 0 = Nessuna, 1 = Sigmoide, 2 = ReLU, 3 = Tanh

DenseLayer layer("Hidden1", 128);
1layer.setActivation(2);
1
Stiamo passando il numero 2, che nella nostra testa significa ReLU.

Questo numero 2 buttato in mezzo al codice prende il nome di Numero Magico (Magic Number).

Un codice illegibile e pericoloso

I Numeri Magici sono la rovina dei grandi progetti software:

  1. Illegibilità: Se leggete layer.setActivation(2) tra sei mesi, vi ricorderete cosa diamine significa il 2? Assolutamente no. Dovrete andare a cercare nei commenti.
  2. Nessuna Sicurezza: La funzione accetta un int. Cosa impedisce a un altro utente della vostra libreria di chiamare layer.setActivation(999)? Il compilatore lo accetterà silenziosamente, per poi far esplodere la rete neurale durante il calcolo.

L’evoluzione del C++: gli Enumeratori

Per risolvere questo problema, nascono i Tipi Enumerati (enum). Un Enumeratore è un modo per inventare un Nuovo Tipo di Dato (come int o double), che però può assumere solo e soltanto un set ristretto di valori stabiliti da noi.

Questi valori avranno internamente un numero intero per il computer (massima velocità), ma nel codice verranno scritti con parole umane (massima leggibilità).

Tuttavia, bisogna fare attenzione: in C++ ci sono due modi di scriverli. Il modo vecchio (C-Style) e il modo nuovo (C++11).

C-Style enum (L’approccio obsoleto)

Il vecchio modo di fare gli enumeratori era semplicemente:

enum Activation { 
    NONE, 
    SIGMOID, 
    RELU, 
    TANH 
1};

DenseLayer layer("Hidden1");
layer.setActivation(RELU); // Leggibile!
1
Sotto il cofano, il C++ assegna NONE=0, SIGMOID=1, RELU=2, ecc.

Sembra perfetto, vero? Eppure, questo approccio porta con sé un grave problema che infesta grandi librerie.

Il problema dei Namespace Clashes

Negli enum classici, i nomi dei valori (“RELU”, “NONE”) “fuoriescono” e inquinano lo scope globale!

Immaginate di avere due enum diversi nel vostro framework:

enum Activation { NONE, SIGMOID, RELU };
1enum ErrorType  { NONE, WARNING, FATAL };
1
ERRORE FATALE DI COMPILAZIONE! Redefinition of NONE.

Entrambi gli enum stanno cercando di creare una variabile globale chiamata NONE. Il compilatore andrà in panico.

C++11: La rivoluzione enum class

Per risolvere l’inquinamento dei nomi, il C++11 ha introdotto gli enum class (Strongly Typed Enums).

enum class Activation { 
    NONE, 
    SIGMOID, 
    RELU 
};

enum class ErrorType { 
    NONE, 
    WARNING, 
    FATAL 
1};
1
Ora questo compila perfettamente! Perché?

Perché le parole non fuoriescono più! Per usarle, dovrete esplicitare il nome della classe-enum usando l’operatore di scope ::.

Strongly Typed: La Sicurezza prima di tutto

L’enum class non protegge solo i nomi, ma applica una sicurezza sui Tipi draconiana.

DenseLayer layer;

// 1. Uso corretto e leggibile
layer.setActivation(Activation::RELU); 

// 2. Errore di Compilazione (Type mismatch)
1layer.setActivation(2);

// 3. Errore di Compilazione (Type mismatch)
2layer.setActivation(ErrorType::NONE);
1
Il compilatore rifiuta i Numeri Magici! Activation non è un int.
2
Il compilatore si accorge che state passando un Errore a una funzione che aspetta un’Attivazione, prevenendo un bug logico gravissimo.

enum class e lo statement switch

L’enum class si sposa in maniera perfetta con lo statement switch, ed è questo il modo in cui scriveremo la matematica del feedforward.

#include <cmath>

double applyActivation(double x, Activation act) {
1    switch (act) {
        case Activation::RELU:
            return (x > 0) ? x : 0.0;
            
        case Activation::SIGMOID:
            return 1.0 / (1.0 + std::exp(-x));
            
        case Activation::NONE:
            return x;
    }
    return x;
}
1
Velocissimo per la CPU, leggibilissimo per i programmatori.

Vantaggi dell’utilizzo nello switch

C’è un ulteriore immenso vantaggio nell’usare enum class dentro gli switch.

Tip

Se il vostro switch sta valutando un enum class, i compilatori moderni vi daranno un Warning (Avvertimento) se vi dimenticate di gestire uno dei possibili casi!

Se domani aggiungete TANH all’enum Activation, ma vi dimenticate di aggiornare la funzione matematica, il compilatore vi dirà: warning: enumeration value 'TANH' not handled in switch. Vi salverà da ore di debugging!

Esercizio: ActivationType (5 min)

  1. Crea un file EnumTest.cpp.
  2. Dichiara un enum class chiamato Optimizer con i valori SGD, ADAM, e RMSPROP.
  3. Crea una funzione vuota void compileNetwork(Optimizer opt) che accetta il nuovo tipo.
  4. Nel main, prova a richiamare compileNetwork(1); (dovrebbe darti errore).
  5. Poi chiama compileNetwork(Optimizer::ADAM); (dovrebbe compilare perfettamente).

L’Identità degli Oggetti: Il Casting

Archiviamo gli enum e torniamo al cuore dell’architettura della nostra Rete Neurale: L’Ereditarietà.

Abbiamo costruito una Classe Base astratta Layer, da cui ereditano classi concrete come DenseLayer e ConvLayer. Sappiamo che ogni oggetto ha una precisa identità in memoria RAM creata dal new.

// Io sono un VERO DenseLayer, in carne e byte!
DenseLayer* dl = new DenseLayer("Hidden1");

Ripasso: Il Superpotere dell’Upcasting

Nella Lezione 12 abbiamo visto la promozione magica: L’Upcasting (Cast verso l’alto). Ovvero la possibilità di far puntare un puntatore della Classe Padre (generico) all’oggetto Figlio (specifico).

1Layer* base_ptr = dl; // UPCASTING!

2base_ptr->printName();
1
Noto anche come Ampliamento Astrattivo. Il tipo del puntatore si allarga per abbracciare l’intera famiglia.
2
Posso usare solo i metodi definiti nella classe Layer, anche se in RAM c’è un DenseLayer.

Perché l’Upcasting è gratuito e sicuro?

Perché il compilatore C++ ci lascia fare Layer* p = new DenseLayer() senza battere ciglio?

Perché per la relazione “IS-A”, il compilatore ha la certezza matematica che la promozione sia sicura. Un DenseLayer “È-UN” Layer. Se il Padre ha la funzione getName(), il Figlio ce l’avrà al 100%, ereditata o sovrascritta! Non c’è alcun rischio che il puntatore base invochi una funzione inesistente nell’oggetto in RAM.

Costruire l’Array Eterogeneo

L’Upcasting è il trucco che ci permette di costruire il Vettore Polimorfico.

std::vector<Layer*> net;

// UPCASTING SILENZIOSO in ogni push_back!
net.push_back( new DenseLayer("L1") ); 
net.push_back( new DropoutLayer("L2") );

Grazie a questo, la nostra Rete Neurale vive felice, e con il Late Binding (virtual) sa eseguire le funzioni giuste.

Ma cosa succede quando dobbiamo fare l’operazione inversa?