CodeGym /Corsi /C++ SELF /Eccezioni personalizzate — denominazione, messaggio e con...

Eccezioni personalizzate — denominazione, messaggio e contesto

C++ SELF
Livello 53, Lezione 3
Disponibile

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
ParseError
la stringa/comando non si può interpretare “formato non valido”
CommandError
la comando è sintatticamente ok ma logicamente impossibile “comando non valido nello stato corrente”
ValidationError
i dati violano le regole (vuoto/troppo lungo/tipo sbagliato) “input non valido”
NotFoundError
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 amount
  • ParseError: 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).

1
Compito
C++ SELF, livello 53, lezione 3
Bloccato
Controllo dell'età
Controllo dell'età
1
Compito
C++ SELF, livello 53, lezione 3
Bloccato
Token di parsing
Token di parsing
1
Compito
C++ SELF, livello 53, lezione 3
Bloccato
Due errori
Due errori
1
Compito
C++ SELF, livello 53, lezione 3
Bloccato
Configurazione del servizio
Configurazione del servizio
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION