Aller au contenu

RAII : le patron fondamental de gestion des ressources

Découvrez le RAII (Resource Acquisition Is Initialization, « l'acquisition d'une ressource est une initialisation ») — le patron central du C++ pour une gestion des ressources sûre vis-à-vis des exceptions. Implémentez des enveloppes RAII, comprenez lock_guard et scoped_lock, et apprenez pourquoi les types RAII doivent être déplaçables uniquement ou à comptage de références.

RAII : acquérir dans le constructeur, libérer dans le destructeur

Le RAII (Resource Acquisition Is Initialization) est le patron le plus important du C++. L'idée est simple :

1. Acquérir une ressource dans le constructeur (ouvrir un fichier, verrouiller un mutex, allouer de la mémoire, se connecter à une base de données).
2. Libérer la ressource dans le destructeur (fermer le fichier, déverrouiller le mutex, libérer la mémoire, se déconnecter).

Comme le C++ garantit que les destructeurs sont appelés lorsqu'un objet sort de portée — même si une exception est levée — le RAII rend la gestion des ressources automatique et sûre vis-à-vis des exceptions. Vous n'oubliez jamais de libérer une ressource, et vous ne la libérez jamais deux fois.

Tout type C++ bien conçu qui gère une ressource utilise le RAII : std::string, std::vector, std::unique_ptr, std::fstream, std::lock_guard, std::unique_lock.

Implémenter une enveloppe RAII

Voici une enveloppe RAII complète pour un descripteur de fichier (POSIX). Elle acquiert la ressource dans le constructeur, la libère dans le destructeur et prend en charge la sémantique de déplacement pour le transfert de propriété.

raii_file_descriptor.cpp
#include <unistd.h>   // open, close, read, write
#include <fcntl.h>    // O_RDONLY, etc.
#include <stdexcept>
#include <utility>
#include <iostream>

class FileDescriptor {
    int fd_ = -1;  // -1 = no resource
public:
    // Acquire: open file in constructor
    explicit FileDescriptor(const char* path, int flags)
        : fd_(::open(path, flags)) {
        if (fd_ < 0) throw std::runtime_error("Cannot open file");
    }

    // Release: close file in destructor
    ~FileDescriptor() {
        if (fd_ >= 0) ::close(fd_);
    }

    // Non-copyable (copying a file descriptor is meaningless)
    FileDescriptor(const FileDescriptor&) = delete;
    FileDescriptor& operator=(const FileDescriptor&) = delete;

    // Moveable (transfer ownership)
    FileDescriptor(FileDescriptor&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) {
            if (fd_ >= 0) ::close(fd_);
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    int get() const { return fd_; }
};

int main() {
    try {
        FileDescriptor fd("/tmp/raii_test.txt", O_RDONLY);
        std::cout << "File opened: fd=" << fd.get() << '\n';
        // Use fd...
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
    // fd.~FileDescriptor() called automatically — file closed
    // Even if an exception was thrown, the file is closed
}

La sûreté vis-à-vis des exceptions grâce au RAII

Sans RAII, un code sûr vis-à-vis des exceptions exige de fastidieux blocs try/catch pour chaque ressource. Avec le RAII, c'est automatique — chaque destructeur s'exécute pendant le déroulement de la pile.

exception_safety.cpp
#include <memory>
#include <mutex>
#include <fstream>
#include <vector>
#include <stdexcept>

std::mutex g_mutex;

// WITHOUT RAII — manual cleanup is error-prone
void bad_example() {
    std::mutex* m = &g_mutex;
    m->lock();
    int* data = new int[100];
    // If this throws, mutex stays locked AND data leaks!
    // process(data);
    delete[] data;
    m->unlock();
}

// WITH RAII — automatic, exception-safe cleanup
void good_example() {
    std::lock_guard<std::mutex> lock(g_mutex);  // RAII lock
    auto data = std::make_unique<int[]>(100);   // RAII allocation
    // If this throws:
    //   1. data's destructor frees the memory
    //   2. lock's destructor unlocks the mutex
    //   Stack unwinding handles everything
    // process(data.get());
}

// Multiple resources — RAII composes naturally
void multi_resource() {
    std::lock_guard<std::mutex> lock(g_mutex);
    auto buffer = std::make_unique<char[]>(4096);
    std::ofstream file("/tmp/output.txt");

    // If any operation throws, ALL resources are released
    // in reverse order of construction:
    //   1. file closed
    //   2. buffer freed
    //   3. mutex unlocked
}

lock_guard, unique_lock et scoped_lock

La bibliothèque standard fournit des enveloppes RAII pour les mutex :

- std::lock_guard — le verrou RAII le plus simple. Il verrouille dans le constructeur et déverrouille dans le destructeur. Ni déplaçable, ni copiable. À utiliser lorsque vous avez besoin d'un simple verrou limité à une portée.

- std::unique_lock — un verrou RAII flexible. Il permet le verrouillage différé, le verrouillage avec délai, le déverrouillage/reverrouillage manuel, et peut être déplacé. Il est obligatoire avec std::condition_variable.

- std::scoped_lock (C++17) — verrouille plusieurs mutex simultanément à l'aide d'un algorithme d'évitement des interblocages. C'est le type de verrou à privilégier pour verrouiller un ou plusieurs mutex.

std::mutex m1, m2;
// C++17 : verrouille les deux sans risque d'interblocage
std::scoped_lock lock(m1, m2);
Piège

1. Les objets RAII sans nom sont détruits immédiatement :

std::lock_guard<std::mutex>(mtx);  // BOGUE ! Temporaire — verrouille puis déverrouille aussitôt
std::lock_guard<std::mutex> lock(mtx);  // Correct — nommé, vit jusqu'à la fin de la portée

2. Ne copiez jamais naïvement des types RAII : copier un descripteur de fichier ou un verrou de mutex n'a aucun sens. Les types RAII doivent être :
- Déplaçables uniquement (comme unique_ptr, unique_lock) pour une propriété exclusive, ou
- À comptage de références (comme shared_ptr) pour une propriété partagée.

3. Un destructeur ne doit pas lever d'exception : si un destructeur lève une exception pendant le déroulement de la pile (alors qu'une autre exception est active), std::terminate est appelé. Les destructeurs sont noexcept par défaut depuis C++11.

Bonne pratique

1. Chaque ressource doit appartenir à un objet RAII — mémoire, fichiers, verrous, sockets, connexions à une base de données, handles GPU.
2. Une ressource par classe RAII — gardez des enveloppes ciblées.
3. Supprimez les opérations de copie, sauf si copier la ressource a un sens.
4. Implémentez les opérations de déplacement pour permettre le transfert de propriété.
5. Utilisez std::exchange dans les opérations de déplacement — il lit et réinitialise la source en une seule opération : fd_ = std::exchange(other.fd_, -1);
6. Préférez les types RAII standard (unique_ptr, lock_guard, fstream) — n'écrivez d'enveloppes personnalisées que pour les ressources qui n'ont pas d'enveloppe standard.

Composer des types RAII (règle de zéro)

L'expression ultime du RAII est la règle de zéro : si toutes vos variables membres sont des types RAII, votre classe n'a besoin d'aucun membre spécial déclaré par l'utilisateur. Le destructeur et les opérations de copie et de déplacement générés par le compilateur font automatiquement ce qu'il faut.

class DatabaseSession {
    std::unique_ptr<Connection> conn_;    // RAII : fermeture automatique
    std::unique_lock<std::mutex> lock_;   // RAII : déverrouillage automatique
    std::vector<std::string> log_;        // RAII : libération automatique
    // Ni destructeur, ni copie, ni déplacement nécessaires — les types des membres s'en chargent
};

La règle de zéro est l'objectif. La règle de cinq (définir explicitement les 5 membres spéciaux) est réservée aux types d'enveloppes de ressources de bas niveau. Il n'y a pas de juste milieu — si vous déclarez l'un des cinq, déclarez-les tous.

Points clés
  • RAII : acquérir dans le constructeur, libérer dans le destructeur — automatique et sûr vis-à-vis des exceptions
  • Les destructeurs sont appelés pendant le déroulement de la pile, le RAII gère donc naturellement les exceptions
  • Les types RAII doivent être déplaçables uniquement (propriété exclusive) ou à comptage de références (propriété partagée)
  • Utilisez std::scoped_lock (C++17) pour verrouiller plusieurs mutex sans interblocage
  • Les objets RAII sans nom sont détruits immédiatement — donnez-leur toujours un nom
  • Suivez la règle de zéro : utilisez des membres RAII pour que le compilateur génère des membres spéciaux corrects

Quiz — Testez vos connaissances

(15 XP)

1. Quel est le problème avec `std::lock_guard<std::mutex>(mtx);` (remarque : sans nom de variable) ?

2. Pourquoi les types RAII doivent-ils généralement supprimer leurs opérations de copie ?

3. Qu'est-ce que la règle de zéro ?