Gestione degli Errori — Eccezioni (Parte 1)
throw, try, catch<stdexcept>)
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.
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.
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.
L’80% del codice diventa “Gestione Errori”, sommergendo la vera logica matematica del programma. Ma non è l’unico problema…
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.
Senza un sistema superiore, l’oggetto verrebbe costruito in uno stato “corrotto” e l’intero programma calcolerebbe reti neurali sbagliate.
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’Eccezione funziona letteralmente come l’allarme antincendio di una scuola.
throw): Siete nel Laboratorio di Chimica al terzo piano. Scoppia un incendio. Tirate la levetta dell’allarme (Fate throw di un’eccezione).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).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.
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;
}throw ferma la funzione all’istante. Il return sottostante non verrà mai eseguito. In questo caso pessimo, stiamo lanciando una semplice stringa.
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";
}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.
Il C++ “riavvolge” in millisecondi A, B e C per cadere direttamente nel main.
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!
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!
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.
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.
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.