Programmazione Scientifica++: Lezione 19

Gestione degli Errori — Eccezioni (Parte 1)

Docente

Argomenti di Oggi

  • Il Crollo del Codice “Felice”
    • Quando il mondo reale distrugge il tuo programma
  • I Limiti del C-Style (Codici di Ritorno)
    • Il grande paradosso dei Costruttori
  • La Soluzione Moderna: Eccezioni
    • L’analogia dell’Allarme Antincendio
    • Le tre parole chiave: throw, try, catch
  • Stack Unwinding & RAII
    • L’incredibile super-potere della memoria in C++
  • La Gerarchia Standard (<stdexcept>)
    • Applicazioni pratiche per il Machine Learning

I Disastri del Codice “Felice”

Finora abbiamo scritto codice assumendo che tutto andasse per il meglio. Abbiamo finto di vivere in un mondo “Felice”.

In quel mondo felice, i Dataset si trovano sempre nel disco fisso. Gli utenti inseriscono sempre numeri positivi per i pesi neurali. Le matrici hanno sempre dimensioni compatibili per la moltiplicazione. La divisione non fa mai la media su zero campioni.

Il mondo reale non è felice. I file spariscono, gli utenti inseriscono lettere al posto di numeri, i server non rispondono. Un buon ingegnere del software deve preparare il codice a sopravvivere al peggio.

Il Vecchio Stile: Return Codes

Negli anni ’70 e ’80 (con il linguaggio C), il modo standard per avvisare il sistema che qualcosa era andato storto era usare un “Codice di Ritorno”.

Per convenzione, una funzione restituiva un numero utile se tutto andava bene, e un numero negativo (di solito -1) per segnalare un’anomalia.

int leggiPixel(const char* filepath) {
    FILE* f = fopen(filepath, "r");
    
    // Controlliamo il codice di ritorno
    if (f == NULL) {
        return -1; // -1 significa "File non trovato"
    }
    
    // ... lettura ...
    return 255; // Valore del pixel
}

Perché i Return Codes sono un incubo

A prima vista sembra logico. Ma su larga scala, distrugge la leggibilità del software. Ogni singola riga di codice deve essere avvolta da un if di controllo.

int main() {
    int p1 = leggiPixel("dati1.csv");
    if (p1 == -1) { /* gestisci l'errore */ return -1; }
    
    int p2 = elaboraDati(p1);
    if (p2 == -2) { /* gestisci l'errore 2 */ return -1; }
    
    int res = salvaRisultato(p2);
    if (res == -3) { /* gestisci l'errore 3 */ return -1; }
}

L’80% del codice diventa “Gestione Errori”, sommergendo la vera logica matematica del programma. Ma non è l’unico problema…

Il Paradosso dei Costruttori

Warning

IL PROBLEMA FATALE DELLA PROGRAMMAZIONE AD OGGETTI

Come si segnala un errore in un Costruttore? Un Costruttore, per definizione, NON HA un tipo di ritorno.

class DenseLayer {
public:
    // Nessun "int" o "bool" prima di DenseLayer!
    DenseLayer(int n_neuroni) {
        if (n_neuroni <= 0) {
            // COSA FACCIO QUI? Non posso fare "return -1"!
        }
    }
};

Senza un sistema superiore, l’oggetto verrebbe costruito in uno stato “corrotto” e l’intero programma calcolerebbe reti neurali sbagliate.

La Soluzione: Le Eccezioni

throw lo usate dal Laboratorio 05 (Lezione 10) per proteggere Matrix. Oggi apriamo il cofano: cosa succede davvero fra il throw e il catch.

Il C++ ha risolto questo problema ideando le Eccezioni (Exceptions). Un’Eccezione è un evento anomalo che interrompe istantaneamente la normale esecuzione sequenziale del codice, per trasferire il controllo a un blocco speciale (scritto da noi) in grado di gestire il disastro.

Pensate a una banale operazione matematica: l’estrazione di radice quadrata di un numero negativo. Nessun numero reale può essere restituito. Si lancia un’eccezione.

L’Analogia dell’Allarme Antincendio

L’Eccezione funziona letteralmente come l’allarme antincendio di una scuola.

  1. Il Pericolo (throw): Siete nel Laboratorio di Chimica al terzo piano. Scoppia un incendio. Tirate la levetta dell’allarme (Fate throw di un’eccezione).
  2. L’Evacuazione (Stack Unwinding): Tutti smettono istantaneamente di fare quello che stavano facendo. Nessuno finisce gli esperimenti. Si corre fuori seguendo un protocollo ordinato.
  3. L’Ambulanza (catch): Nel cortile della scuola ci sono dei medici in attesa (il blocco catch). Se sanno curare le ustioni, vi salvano. Se l’ambulanza non c’è, o non sa come trattare il problema, l’intero programma muore sul colpo (std::terminate).

Il Trittico Magico: throw, try, catch

L’infrastruttura si basa su tre parole chiave fondamentali:

  • throw (Lancia): Il verbo usato per innescare fisicamente l’allarme. Si “lancia” un oggetto che descrive l’errore.
  • try (Prova): Si avvolge una porzione di codice in questo blocco. Significa: “Prova a eseguire questo codice. Se scatta l’allarme, intercettalo”.
  • catch (Cattura): Immediatamente dopo il blocco try, ci deve essere l’ambulanza. Specifica quale tipo di allarme sa gestire e cosa fare per rimediare.

Vediamo la sintassi base in azione.

Sintassi Base: Lanciare un Errore

Nella Lezione 18 abbiamo visto l’orribile bug della std::map quando si cerca una chiave inesistente. Scriviamo una nostra funzione sicura per leggere gli iperparametri:

#include <map>
#include <string>

// Una mappa finta per l'esempio
std::map<std::string, double> configurazione;

double getParametro(std::string chiave) {
    auto it = configurazione.find(chiave);
    
    if (it == configurazione.end()) {
        // LA CHIAVE NON ESISTE! ALARME ANTINCENDIO!
1        throw "Parametro Inesistente!";
    }
    
    return it->second;
}
1
La parola chiave throw ferma la funzione all’istante. Il return sottostante non verrà mai eseguito. In questo caso pessimo, stiamo lanciando una semplice stringa.

Sintassi Base: Catturare l’Errore

Cosa succede nel main che ha invocato la funzione? Se non mettiamo il blocco try-catch, il sistema operativo terminerà bruscamente il programma stampando un sinistro messaggio (“terminate called after throwing an instance of…”).

Per evitarlo:

#include <iostream>

int main() {
1    try {
        std::cout << "Avvio calcolo...\n";
        double lr = getParametro("LearningRateX"); // Sbagliato!
2        std::cout << "Il LR e': " << lr << "\n";
    }
3    catch (const char* errore) {
        std::cout << "Allarme catturato: " << errore << "\n";
    }
    std::cout << "Programma sopravvissuto!\n";
}
1
L’area protetta.
2
Riga ignorata! L’esecuzione salta via al momento del throw.
3
Il blocco che gestisce l’emergenza, recuperando la stringa lanciata.

Il Flusso di Esecuzione (Salto nel Vuoto)

Una delle cose più difficili da assimilare è il “Teletrasporto” causato dal throw. A differenza di un if, l’eccezione salta a ritroso lungo tutta la catena delle funzioni chiamate (Call Stack), distruggendo l’ordine di esecuzione sequenziale.

void funzioneC() { throw 42; std::cout << "Mai stampata"; }
void funzioneB() { funzioneC(); std::cout << "Mai stampata"; }
void funzioneA() { funzioneB(); std::cout << "Mai stampata"; }

int main() {
    try {
        funzioneA();
    } catch (int errore) {
        std::cout << "Arrivato dal profondo: " << errore;
    }
}

Il C++ “riavvolge” in millisecondi A, B e C per cadere direttamente nel main.

Lo Stack Unwinding (Il Super-Potere del C++)

Come viene gestita la memoria durante questo gigantesco salto temporale all’indietro?

Qui brilla la vera unicità del C++. Durante la fuga per l’allarme antincendio (il percorso a ritroso chiamato tecnicamente Stack Unwinding), il compilatore non si fa prendere dal panico. Prima di uscire forzatamente da una funzione (C, poi B, poi A), esegue metodicamente e matematicamente TUTTI I DISTRUTTORI delle variabili locali allocate in quella funzione!

Come il C++ chiude ordinatamente le funzioni

Mettiamo una stampa per capirlo:

class Sentinella {
public:
    Sentinella()  { std::cout << "Creo\n"; }
    ~Sentinella() { std::cout << "Distruggo\n"; }
};

void calcoloRischioso() {
    Sentinella s; // Creata nello stack locale
    throw "BOOM!";
    std::cout << "Questa riga non verra' eseguita";
}

int main() {
    try { calcoloRischioso(); } 
    catch(...) { std::cout << "Salvi!\n"; }
}

L’output a terminale sarà infallibilmente: Creo -> Distruggo -> Salvi!

Unwinding e Puntatori Raw (Memory Leak!)

Nella Lezione 17 vi ho fatto una predica feroce contro l’uso del vecchissimo delete manuale. Vi avevo detto che un return prematuro causava leak distruttivi. Con le eccezioni, il problema è immensamente peggiore:

void disastroTotale() {
    // 1. Alloco GIGABYTE di pesi nello Heap. 
    // dl è solo un puntatore nello Stack, ma i dati sono nello Heap.
    DenseLayer* dl = new DenseLayer(1000000); 
    
    // 2. Qualcosa va storto durante l'allocazione interna o il calcolo
    calcolaMatrice(dl); // LANCIA UN'ECCEZIONE!
    
    // 3. Provo a pulire... ma questa riga NON SARÀ MAI RAGGIUNTA!
    delete dl; 
}

Lo Stack Unwinding distruggerà il piccolo puntatore dl, ma lascerà marcire per l’eternità i Gigabyte della rete nello Heap. Siete fritti.

Unwinding e Smart Pointers (RAII ti salva)

Come si risolve? Semplicemente usando il C++11! Se usate i concetti appresi finora (std::unique_ptr e RAII), il vostro codice è Exception-Safe (A prova di eccezione) in automatico, gratis, senza alcuno sforzo.

#include <memory>

void codicePerfetto() {
    // Il Layer è avvolto nel custode unico intelligente
    std::unique_ptr<DenseLayer> dl = std::make_unique<DenseLayer>(1000000);
    
    // Lancio dell'eccezione! Scatta l'allarme antincendio!
    calcolaMatrice(dl.get()); 
    
    // Non serve delete.
}

Durante la fuga, lo Stack Unwinding distrugge la variabile dl. Dato che dl è un oggetto intelligente con un suo Distruttore ben codificato, prima di morire farà delete dei Gigabyte di pesi per voi. Niente Leak.