Vai al contenuto

Garanzie di sicurezza rispetto alle eccezioni

Impara i tre livelli di sicurezza rispetto alle eccezioni — di base, forte e no-throw — e come il RAII e il copy-and-swap li forniscono.

Le tre garanzie

La sicurezza rispetto alle eccezioni non consiste nell'impedire le eccezioni: consiste nel definire cosa il tuo codice garantisce quando un'eccezione si verifica davvero. Ogni funzione che scrivi fornisce implicitamente uno di questi livelli:

1. Garanzia no-throw (la più forte): la funzione non lancia mai. Ha sempre successo. Esempi: distruttori, swap, semplici getter. Si dichiara con noexcept.

2. Garanzia forte (commit-or-rollback): se la funzione lancia, lo stato del programma è esattamente quello di prima della chiamata. È come se la funzione non fosse mai stata chiamata. Pensa a una transazione di database che o viene confermata del tutto o viene annullata del tutto.

3. Garanzia di base (il minimo accettabile): se la funzione lancia, il programma si trova in uno stato valido ma non specificato. Nessuna risorsa viene persa e tutti gli invarianti valgono, ma lo stato esatto può differire da quello precedente.

Esiste anche un quarto livello, inaccettabile: nessuna garanzia — in cui un'eccezione può lasciare il programma in uno stato corrotto con risorse perse. Il codice di produzione non deve mai offrire meno della garanzia di base.

RAII: il fondamento della sicurezza rispetto alle eccezioni

Il RAII (Resource Acquisition Is Initialization) lega la durata di vita di una risorsa alla durata di vita di un oggetto. Il costruttore acquisisce la risorsa, il distruttore la rilascia. Poiché i distruttori vengono eseguiti durante lo srotolamento dello stack, il RAII fornisce automaticamente la garanzia di base.

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

class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& conn_str)
        : conn_str_(conn_str), connected_(false) {
        // Simulate connection
        if (conn_str.empty()) {
            throw std::invalid_argument("empty connection string");
        }
        connected_ = true;
        std::cout << "Connected to: " << conn_str_ << '\n';
    }

    ~DatabaseConnection() {
        if (connected_) {
            // Cleanup never throws
            std::cout << "Disconnected from: " << conn_str_ << '\n';
            connected_ = false;
        }
    }

    void execute(const std::string& query) {
        if (!connected_) throw std::runtime_error("not connected");
        // Even if this function throws, the destructor
        // will still close the connection during unwinding
        std::cout << "Executing: " << query << '\n';
    }

    // Non-copyable, movable
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;
    DatabaseConnection(DatabaseConnection&& other) noexcept
        : conn_str_(std::move(other.conn_str_)),
          connected_(other.connected_) {
        other.connected_ = false;
    }

private:
    std::string conn_str_;
    bool connected_;
};

void process_data() {
    DatabaseConnection db("postgres://localhost/mydb");
    db.execute("SELECT * FROM users");
    // If execute() throws, ~DatabaseConnection() still runs
    // — the connection is always properly closed
}

Copy-and-swap per la garanzia forte

L'idioma copy-and-swap fornisce la garanzia forte agli operatori di assegnazione. L'idea: prima si fa una copia (operazione che può lanciare), poi la si scambia con l'originale (operazione che non lancia mai). Se la copia lancia, l'oggetto originale resta intatto.

copy_and_swap.cpp
#include <algorithm>
#include <utility>
#include <cstddef>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols),
          data_(new double[rows * cols]{}) {}

    // Copy constructor — may throw (allocates memory)
    Matrix(const Matrix& other)
        : rows_(other.rows_), cols_(other.cols_),
          data_(new double[other.rows_ * other.cols_]) {
        std::copy(other.data_,
                  other.data_ + rows_ * cols_,
                  data_);
    }

    // Move constructor — never throws
    Matrix(Matrix&& other) noexcept
        : rows_(other.rows_), cols_(other.cols_),
          data_(other.data_) {
        other.data_ = nullptr;
        other.rows_ = other.cols_ = 0;
    }

    ~Matrix() { delete[] data_; }

    // No-throw swap — the key ingredient
    friend void swap(Matrix& a, Matrix& b) noexcept {
        using std::swap;
        swap(a.rows_, b.rows_);
        swap(a.cols_, b.cols_);
        swap(a.data_, b.data_);
    }

    // Copy-and-swap assignment: STRONG guarantee
    // Takes parameter by value (invokes copy/move ctor)
    Matrix& operator=(Matrix other) noexcept {
        swap(*this, other);
        return *this;
        // 'other' is destroyed here, freeing old data
    }

private:
    std::size_t rows_, cols_;
    double* data_;
};

noexcept e le operazioni di spostamento

La STL si affida moltissimo a noexcept per i costruttori di spostamento. Considera std::vector::push_back: quando il vector deve riallocare, deve trasferire gli elementi dal vecchio buffer a quello nuovo.

- Se il costruttore di spostamento dell'elemento è noexcept, il vector sposta gli elementi (veloce).
- Se il costruttore di spostamento potrebbe lanciare, il vector copia invece gli elementi (lento, ma sicuro — se una copia lancia, il buffer originale è ancora intatto).

Ecco perché marcare noexcept i costruttori di spostamento non è solo buona pratica: può significare una differenza di prestazioni di 10x o 100x per i tipi conservati nei contenitori.

Puoi verificarlo a tempo di compilazione con std::is_nothrow_move_constructible_v. La STL usa internamente questo trait per decidere la propria strategia.

Riconoscere il livello di garanzia

Quando scrivi una funzione, chiediti quale garanzia offre. Ecco un esempio con tutti e tre i livelli in un'unica classe.

guarantee_levels.cpp
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>

class UserRegistry {
public:
    // NO-THROW: simple getter, can never fail
    std::size_t size() const noexcept {
        return users_.size();
    }

    // STRONG GUARANTEE: either the user is added or nothing changes
    void add_user(const std::string& name) {
        if (name.empty()) {
            throw std::invalid_argument("name cannot be empty");
        }
        // If push_back throws (e.g., bad_alloc during reallocation),
        // vector guarantees the original data is intact.
        users_.push_back(name);
    }

    // BASIC GUARANTEE: if an exception occurs partway through,
    // some users may have been processed and some may not.
    // But the object is still in a valid state (no leaks, no corruption).
    void normalize_all() {
        for (auto& user : users_) {
            // std::transform could theoretically throw
            // (e.g., if user string allocation fails during tolower)
            std::transform(user.begin(), user.end(), user.begin(),
                          [](unsigned char c) { return std::tolower(c); });
        }
    }

private:
    std::vector<std::string> users_;
};
Buona pratica

Segui queste linee guida per scrivere in modo coerente codice sicuro rispetto alle eccezioni.

  • Usa il RAII ovunque: non tenere mai risorse grezze — incapsulale in smart pointer o wrapper RAII
  • Marca noexcept i costruttori di spostamento e swap: questo abilita le ottimizzazioni della STL e fornisce la garanzia no-throw
  • Svolgi tutto il lavoro che può lanciare prima di modificare lo stato: prima alloca, calcola e valida — poi conferma con operazioni no-throw come swap
  • Preferisci l'idioma copy-and-swap per gli operatori di assegnazione: ottieni la garanzia forte con il minimo sforzo
  • I distruttori non devono mai lanciare: dal C++11 in poi sono implicitamente noexcept
Insidia

Questi pattern violano le garanzie di sicurezza rispetto alle eccezioni e portano a bug sottili.

  • Modificare lo stato prima di aver completato tutte le operazioni che possono lanciare: se il passo 2 di 3 lancia, il passo 1 ha già modificato l'oggetto
  • Dimenticare che new può lanciare std::bad_alloc: ogni allocazione dinamica è un potenziale punto di lancio
  • Costruttori di spostamento non noexcept: la STL ripiegherà silenziosamente sulla copia, distruggendo le prestazioni
  • Catturare le eccezioni in modo troppo ampio: un catch(...) senza rilancio nasconde il vero problema
Concetti chiave
  • Le tre garanzie sono: no-throw (ha sempre successo), forte (commit-or-rollback) e di base (stato valido ma non specificato)
  • Il RAII è il fondamento — i distruttori vengono eseguiti durante lo srotolamento, assicurando che le risorse siano sempre liberate
  • Copy-and-swap offre la garanzia forte: prima tutto il lavoro che può lanciare, poi swap (che è noexcept)
  • I costruttori di spostamento noexcept sono fondamentali — senza di essi std::vector ripiega sulla copia durante la riallocazione
  • Ogni funzione dovrebbe puntare almeno alla garanzia di base, e alla no-throw dove possibile

Quiz — Metti alla prova le tue conoscenze

(20 XP)

1. Che cosa promette la garanzia forte rispetto alle eccezioni?

2. Perché `std::vector` copia gli elementi invece di spostarli quando il costruttore di spostamento non è `noexcept`?

3. Nell'idioma copy-and-swap, perché l'operatore di assegnazione prende il parametro per valore?