Skip to content

Latest commit

 

History

History
365 lines (259 loc) · 16.9 KB

File metadata and controls

365 lines (259 loc) · 16.9 KB

🔝 Retour au Sommaire

6.3.1 — Resource Acquisition Is Initialization

Chapitre 6 : Classes et Encapsulation


Ce que vous allez apprendre

  • Formaliser le principe RAII au-delà de l'intuition donnée en section 6.3.
  • Comprendre les trois garanties que le RAII apporte au code.
  • Identifier les catégories de bugs que le RAII élimine structurellement.
  • Concevoir une classe RAII correcte : les règles à respecter.
  • Appréhender la notion de propriété (ownership) des ressources.

Le principe, formalisé

En section 6.3, nous avons vu l'idée générale : lier l'acquisition d'une ressource au constructeur et sa libération au destructeur. Formalisons les trois piliers sur lesquels repose le RAII :

Pilier 1 — Acquisition dans le constructeur. La ressource est acquise pendant la construction de l'objet. Si l'acquisition échoue, le constructeur lance une exception. L'objet n'existe jamais dans un état "à moitié construit" : soit il est entièrement construit et la ressource est disponible, soit la construction a échoué et l'objet n'existe pas.

Pilier 2 — Libération dans le destructeur. La ressource est libérée dans le destructeur, qui ne lance jamais d'exception. Le destructeur est la dernière chose qui s'exécute avant que la mémoire de l'objet ne soit récupérée.

Pilier 3 — Durée de vie automatique. Le compilateur garantit que le destructeur sera appelé quand l'objet quitte sa portée — que ce soit par un retour normal, un return anticipé, ou une exception. La libération n'est pas optionnelle, elle est structurelle.

La combinaison de ces trois piliers crée une propriété remarquable : il est impossible d'oublier de libérer une ressource, parce que ce n'est pas au développeur de le faire. Le compilateur s'en charge.


Les trois garanties du RAII

Le RAII fournit trois garanties distinctes. Comprendre chacune d'elles permet de mesurer pourquoi ce principe est si puissant.

Garantie 1 : pas de fuite de ressources

Chaque ressource acquise est associée à un objet. Quand cet objet est détruit — et le compilateur garantit qu'il le sera — la ressource est libérée. Il n'existe aucun chemin d'exécution qui contourne le destructeur pour les objets automatiques (sur la pile).

void guaranteed_cleanup() {
    std::vector<int> data(1'000'000);     // 4 Mo alloués
    FileHandle config("/etc/app.conf");   // Fichier ouvert
    std::lock_guard<std::mutex> lock(m);  // Mutex verrouillé

    might_throw();   // Peu importe si ça lance ou non

}   // lock déverrouillé, config fermé, data libéré — TOUJOURS

Peu importe que might_throw() lance une exception, que la fonction retourne normalement, ou qu'un return anticipé soit exécuté. Les trois destructeurs s'exécutent systématiquement, dans l'ordre inverse de construction.

Garantie 2 : pas d'utilisation avant initialisation

Puisque la ressource est acquise dans le constructeur, elle est disponible dès que l'objet existe. Il n'y a pas d'état intermédiaire "objet créé mais pas encore initialisé" :

// Avec RAII — impossible d'utiliser un objet non initialisé
FileHandle file("/tmp/data.bin");   // Le fichier est ouvert dès cette ligne  
file.read(buffer, 1024);            // Garanti : fd_ est valide  

// Sans RAII — l'état intermédiaire existe
FileHandle file;                     // Objet construit, mais fd_ = ???  
file.open("/tmp/data.bin");          // Oubliable !  
file.read(buffer, 1024);            // Crash si open() n'a pas été appelé  

Le design RAII élimine la nécessité d'une méthode init() ou open() séparée. L'objet est prêt à l'emploi dès sa construction — ou il n'existe pas.

Garantie 3 : pas d'utilisation après libération

Quand le destructeur s'exécute, l'objet sort de portée. Il n'est plus accessible par le code. L'utilisation après libération (use-after-free) est structurellement impossible pour les objets automatiques :

void safe_scope() {
    {
        DynArray temp(100);
        temp[0] = 42;           // OK — temp est en vie
    }                            // ~DynArray() → delete[] data_

    // temp n'existe plus ici — le compilateur refuse toute référence à temp
    // temp[0] = 99;            // ERREUR de compilation — 'temp' was not declared in this scope
}

Ce n'est pas une vérification à l'exécution — c'est une impossibilité syntaxique. Le nom de la variable n'existe plus en dehors de sa portée, et le compilateur l'impose.

⚠️ Cette garantie ne s'applique qu'aux objets sur la pile. Pour les objets sur le tas (via new), l'utilisation après delete reste possible. C'est l'une des raisons pour lesquelles les smart pointers (chapitre 9) sont préférables au new/delete manuel : std::unique_ptr élimine le risque en rendant le pointeur inaccessible après le transfert de propriété.


La notion de propriété (ownership)

Le RAII repose sur un concept fondamental : la propriété. Un objet RAII est le propriétaire de sa ressource. Être propriétaire signifie deux choses :

  • Vous êtes responsable de la libération de la ressource.
  • Vous avez le droit exclusif de décider quand et comment la ressource est utilisée.

Cette notion de propriété s'exprime naturellement dans le code :

class DatabaseConnection {  
public:  
    explicit DatabaseConnection(const std::string& conn_string) {
        handle_ = db_connect(conn_string.c_str());   // J'acquiers → je possède
        if (!handle_) throw std::runtime_error("Connection failed");
    }

    ~DatabaseConnection() {
        if (handle_) db_disconnect(handle_);          // Je possède → je libère
    }

    // Accès à la ressource — sans transférer la propriété
    db_handle_t get() const { return handle_; }

private:
    db_handle_t handle_ = nullptr;
};

La propriété peut être :

Exclusive — un seul objet possède la ressource à un instant donné. C'est le cas de std::unique_ptr et de notre DynArray. Si vous voulez transférer la propriété, vous déplacez l'objet (section 6.2.4). La copie est soit une copie profonde (duplication de la ressource), soit interdite.

Partagée — plusieurs objets se partagent la propriété. La ressource n'est libérée que quand le dernier propriétaire est détruit. C'est le modèle de std::shared_ptr, basé sur un compteur de références. Nous l'étudierons au chapitre 9.

Empruntée — un objet utilise une ressource sans en être propriétaire. Il ne la libère pas. En C++, cela se traduit par des références (&) ou des pointeurs bruts non propriétaires. L'emprunteur doit s'assurer que la ressource reste valide pendant qu'il l'utilise.

void print_data(const DynArray& arr) {   // Emprunt — pas de copie, pas de propriété
    for (std::size_t i = 0; i < arr.size(); ++i) {
        std::cout << arr[i] << " ";
    }
}

DynArray data(5, 42);      // data est propriétaire  
print_data(data);           // print_data emprunte data — ne le détruit pas  

Rendre la propriété explicite dans le code est l'un des bénéfices majeurs du RAII. En lisant la signature d'une fonction, vous savez immédiatement si elle prend possession de la ressource (paramètre par valeur ou unique_ptr), si elle l'emprunte en lecture (const&), ou si elle l'emprunte en écriture (&).


Catégories de bugs éliminés par le RAII

Le RAII n'est pas une optimisation ou un confort syntaxique. C'est un mécanisme qui élimine structurellement des catégories entières de bugs :

Fuites de ressources (resource leaks)

Sans RAII, chaque chemin de sortie d'une fonction doit libérer chaque ressource. Avec le RAII, la libération est automatique quel que soit le chemin :

// Sans RAII — fuite si une exception est lancée entre les deux
void leaky() {
    int* data = new int[1000];
    process(data);                 // Si process() lance → data fuit
    delete[] data;
}

// Avec RAII — aucune fuite possible
void safe() {
    std::vector<int> data(1000);
    process(data.data());          // Si process() lance → vector détruit automatiquement
}

Double libération (double free)

Sans RAII, si deux chemins de code tentent de libérer la même ressource, le programme crashe. Avec le RAII, la propriété est claire : un seul objet possède la ressource, et seul son destructeur la libère :

// Sans RAII — double free si le code est mal structuré
void dangerous() {
    int* p = new int(42);
    registry.store(p);     // Le registry pense posséder p
    delete p;              // ... mais on le libère aussi ici → double free
}

// Avec RAII et unique_ptr — propriété explicite
void safe() {
    auto p = std::make_unique<int>(42);
    registry.store(p.get());    // Le registry emprunte, il ne possède pas
}   // p est détruit ici — une seule libération, au bon moment

Utilisation après libération (use-after-free)

Nous l'avons vu : pour les objets automatiques, le compilateur empêche toute référence à un objet hors de sa portée. Le nom de la variable n'existe plus.

Ressources non libérées sur les chemins d'exception

C'est sans doute le bénéfice le plus important. Sans RAII, écrire du code robuste face aux exceptions demande un effort considérable — chaque try/catch doit nettoyer manuellement. Avec le RAII, la robustesse face aux exceptions est gratuite :

void complex_operation() {
    DatabaseConnection db("host=localhost dbname=app");
    FileHandle log("/var/log/app.log");
    std::lock_guard<std::mutex> lock(global_mutex);

    // 15 lignes de logique métier, dont certaines peuvent lancer...
    step_one(db);
    step_two(db, log);
    step_three(log);

    // Aucun try/catch nécessaire pour le nettoyage.
    // Si step_two lance, lock est déverrouillé, log est fermé, db est déconnecté.
    // Automatiquement. Toujours. Dans le bon ordre.
}

Concevoir une classe RAII : les règles

Si vous devez encapsuler une ressource brute dans une classe RAII, suivez ces règles :

Règle 1 — Acquérir dans le constructeur. Si l'acquisition échoue, lancez une exception. Ne laissez jamais l'objet exister avec une ressource invalide.

Règle 2 — Libérer dans le destructeur. Le destructeur ne doit jamais lancer d'exception. Si la libération peut échouer (ce qui est rare), loggez l'erreur silencieusement ou ignorez-la.

Règle 3 — Un objet, une ressource. Chaque objet RAII ne devrait gérer qu'une seule ressource. Si vous avez besoin de gérer deux ressources, créez deux classes RAII ou utilisez deux smart pointers comme membres. Cela simplifie la gestion des exceptions : si la deuxième acquisition échoue, le destructeur de la première (déjà construite) libère correctement la première ressource.

// MAUVAIS — deux ressources dans un même objet
class TwoResources {  
public:  
    TwoResources() {
        a_ = acquire_a();
        b_ = acquire_b();   // Si ça échoue, a_ fuit !
    }
    ~TwoResources() {
        release_b(b_);
        release_a(a_);
    }
private:
    ResourceA* a_;
    ResourceB* b_;
};

// BON — chaque ressource dans son propre wrapper
class SafeComposite {  
public:  
    SafeComposite()
        : a_(acquire_a(), release_a)     // unique_ptr avec custom deleter
        , b_(acquire_b(), release_b) {}  // Si ça échoue, a_ est auto-libéré
private:
    std::unique_ptr<ResourceA, decltype(&release_a)> a_;
    std::unique_ptr<ResourceB, decltype(&release_b)> b_;
};

Règle 4 — Définir ou interdire la copie et le déplacement. Une classe RAII possède une ressource. Copier l'objet signifie-t-il dupliquer la ressource ou partager la propriété ? Déplacer l'objet transfère-t-il la propriété ? Si la copie n'a pas de sens, interdisez-la avec = delete. C'est la Règle des 5 (section 6.5).

Règle 5 — Fournir un accès contrôlé à la ressource. L'objet RAII doit offrir un moyen d'accéder à la ressource sous-jacente, mais sans transférer la propriété. C'est typiquement une méthode get() qui retourne un pointeur ou un handle brut :

class SocketWrapper {  
public:  
    // ... constructeur, destructeur ...

    // Accès non propriétaire — l'appelant ne doit PAS fermer ce socket
    int fd() const { return fd_; }

private:
    int fd_;
};

RAII et les fonctions d'initialisation en deux temps

Le RAII s'oppose au pattern courant dans d'autres langages où l'on crée un objet puis l'initialise :

// Anti-pattern — initialisation en deux temps
class BadDesign {  
public:  
    BadDesign() = default;

    bool init(const std::string& path) {    // Peut échouer
        fd_ = ::open(path.c_str(), O_RDONLY);
        return fd_ >= 0;
    }

    void close() {                           // Peut être oublié
        if (fd_ >= 0) { ::close(fd_); fd_ = -1; }
    }

    ~BadDesign() {
        close();   // Filet de sécurité, mais le design est faible
    }

private:
    int fd_ = -1;
};

// Utilisation — l'objet existe avant d'être prêt
BadDesign file;  
if (!file.init("/etc/config")) {  
    // Gestion d'erreur
}
// Danger : file pourrait être utilisé sans init(), ou init() pourrait être appelé deux fois

Ce design brise le pilier 1 du RAII : l'objet existe dans un état invalide entre la construction et l'appel à init(). Il brise aussi le pilier 2 : close() peut être appelé manuellement ou oublié.

Le design RAII correct est celui que nous avons vu tout au long de ce chapitre : le constructeur fait tout le travail, et si ça échoue, il lance une exception. L'objet n'existe jamais à moitié.

// Design RAII — pas d'état invalide possible
class GoodDesign {  
public:  
    explicit GoodDesign(const std::string& path)
        : fd_(::open(path.c_str(), O_RDONLY)) {
        if (fd_ < 0) throw std::runtime_error("Cannot open: " + path);
    }

    ~GoodDesign() {
        ::close(fd_);   // fd_ est garanti valide — le constructeur l'a vérifié
    }

    // Pas de init(), pas de close() — le cycle de vie est automatique

private:
    int fd_;
};

💡 Il existe des cas légitimes d'initialisation en deux temps — par exemple lorsque l'objet doit exister avant qu'une information nécessaire à son initialisation ne soit disponible. Mais ce sont des exceptions, pas la règle. Préférez toujours le RAII quand c'est possible.


Le RAII dans la bibliothèque standard

La bibliothèque standard du C++ est entièrement construite sur le RAII. Chaque conteneur, chaque smart pointer, chaque objet de synchronisation suit ce principe :

Classe Ressource gérée Acquisition Libération
std::vector<T> Bloc mémoire dynamique new[] dans le constructeur delete[] dans le destructeur
std::string Buffer de caractères Allocation interne Libération interne
std::fstream Descripteur de fichier open() dans le constructeur close() dans le destructeur
std::unique_ptr<T> Objet alloué dynamiquement Reçoit un pointeur new delete dans le destructeur
std::lock_guard Verrou mutex lock() dans le constructeur unlock() dans le destructeur
std::jthread (C++20) Thread d'exécution Lancement dans le constructeur join() dans le destructeur

Quand vous utilisez un std::vector, vous faites du RAII sans y penser. Le vecteur alloue la mémoire quand il en a besoin, la libère quand il est détruit, et tout chemin de sortie — normal ou exceptionnel — passe par son destructeur. C'est pour cette raison que le C++ moderne encourage l'utilisation de ces types plutôt que la gestion manuelle des ressources.


Points clés à retenir

  • Le RAII repose sur trois piliers : acquisition dans le constructeur, libération dans le destructeur, et durée de vie automatique garantie par le compilateur.
  • Le RAII fournit trois garanties : pas de fuite de ressources, pas d'utilisation avant initialisation, pas d'utilisation après libération (pour les objets automatiques).
  • Le RAII élimine structurellement les fuites, les double free, les use-after-free, et les fuites sur les chemins d'exception. Ce ne sont pas des bugs "rares" — ce sont les bugs les plus coûteux en C et C++ non idiomatique.
  • La propriété est au cœur du RAII : chaque ressource a un propriétaire clairement identifié. La propriété peut être exclusive, partagée ou empruntée.
  • Concevez vos classes RAII avec une ressource par objet, une acquisition dans le constructeur (exception si échec), une libération dans le destructeur (jamais d'exception), et une copie/déplacement explicitement définis ou interdits.
  • Évitez l'initialisation en deux temps (init()/close()). Préférez le constructeur qui fait tout le travail.
  • La bibliothèque standard est entièrement RAII. Utilisez ses types (vector, string, unique_ptr, lock_guard) plutôt que de gérer les ressources manuellement.

⏭️ Exemples pratiques de RAII