🔝 Retour au Sommaire
- 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.
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.
Le RAII fournit trois garanties distinctes. Comprendre chacune d'elles permet de mesurer pourquoi ce principe est si puissant.
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é — TOUJOURSPeu 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.
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.
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 (vianew), l'utilisation aprèsdeletereste possible. C'est l'une des raisons pour lesquelles les smart pointers (chapitre 9) sont préférables aunew/deletemanuel :std::unique_ptrélimine le risque en rendant le pointeur inaccessible après le transfert de propriété.
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 (&).
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 :
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
}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 momentNous 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.
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.
}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_;
};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 foisCe 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.
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.
- 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.