1. Perché servono eccezioni proprie e quando runtime_error non basta
Quando si comincia, viene voglia di fare così: “Oh, errore — lancio std::runtime_error("qualcosa è andato storto")”. E non è... terribile. È persino meglio che “restituire -1 e fare finta che sia successo”. Ma non appena la vostra applicazione diventa un po' più vivace, si scopre che un solo testo non è sufficiente: volete distinguere un errore di parsing del comando da un errore di business logic, e non trovato da formato non valido.
Le eccezioni personalizzate non servono per estetica né perché “OOP è tutto”. Servono affinché il tipo di errore diventi parte del contratto: il codice in livelli superiori può capire la categoria di errore dal tipo invece di cercare di parsare il testo del messaggio (sarebbe… ironico: parsare gli errori del parser).
Immaginate di scrivere una piccola applicazione da console “conto spese” (svilupperemo un esempio). L’utente inserisce un comando:
add 250 coffee
E poi all’improvviso inserisce:
add -999999999999999999999 coffee
O:
add 250
O:
remove 10
In un caso il problema è il numero, in un altro la mancanza di argomenti, in un terzo l'id sbagliato. Sono errori diversi e talvolta si vogliono gestire in modo diverso: in un punto suggerire il formato, altrove dire “nessuna voce con quell'id”, e altrove “numero troppo grande”.
2. Denominazione: come scegliere le categorie
Quando inventate il nome di un’eccezione, avete due obiettivi: renderla comprensibile a una persona e utile per il codice. Chiamare un’eccezione MyException è come chiamare una variabile x2: formalmente valido, ma di utilità pratica pari a un ombrello di garza.
Di solito i nomi delle eccezioni si costruiscono intorno alla categoria dell’errore. La categoria risponde alla domanda: “Che cosa non è andato per il verso giusto, a livello di significato?”.
Buone categorie “operative” per un semplice CLI didattico sono: errori di parsing del comando, errori di validazione dell’input, errori di logica di dominio. Potete usare il suffisso Error o Exception. In C++ si vede più spesso Error nel codice applicativo e ..._error/..._argument nella libreria standard. L’importante è essere coerenti.
Ecco una piccola tabella (non una regola ferrea, ma una “mappa”):
| Nome | Quando è appropriato | Cosa capisce il lettore senza contesto |
|---|---|---|
|
la stringa/comando non si può interpretare | “formato non valido” |
|
la comando è sintatticamente ok ma logicamente impossibile | “comando non valido nello stato corrente” |
|
i dati violano le regole (vuoto/troppo lungo/tipo sbagliato) | “input non valido” |
|
elemento non trovato | “cercato ma non trovato” |
Trappola comune: denominare l’eccezione per il luogo invece che per il significato. Per esempio, LedgerError (“errore del registro contabile”). Può andar bene come tipo base, ma per i casi concreti è meglio TransactionNotFound, InvalidAmount, ParseError. Il luogo (modulo) cambia spesso, il significato dell’errore è più stabile.
3. Da chi ereditare: std::runtime_error vs std::exception
A questo punto voglio dare una ricetta pratica senza filosofia in tre volumi. In C++ si può lanciare qualunque cosa, ma se volete interoperare con altro codice e i gestori standard, conviene essere compatibili con std::exception, in modo che funzioni what() e un catch (const std::exception&) catturi tutto.
Ci sono due percorsi comuni: ereditare direttamente da std::exception e implementare what(), oppure ereditare da std::runtime_error (o un altro tipo da <stdexcept>) e passare semplicemente il messaggio al costruttore base.
Nei progetti didattici e nella maggior parte dei casi applicativi è più comodo ereditare da std::runtime_error. Questo fornisce storage per il messaggio e un what() corretto “out of the box”, e voi aggiungete solo ciò che serve: campi di contesto, metodi di accesso aggiuntivi.
Ereditare da std::exception va bene comunque, ma in quel caso dovrete gestire voi la stringa e garantire che what() restituisca un puntatore a memoria che viva abbastanza a lungo. Altrimenti otterrete il classico problema: “what() ha restituito il puntatore a una stringa che non esiste più”. È simile a “restituire una reference a una variabile locale”, ma nel mondo delle eccezioni — la sofferenza arriva all’improvviso e nel momento meno opportuno.
4. Messaggio dell’eccezione: cosa scrivere in what()
Si vorrebbe scrivere un messaggio tipo "Error" o "Bad input". Questi messaggi sembrano dire “so che è sbagliato, ma non dirò perché”. Poi vi ritrovate a pensare di notte: “perché è sbagliato?” — e C++ tace, perché glielo avete chiesto voi.
Il messaggio dell’eccezione dovrebbe rispondere almeno a tre domande: cosa si stava facendo, perché non è riuscito, qual è il parametro/pezzo chiave che ha causato il problema. Idealmente è breve ma concreto. Immaginate che lo legga una persona nei log, senza accesso al vostro cervello (sì, anche voi dopo due settimane sarete “una persona estranea”).
Un modello semplice:
<categoria>: <operazione> failed: <motivo> (contesto)
Esempi:
ParseError: command failed: missing amountParseError: command failed: amount is not a number: '12x'CommandError: remove failed: transaction id not found: 10
Nota: non insultiamo l’utente. Scriviamo fattualmente. Anche il compilatore non insulta: scrive expected ';' — e questo è già sufficiente per far arrabbiare.
5. Contesto: quando servono campi e non solo testo
A volte il messaggio è sufficiente. Altre volte volete non solo stampare l’errore ma, per esempio, evidenziare la posizione nella stringa (se ci fosse una UI), oppure mostrare separatamente il nome del campo, o passare in alto l’id della voce non trovata. Se nascondete tutto in una stringa, non riuscirete poi a “estrarre” quei dati senza parsare il testo (e abbiamo deciso di non farlo).
Qui nasce l’idea: l’eccezione è un oggetto normale. Può avere campi. Potete salvare, per esempio, field, token, position, id. Il messaggio resta per la persona, i campi per il codice.
Uno schema di come evolve un errore nell’app:
flowchart TD
A[L'utente ha inserito la stringa del comando] --> B[Il parser scompone i token]
B -->|errore di formato| E[throw ParseError]
B --> C[Costruita la command/data]
C --> D[La business logic esegue il comando]
D -->|errore di significato| F[throw CommandError]
E --> G[main cattura e stampa]
F --> G[main cattura e stampa]
Idea principale: il parser lancia una cosa, la logica di dominio un’altra, e il livello superiore (main) decide cosa mostrare all’utente.
6. Esempio pratico: mini-app “Conto spese”
Proseguiremo sviluppando un unico esempio. Supponiamo di avere comandi semplici:
- add <amount> <title>
- remove <id>
- list
Per ora non usiamo file, JSON o altre comodità future. Tutto vive in memoria, per non distrarci dal tema delle eccezioni.
Modello e storage
Iniziamo dal modello e dallo storage (minimo):
#include <string>
struct Expense {
int id{};
int amount{};
std::string title{};
};
Ora facciamo il “registro spese”:
#include <vector>
#include <string>
class Ledger {
public:
void add(int amount, std::string title) {
expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
}
const std::vector<Expense>& all() const { return expenses_; }
private:
int next_id_{1};
std::vector<Expense> expenses_{};
};
Qui ancora non ci sono errori. Ma ne appariranno appena implementeremo remove(id) o la validazione di amount. Torneremo su questo più avanti, per ora concentriamoci sul parsing e sugli errori di formato.
7. ParseError: messaggio e campo di contesto
Creiamo il tipo ParseError. Vogliamo poterlo catturare come std::exception, che abbia un messaggio leggibile e che contenga un campo input_ o field_ — per esempio cosa stavamo tentando di leggere.
Ereditiamo da std::runtime_error:
#include <stdexcept>
#include <string>
#include <utility>
class ParseError : public std::runtime_error {
public:
ParseError(std::string input, std::string message)
: std::runtime_error("ParseError: " + message + " | input='" + input + "'")
, input_(std::move(input)) {}
const std::string& input() const { return input_; }
private:
std::string input_;
};
Ci sono diversi punti importanti. Costruiamo un messaggio leggibile una sola volta nel costruttore, e sarà disponibile tramite what(). Nel contempo la stringa originale input_ è memorizzata separatamente, per poterla usare senza dipendere dal testo dell’errore.
Cosa considerare “contesto”
Il contesto non è “tutto ciò che sai del mondo”. È un piccolo insieme di dati che aiuta a capire l’errore o a decidere cosa fare. Per il parsing di comandi è utile spesso avere command_name, field, token, position. Per la logica di dominio sono più utili id, amount, limit, state.
Contesto pessimo è un oggetto gigantesco da 2 megabyte o un riferimento a un oggetto che morirà presto. Le eccezioni dovrebbero essere relativamente leggere. Ricordate: un’eccezione non è un contenitore per l’intero database, ma una “busta” con la causa del fallimento.
Se avete dubbi, mettete nell’eccezione solo ciò che è facile stampare o mostrare all’utente: stringhe corte, numeri, piccole strutture.
8. Parsing e normalizzazione degli errori
Parsing della prima parola
Facciamo una funzione che legge la prima “parola” della stringa. Per ora non usiamo std::stringstream (lo vedremo in un’altra lezione), quindi useremo un semplice parsing con find.
#include <string>
std::string first_word(const std::string& s) {
auto pos = s.find(' ');
if (pos == std::string::npos) return s;
return s.substr(0, pos);
}
parse_amount_or_throw e traduzione delle eccezioni in ParseError
Scriviamo ora la funzione parse_add_amount, che si aspetta il comando add <amount> <title>. La renderemo volutamente “un po' severa”: se il formato non è corretto lanciamo ParseError.
#include <string>
#include <stdexcept>
int parse_amount_or_throw(const std::string& token, const std::string& input) {
if (token.empty()) {
throw ParseError{input, "missing amount"};
}
try {
return std::stoi(token);
} catch (const std::exception&) {
throw ParseError{input, "amount is not a number: '" + token + "'"};
}
}
Qui è importante che catturiamo “qualcosa di standard” e lo trasformiamo nella nostra errore, così gli strati superiori vedranno proprio ParseError. Non discutiamo adesso i dettagli delle eccezioni lanciate da stoi — l’idea è “catturare e normalizzare”.
Nota: dentro il catch lanciamo una nuova eccezione. È una pratica normale quando volete tradurre un errore di basso livello in una categoria comprensibile dalla vostra applicazione.
Arricchimento e rilancio
Nelle lezioni precedenti avete visto throw; per rilanciare. Ricordate la differenza: throw; rilancia la stessa eccezione, conservando l’oggetto. Questo è legato al fatto che l’oggetto eccezione è accessibile dal gestore, e lo standard descrive l’osservazione dell’oggetto tramite la dichiarazione dell’handler.
Oggi ci interessa il caso pratico: catturiamo un errore “grezzo” e lanciamo uno “normalizzato” — con il nostro tipo e messaggio. Così negli strati superiori è più semplice costruire l’UX: abbiamo una categoria chiara e un testo prevedibile.
Una strategia comune: il livello basso lancia eccezioni standard o proprie piccole, il livello intermedio le converte in tipi più “semantici”, e il livello superiore stampa. L’importante è non stampare ad ogni livello, altrimenti otterrete tre messaggi identici e l’impressione che il programma sia “bloccato”.
9. Errore di dominio e gestione in main
CommandError per la logica, non per il formato
Facciamo ora una seconda eccezione, non legata al parsing ma all’azione. Per esempio, la rimozione per id.
#include <stdexcept>
#include <string>
class CommandError : public std::runtime_error {
public:
explicit CommandError(std::string message)
: std::runtime_error("CommandError: " + std::move(message)) {}
};
Ledger::remove lancia CommandError se l'id non è trovato
Aggiungiamo remove in Ledger. Lo faremo lanciare CommandError se lo id non viene trovato.
#include <algorithm>
#include <vector>
class Ledger {
public:
void add(int amount, std::string title) {
expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
}
void remove(int id) {
auto it = std::find_if(expenses_.begin(), expenses_.end(),
[id](const Expense& e){ return e.id == id; });
if (it == expenses_.end()) {
throw CommandError{"expense id not found: " + std::to_string(id)};
}
expenses_.erase(it);
}
const std::vector<Expense>& all() const { return expenses_; }
private:
int next_id_{1};
std::vector<Expense> expenses_{};
};
Nota importante: il tipo di errore riflette il significato. ParseError dice “non riesco a capire il comando”, mentre CommandError dice “ho capito il comando ma non posso eseguirlo”.
Punto di gestione: catturiamo in main e stampiamo what()
Ora assemblaremo il mini-ciclo di lettura. Leggeremo le righe con std::getline, così i comandi possono contenere spazi nel titolo.
#include <iostream>
#include <string>
int main() {
Ledger ledger;
std::cout << "Expense CLI. Type 'exit'.\n";
for (std::string line; std::getline(std::cin, line);) {
if (line == "exit") break;
try {
// qui in seguito ci sarà il parsing dei comandi
std::cout << "got: " << line << '\n';
} catch (const ParseError& e) {
std::cout << e.what() << '\n';
} catch (const CommandError& e) {
std::cout << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "Unexpected error: " << e.what() << '\n';
}
}
}
Perché catturiamo separatamente ParseError e CommandError? Perché domani (o anche tra un’ora) vorrete stamparli diversamente: per ParseError mostrare il suggerimento del formato, per CommandError — semplicemente “non trovato”.
E sì: catturiamo con const&. Pratica standard: così non copiate l’oggetto eccezione e non perdete il polimorfismo.
Suggerimenti UX e what(): non trasformare il messaggio in un romanzo
È allettante mettere tutto nel messaggio: input completo, suggerimento, esempio e citazione motivazionale. Ma what() è un messaggio diagnostico, deve essere sufficientemente breve. Se volete spiegare come fare, fatelo nel codice del gestore, non sigillate tutto dentro what().
Per esempio, per ParseError potete stampare what(), e poi in una riga separata mostrare l’help:
catch (const ParseError& e) {
std::cout << e.what() << '\n';
std::cout << "Hint: add <amount> <title> | remove <id> | list\n";
}
Così separate “diagnostica” e “UX”. L’eccezione informa sul fatto dell’errore, lo strato UI decide come aiutare l’utente.
10. Errori tipici
Errore n.1: lanciare std::string o un int e poi catturare “qualsiasi cosa”.
Tecnicamente C++ permette di lanciare quasi qualsiasi tipo, ma così perdete il punto comune di compatibilità (std::exception) e la diagnosi normale via what(). Di conseguenza i gestori si sparpagliano nel codice, e in main appare uno zoo di catch (std::string), catch (int) e “giusto nel caso catch (...)”.
Errore n.2: usare un unico tipo di eccezione per tutto (“AppError”) senza categorie semantiche.
Se tutti gli errori sono dello stesso tipo, il tipo smette di essere utile. Tornerete a distinguere le cause dal testo, e il testo serve alle persone, non alla logica. Meglio avere qualche categoria piccola: almeno ParseError e CommandError, e poi estendere solo quando serve davvero.
Errore n.3: costruire what() da una stringa temporanea e restituire un puntatore “volante”.
Se ereditate da std::exception e implementate what() a mano, non potete restituire c_str() di un oggetto temporaneo. Il messaggio deve essere memorizzato in un campo (per es. std::string msg_), così la memoria vive quanto l’oggetto eccezione.
Errore n.4: appesantire l’eccezione con troppo contesto e trasformarla in un “camion”.
Un’eccezione non dovrebbe trascinarsi metà stato del programma. Mettete dentro dati piccoli, economici e utili: id, nome del campo, posizione, token breve. Il resto è meglio loggarlo o gestirlo altrove.
Errore n.5: stampare l’errore a ogni livello e ottenere un “echo” di tre messaggi identici.
Funzione di basso livello lancia, livello intermedio cattura e stampa, livello superiore cattura e stampa di nuovo — e l’utente vede lo stesso problema più volte. Molto più pulito: i livelli bassi o rilanciano o convertono il tipo, la stampa avviene in un punto scelto (di solito vicino a main).
GO TO FULL VERSION