Aller au contenu

Garanties de sûreté des exceptions

Apprenez les trois niveaux de sûreté d'exception — basique, forte et sans levée — et comment le RAII et le copy-and-swap les fournissent.

Les trois garanties

La sûreté des exceptions ne consiste pas à empêcher les exceptions — il s'agit de définir ce que votre code garantit lorsqu'une exception se produit. Chaque fonction que vous écrivez fournit implicitement l'un de ces niveaux :

1. Garantie sans levée (la plus forte) : La fonction ne lève jamais. Elle réussit toujours. Exemples : destructeurs, swap, accesseurs simples. Marquée noexcept.

2. Garantie forte (commit-ou-rollback) : Si la fonction lève, l'état du programme est exactement celui d'avant l'appel. C'est comme si la fonction n'avait jamais été appelée. Pensez à une transaction de base de données qui soit valide complètement, soit annule complètement.

3. Garantie basique (minimum acceptable) : Si la fonction lève, le programme est dans un état valide mais non spécifié. Aucune ressource ne fuit, tous les invariants sont maintenus, mais l'état exact peut différer de celui d'avant.

Il existe aussi un quatrième niveau inacceptable : aucune garantie — où une exception peut laisser le programme dans un état corrompu avec des fuites de ressources. Le code de production ne doit jamais fournir moins que la garantie basique.

RAII : la fondation de la sûreté d'exception

Le RAII (Resource Acquisition Is Initialization) lie la durée de vie d'une ressource à la durée de vie d'un objet. Le constructeur acquiert la ressource ; le destructeur la libère. Puisque les destructeurs s'exécutent pendant le déroulement de pile, le RAII fournit automatiquement la garantie basique.

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 pour la garantie forte

L'idiome copy-and-swap fournit la garantie forte d'exception pour les opérateurs d'affectation. L'idée : faites d'abord une copie (qui peut lever), puis échangez avec la copie (qui ne lève jamais). Si la copie lève, l'objet original est intact.

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 et opérations de déplacement

La STL s'appuie fortement sur noexcept pour les constructeurs de déplacement. Considérez std::vector::push_back : quand le vector doit se réallouer, il doit transférer les éléments de l'ancien buffer vers le nouveau.

- Si le constructeur de déplacement de l'élément est noexcept, le vector déplace les éléments (rapide).
- Si le constructeur de déplacement peut lever, le vector copie les éléments à la place (lent, mais sûr — si une copie lève, le buffer original est toujours intact).

C'est pourquoi marquer les constructeurs de déplacement noexcept n'est pas juste une bonne pratique — cela peut représenter une différence de performance de 10x ou 100x pour les types stockés dans des conteneurs.

Vous pouvez vérifier à la compilation avec std::is_nothrow_move_constructible_v. La STL utilise ce trait en interne pour décider de sa stratégie.

Identifier le niveau de garantie

Quand vous écrivez une fonction, réfléchissez à quelle garantie elle fournit. Voici un exemple des trois niveaux dans une seule 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_;
};
Bonne pratique

Suivez ces directives pour écrire du code sûr vis-à-vis des exceptions de manière cohérente.

  • Utilisez le RAII partout : ne détenez jamais de ressources brutes — encapsulez-les dans des pointeurs intelligents ou des wrappers RAII
  • Marquez les constructeurs de déplacement et swap noexcept : cela active les optimisations STL et fournit la garantie sans levée
  • Faites tout le travail pouvant lever avant de modifier l'état : allouez, calculez et validez d'abord — puis validez avec des opérations sans levée comme swap
  • Préférez l'idiome copy-and-swap pour les opérateurs d'affectation afin d'obtenir la garantie forte avec un effort minimal
  • Les destructeurs ne doivent jamais lever : ils sont implicitement noexcept depuis C++11
Piège

Ces patrons brisent les garanties de sûreté d'exception et mènent à des bugs subtils.

  • Modifier l'état avant d'avoir terminé toutes les opérations pouvant lever : si l'étape 2 sur 3 lève, l'étape 1 a déjà changé l'objet
  • Oublier que new peut lever std::bad_alloc : chaque allocation dynamique est un point de levée potentiel
  • Les constructeurs de déplacement non-noexcept : la STL se rabattra silencieusement sur la copie, tuant les performances
  • Attraper les exceptions trop largement : catch(...) sans relever masque le vrai problème
Points clés
  • Les trois garanties sont : sans levée (réussit toujours), forte (commit-ou-rollback) et basique (état valide mais non spécifié)
  • Le RAII est la fondation — les destructeurs s'exécutent pendant le déroulement, assurant que les ressources sont toujours libérées
  • Le copy-and-swap donne la garantie forte : faites tout le travail pouvant lever d'abord, puis swap (qui est noexcept)
  • Les constructeurs de déplacement noexcept sont critiques — sans eux, std::vector se rabat sur la copie lors de la réallocation
  • Chaque fonction devrait viser au moins la garantie basique ; sans levée quand c'est possible

Quiz — Testez vos connaissances

(20 XP)

1. Que promet la garantie forte d'exception ?

2. Pourquoi `std::vector` copie-t-il les éléments au lieu de les déplacer quand le constructeur de déplacement n'est pas `noexcept` ?

3. Dans l'idiome copy-and-swap, pourquoi l'opérateur d'affectation prend-il son paramètre par valeur ?