Vai al contenuto

Strategie di gestione degli errori: scegliere l'approccio giusto

Confronta eccezioni, codici di errore e std::expected — scopri quando ciascun approccio è appropriato e come gestire gli errori nei costruttori, nei distruttori e nel codice multithread.

Non esiste una soluzione universale

Il C++ offre diversi meccanismi di gestione degli errori, ciascuno con compromessi differenti. Scegliere quello giusto dipende dal tuo dominio:

Le eccezioni sono la scelta migliore per errori rari e che non possono essere gestiti localmente. Hanno costo zero sul percorso di successo, ma sono costose quando vengono lanciate (migliaia di nanosecondi). Si propagano automaticamente attraverso i frame di chiamata.

I codici di errore (valori di ritorno, errno, std::error_code) sono la scelta migliore per errori previsti e frequenti. Hanno un costo piccolo e costante a ogni chiamata (chi chiama deve controllare il valore restituito). Sono lo standard nelle API C e nel codice a livello di kernel.

std::expected (C++23) combina il meglio dei due mondi: obbliga chi chiama a prendere atto dell'errore (come i codici di errore) ma trasporta un tipo di errore ricco (come le eccezioni) e supporta il concatenamento monadico. Sta rapidamente diventando l'approccio preferito nel nuovo codice C++.

L'idea chiave: non sono filosofie in competizione, sono strumenti per situazioni diverse, e il codice di produzione spesso li usa tutti e tre.

Confronto: eccezioni vs codici di errore vs std::expected

| Criterio | Eccezioni | Codici di errore | std::expected |
|---|---|---|---|
| Costo in caso di successo | Zero (basato su tabelle) | Salto + controllo | Minimo (memorizza il valore) |
| Costo in caso di errore | Alto (srotolamento) | Come in caso di successo | Come in caso di successo |
| Si possono ignorare? | No (il programma termina) | Sì (bug silenziosi) | Più difficile (bisogna accedere a .value()) |
| Costruttori | Unica opzione | Non possono restituire un valore | Non possono restituire un valore |
| Distruttori | Non devono lanciare | Vanno bene | Vanno bene |
| Componibilità | Limitata (try/catch) | Catene di if manuali | Monadica (and_then, transform) |
| Dimensione del binario | Maggiore (tabelle di unwinding) | Minima | Minima |
| Sicuri per il tempo reale? | No (tempo non limitato) | Sì | Sì |

Gestione degli errori nei costruttori

I costruttori non possono restituire codici di errore — non hanno un valore di ritorno. Le eccezioni sono l'unico meccanismo pulito per segnalare il fallimento della costruzione. Un'eccezione lanciata significa che l'oggetto non è mai stato costruito completamente, quindi il suo distruttore non viene mai chiamato (mentre vengono chiamati i distruttori dei sotto-oggetti e delle basi già costruiti).

constructor_error.cpp
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>

class ConfigFile {
public:
    explicit ConfigFile(const std::string& path)
        : path_(path), stream_(path) {
        if (!stream_.is_open()) {
            throw std::runtime_error(
                "Cannot open config file: " + path);
        }
        // Parse the file — if this throws, stream_ is
        // already constructed and its destructor will
        // close the file automatically (RAII)
        parse();
    }

    std::string get(const std::string& key) const {
        auto it = data_.find(key);
        if (it == data_.end()) {
            throw std::out_of_range("Key not found: " + key);
        }
        return it->second;
    }

private:
    void parse() {
        std::string line;
        while (std::getline(stream_, line)) {
            auto pos = line.find('=');
            if (pos != std::string::npos) {
                data_[line.substr(0, pos)] = line.substr(pos + 1);
            }
        }
    }

    std::string path_;
    std::ifstream stream_;  // RAII: closed by destructor
    std::map<std::string, std::string> data_;
};

// Usage: the object is fully valid or does not exist
int main() {
    try {
        ConfigFile cfg("/etc/myapp.conf");
        std::cout << cfg.get("port") << '\n';
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
}

std::error_code per gli errori a livello di sistema

std::error_code offre un meccanismo leggero e che non lancia per gli errori a livello di sistema. La libreria ne è un ottimo esempio — la maggior parte delle sue funzioni ha un overload che accetta un parametro std::error_code& invece di lanciare.

error_code.cpp
#include <filesystem>
#include <iostream>
#include <system_error>

namespace fs = std::filesystem;

// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
    fs::copy_file(src, dst);  // throws fs::filesystem_error on failure
}

// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
    std::error_code ec;
    fs::copy_file(src, dst, ec);  // sets ec instead of throwing
    if (ec) {
        std::cerr << "Copy failed: " << ec.message()
                  << " (" << ec.category().name()
                  << ':' << ec.value() << ")\n";
        return false;
    }
    return true;
}

int main() {
    // The error_code version is preferable in loops where
    // failure is expected (e.g., batch file processing)
    for (const auto& entry : fs::directory_iterator(".")) {
        std::error_code ec;
        auto sz = fs::file_size(entry.path(), ec);
        if (!ec) {
            std::cout << entry.path().filename()
                      << ": " << sz << " bytes\n";
        }
    }
}
Insidia

Dal C++11 in poi i distruttori sono implicitamente noexcept. Se un distruttore lancia, viene chiamata std::terminate(). Anche se marchi esplicitamente un distruttore come noexcept(false), lanciare durante lo srotolamento dello stack (quando un'altra eccezione è già attiva) comporta la chiamata a std::terminate().

  • Registra nel log e sopprimi: se un'operazione di pulizia fallisce, registra l'errore e prosegui
  • Fornisci un metodo close() separato: lascia che chi chiama gestisca esplicitamente gli errori di pulizia prima della distruzione
  • Progetta le risorse in modo che la pulizia non possa fallire: per esempio, chiudere un descrittore di file riesce sempre a livello di sistema operativo
  • Non chiamare mai da un distruttore una funzione che può lanciare senza racchiuderla in un try/catch

Gestione degli errori nel codice multithread

Le eccezioni non attraversano i confini tra thread. Se un thread lancia e l'eccezione non viene catturata all'interno di quel thread, viene chiamata std::terminate(). Per propagare le eccezioni tra thread:

1. Usa std::future e std::async: le eccezioni lanciate nel task asincrono vengono catturate e rilanciate quando chiami .get() sul future.
2. Usa std::exception_ptr insieme a std::current_exception() e std::rethrow_exception() per catturare e trasportare manualmente le eccezioni.
3. Usa std::promise::set_exception() per inviare un'eccezione da un thread produttore a un consumatore.

Nel codice concorrente ad alto throughput si preferiscono spesso i codici di errore o std::expected, perché evitano il costo dell'infrastruttura delle eccezioni in ogni thread e sono più semplici da ragionare attraverso i confini tra thread.

Buona pratica

Adatta il meccanismo di gestione degli errori alla situazione.

  • Usa le eccezioni per errori rari, che non possono essere gestiti localmente e che devono propagarsi lungo lo stack (ad es. file non trovato, database non raggiungibile)
  • Usa i codici di errore per operazioni ad alta frequenza in cui il fallimento è normale e previsto (ad es. cache miss, I/O non bloccante che si bloccherebbe)
  • Usa std::expected nel codice nuovo, quando vuoi una gestione esplicita degli errori con tipi di errore ricchi e composizione monadica
  • Usa le eccezioni nei costruttori — sono l'unico modo per segnalare un fallimento prima che l'oggetto esista
  • Non lanciare mai nei distruttori — registra l'errore nel log e sopprimilo
Concetti chiave
  • Eccezioni, codici di errore e std::expected sono strumenti complementari — non filosofie in competizione
  • I costruttori devono usare le eccezioni per segnalare un fallimento; i codici di errore non sono un'opzione
  • I distruttori non devono mai lanciare — dal C++11 sono implicitamente noexcept
  • Le eccezioni non attraversano i confini tra thread — usa std::future o std::exception_ptr per trasferirle
  • std::error_code è ideale per gli errori a livello di sistema, quando vuoi un'API che non lanci

Quiz — Metti alla prova le tue conoscenze

(15 XP)

1. Perché le eccezioni sono il meccanismo di gestione degli errori preferito nei costruttori?

2. Cosa succede se un'eccezione viene lanciata in un distruttore durante lo srotolamento dello stack provocato da un'altra eccezione?

3. Come si possono trasferire le eccezioni tra thread?