Le débat sur la sécurité mémoire entre C++ et Rust est devenu l’un des sujets les plus actifs en génie logiciel. Des agences gouvernementales se sont prononcées, des conférences entières y sont consacrées, et les avis sont tranchés des deux côtés.
Soyons clairs d’entrée : Rust est un excellent langage. Son modèle de propriété (ownership) et son borrow checker sont véritablement innovants, et ils détectent des catégories entières de bugs à la compilation. Si vous démarrez un nouveau projet et que Rust convient à votre équipe et à votre écosystème, c’est un excellent choix.
En même temps, C++ reste la colonne vertébrale des logiciels les plus critiques en termes de performance : noyaux de systèmes d’exploitation, moteurs de jeux, navigateurs, bases de données et systèmes financiers. Ce n’est pas un hasard, et ce n’est pas parce que ces équipes n’ont jamais entendu parler de Rust.
Ce que je souhaite explorer dans cet article est un point souvent occulté dans le débat : le C++ moderne (C++11 et versions ultérieures) fournit des outils robustes pour écrire du code sûr en termes de mémoire. Les pointeurs intelligents, RAII, les conteneurs avec vérification des bornes et une riche bibliothèque standard répondent aux mêmes catégories de bugs que le compilateur Rust impose. Les deux langages adoptent simplement des approches de conception différentes pour le même problème : l’application par le compilateur vs. les idiomes choisis par le développeur.
Plutôt que d’argumenter dans l’abstrait, laissez-moi vous montrer ce que je veux dire avec des exemples concrets et exécutables.
Avertissement : Les opinions exprimées dans cet article sont les miennes et reposent sur mon expérience professionnelle. Tous les exemples de code sont volontairement simplifiés à des fins pédagogiques. Chaque langage a ses compromis ; cet article vise à encourager une discussion réfléchie, et non à dénigrer un langage ou sa communauté. Les résultats peuvent varier.
Exemple 1 : Pointeur pendant (Use-After-Free)
Il s’agit de l’un des bugs de sécurité mémoire les plus courants. Un pointeur continue d’être utilisé après que la mémoire vers laquelle il pointe a été libérée.
Le mauvais C++ (mauvaise pratique)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(42);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 // ptr is now dangling (it still holds the address of freed memory)
8 delete ptr;
9
10 // Undefined behavior: reading freed memory
11 std::cout << "After delete: " << *ptr << std::endl;
12
13 return 0;
14}
Qu’est-ce qui a mal tourné :
Le développeur a manuellement alloué de la mémoire avec new, l’a manuellement libérée avec delete, puis a continué à utiliser le pointeur brut. C’est un bug de type use-after-free classique. Le problème n’est pas que C++ a « permis » que cela arrive — le problème est que le développeur a utilisé un schéma des années 1990 alors que des alternatives modernes existent.
Le bon C++ (pratique moderne)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(42);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Explicitly releases the memory
9 ptr.reset();
10
11 if (!ptr) {
12 std::cout << "Pointer is null, safe to handle." << std::endl;
13 }
14
15 return 0;
16}
Ce qui a changé :
std::unique_ptr possède la ressource. Lorsqu’il sort de la portée ou est réinitialisé, la mémoire est libérée et le pointeur devient vérifiable (null-checkable). Il n’y a pas de pointeur pendant. Pas de comportement indéfini. Cela fait partie du standard C++ depuis 2011.
Exemple 2 : Dépassement de tampon (Buffer Overflow)
Lire ou écrire au-delà des limites d’un tableau est une autre source classique de vulnérabilités.
Le mauvais C++ (mauvaise pratique)
1#include <iostream>
2#include <cstring>
3
4int main() {
5 char buffer[10];
6
7 // Copying more data than the buffer can hold
8 const char* source = "This string is way too long for the buffer";
9 std::strcpy(buffer, source);
10
11 std::cout << "Buffer: " << buffer << std::endl;
12 return 0;
13}
Qu’est-ce qui a mal tourné :
Le développeur a utilisé une fonction de l’ère C (strcpy) avec un tableau C de taille fixe et aucune vérification des bornes. C’est le genre de code qui donne mauvaise réputation au C++, mais ce n’est pas du C++ moderne. C’est du code C compilé avec un compilateur C++.
Le bon C++ (pratique moderne)
1#include <iostream>
2#include <string>
3
4int main() {
5 std::string buffer;
6
7 // std::string manages its own memory and grows as needed
8 buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
9
10 std::cout << "Buffer: " << buffer << std::endl;
11
12 // Need a substring? Bounds-checked:
13 try {
14 std::string sub = buffer.substr(0, 10);
15 std::cout << "Substring: " << sub << std::endl;
16
17 // This would throw std::out_of_range:
18 // std::string bad_sub = buffer.substr(9999, 10);
19 } catch (const std::out_of_range& e) {
20 std::cout << "Caught: " << e.what() << std::endl;
21 }
22
23 return 0;
24}
Ce qui a changé :
std::string gère sa propre mémoire. Il ne peut pas déborder. Tenter une opération hors limites avec .at() ou .substr() avec des indices invalides lève une exception rattrapable. Ceci fait partie du standard depuis C++98.
Exemple 3 : Accès hors limites d’un tableau
Le mauvais C++ (mauvaise pratique)
1#include <iostream>
2
3int main() {
4 int arr[5] = {10, 20, 30, 40, 50};
5
6 // Accessing index 10 (way out of bounds)
7 std::cout << "arr[10] = " << arr[10] << std::endl;
8
9 return 0;
10}
Qu’est-ce qui a mal tourné :
Les tableaux C bruts n’ont aucune vérification des bornes. Le développeur a accédé à un indice invalide et a obtenu des données aberrantes (ou pire, une vulnérabilité de sécurité). Encore une fois, c’est un choix du développeur d’utiliser la structure de données la plus primitive disponible.
Le bon C++ (pratique moderne)
1#include <iostream>
2#include <array>
3#include <stdexcept>
4
5int main() {
6 std::array<int, 5> arr = {10, 20, 30, 40, 50};
7
8 try {
9 // .at() performs bounds checking and throws on invalid access
10 std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11 std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12 } catch (const std::out_of_range& e) {
13 std::cout << "Caught out-of-range: " << e.what() << std::endl;
14 }
15
16 return 0;
17}
Ce qui a changé :
std::array est un conteneur de taille fixe qui connaît sa propre taille. La méthode .at() effectue une vérification des bornes à l’exécution et lève une exception si vous dépassez les limites. Disponible depuis C++11.
Exemple 4 : Double libération (Double Free)
Le mauvais C++ (mauvaise pratique)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(100);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 delete ptr;
8 // Double free (undefined behavior, potential crash or exploit)
9 delete ptr;
10
11 return 0;
12}
Qu’est-ce qui a mal tourné :
Le développeur a libéré la même mémoire deux fois. Cela peut corrompre l’état interne de l’allocateur de tas et constitue un vecteur d’exploitation bien connu. C’est, encore une fois, une erreur de gestion manuelle de la mémoire.
Le bon C++ (pratique moderne)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(100);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Memory is freed exactly once when ptr goes out of scope.
9 // You literally cannot double-free with unique_ptr.
10 // Calling reset() is also safe:
11 ptr.reset();
12 // No-op. No crash. No UB.
13 ptr.reset();
14
15 std::cout << "Done safely." << std::endl;
16 return 0;
17}
Ce qui a changé :
std::unique_ptr garantit une sémantique de propriété unique. La mémoire est libérée exactement une seule fois : soit lorsque le pointeur sort de la portée, soit lorsque reset() est appelé. Appeler reset() sur un unique_ptr déjà nul est une opération sans effet et parfaitement sûre.
Exemple 5 : Propriété partagée mal faite vs. bien faite
Le mauvais C++ (mauvaise pratique)
1#include <iostream>
2
3void use(int* p) {
4 std::cout << "Using: " << *p << std::endl;
5}
6
7int main() {
8 int* shared = new int(99);
9
10 use(shared);
11
12 delete shared;
13
14 // Another function tries to use the same pointer (use-after-free)
15 use(shared);
16
17 return 0;
18}
Qu’est-ce qui a mal tourné :
Plusieurs parties du code partagent un pointeur brut, et il n’y a pas de propriété clairement définie. Une fois qu’une partie supprime la mémoire, toutes les autres se retrouvent avec un pointeur pendant.
Le bon C++ (pratique moderne)
1#include <iostream>
2#include <memory>
3
4void use(std::shared_ptr<int> p) {
5 std::cout << "Using: " << *p
6 << " (ref count: " << p.use_count() << ")" << std::endl;
7}
8
9int main() {
10 auto shared = std::make_shared<int>(99);
11
12 use(shared);
13 use(shared);
14
15 // Memory freed automatically when the last shared_ptr goes out of scope.
16 std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18 return 0;
19}
Ce qui a changé :
std::shared_ptr utilise le comptage de références pour gérer la propriété partagée. La mémoire n’est libérée que lorsque le dernier shared_ptr la référençant est détruit. Pas de pointeurs pendants. Pas de delete manuel. Disponible depuis C++11.
Exemple 6 : RAII - La sécurité du C++ moderne
RAII (Resource Acquisition Is Initialization) est sans doute l’idiome le plus important du C++. L’idée est simple : lier la durée de vie d’une ressource à la durée de vie d’un objet. Lorsque l’objet est créé, il acquiert la ressource. Lorsque l’objet est détruit (sort de la portée), il libère la ressource. Pas de nettoyage manuel. Aucune possibilité d’oublier.
Les pointeurs intelligents ne sont qu’une application de RAII. Ce schéma fonctionne pour toute ressource : fichiers, mutex, sockets réseau, connexions à des bases de données, handles GPU — tout ce que vous voulez.
Le mauvais C++ (gestion manuelle des ressources)
1#include <iostream>
2#include <fstream>
3#include <stdexcept>
4
5void process_file(const std::string& filename) {
6 FILE* f = fopen(filename.c_str(), "r");
7 if (!f) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 char buf[256];
12 if (!fgets(buf, sizeof(buf), f)) {
13 // Oops - if we throw or return here, we leak the file handle
14 throw std::runtime_error("Cannot read file");
15 }
16
17 // If any exception is thrown above, fclose is never called
18 fclose(f);
19 std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23 try {
24 process_file("example.txt");
25 } catch (const std::exception& e) {
26 std::cerr << "Error: " << e.what() << std::endl;
27 }
28 return 0;
29}
Qu’est-ce qui a mal tourné :
Le développeur a utilisé les fonctions C fopen/fclose. Si une exception est levée entre l’ouverture et la fermeture, le descripteur de fichier est perdu (fuite de ressource). À mesure que le nombre de ressources et de chemins d’erreur augmente, fermer proprement chaque ressource devient un cauchemar de maintenance.
Le bon C++ (RAII avec std::ifstream)
1#include <iostream>
2#include <fstream>
3#include <string>
4
5void process_file(const std::string& filename) {
6 std::ifstream file(filename);
7 if (!file.is_open()) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 std::string line;
12 if (!std::getline(file, line)) {
13 throw std::runtime_error("Cannot read file");
14 }
15
16 // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17 std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21 try {
22 process_file("example.txt");
23 } catch (const std::exception& e) {
24 std::cerr << "Error: " << e.what() << std::endl;
25 }
26 return 0;
27}
Ce qui a changé :
std::ifstream est un wrapper RAII autour d’un descripteur de fichier. Le fichier est ouvert dans le constructeur et fermé dans le destructeur — garanti, quelle que soit la manière dont la fonction se termine (retour normal, exception, retour anticipé). Zéro fuite de ressource par conception.
RAII pour les ressources personnalisées (verrouillage de mutex)
RAII ne se limite pas à la bibliothèque standard. Voici comment cela fonctionne avec la synchronisation de threads :
1#include <iostream>
2#include <mutex>
3#include <thread>
4#include <vector>
5
6std::mutex mtx;
7int shared_counter = 0;
8
9void increment(int times) {
10 for (int i = 0; i < times; ++i) {
11 // std::lock_guard locks the mutex on construction,
12 // unlocks it on destruction (end of scope, even if an exception occurs)
13 std::lock_guard<std::mutex> lock(mtx);
14 ++shared_counter;
15 }
16}
17
18int main() {
19 std::vector<std::thread> threads;
20 for (int i = 0; i < 4; ++i) {
21 threads.emplace_back(increment, 10000);
22 }
23
24 for (auto& t : threads) {
25 t.join();
26 }
27
28 // Always prints 40000 (no data race, no forgotten unlock)
29 std::cout << "Counter: " << shared_counter << std::endl;
30
31 return 0;
32}
Écrire votre propre wrapper RAII
Vous pouvez appliquer RAII à n’importe quelle ressource. Voici un exemple minimal :
1#include <iostream>
2#include <stdexcept>
3
4// A generic RAII wrapper (acquires in constructor, releases in destructor)
5class SocketHandle {
6 int fd_;
7public:
8 explicit SocketHandle(int fd) : fd_(fd) {
9 if (fd_ < 0) throw std::runtime_error("Invalid socket");
10 std::cout << "Socket " << fd_ << " acquired." << std::endl;
11 }
12
13 ~SocketHandle() {
14 if (fd_ >= 0) {
15 std::cout << "Socket " << fd_ << " released." << std::endl;
16 fd_ = -1;
17 }
18 }
19
20 // Prevent copying (single ownership, like unique_ptr)
21 SocketHandle(const SocketHandle&) = delete;
22 SocketHandle& operator=(const SocketHandle&) = delete;
23
24 // Allow moving
25 SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26 other.fd_ = -1;
27 }
28
29 int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33 SocketHandle sock(42); // "Acquired"
34 std::cout << "Using socket " << sock.get() << std::endl;
35 // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39 do_network_stuff();
40 std::cout << "Back in main - no resource leak." << std::endl;
41 return 0;
42}
Point clé :
RAII n’est ni un contournement ni un simple « conseil de bonne pratique » — c’est un schéma de conception fondamental du C++ qui rend les fuites de ressources structurellement impossibles lorsqu’il est appliqué de manière cohérente. Il est antérieur au modèle de propriété de Rust et, à bien des égards, l’a inspiré.
Comment se positionne Rust ?
Le borrow checker de Rust détecte beaucoup de ces problèmes avant même que votre code ne s’exécute, à la compilation. C’est un avantage réel et significatif qui mérite d’être reconnu.
Voici comment je présenterais honnêtement les compromis :
Rust impose la sécurité au niveau du compilateur ; C++ offre la sécurité à travers sa bibliothèque standard et ses idiomes. Les deux approches sont valables ; elles reflètent des philosophies de conception différentes.
Le C++ moderne prévient les mêmes catégories de bugs lorsque ses outils recommandés sont utilisés. Les pointeurs intelligents, RAII,
std::array,std::string,std::span,std::optional— ce ne sont pas des fonctionnalités obscures de bibliothèque. Ce sont la manière recommandée d’écrire du C++, et ce depuis bien plus d’une décennie.Aucun des deux langages n’est une solution miracle. Le mot-clé
unsafede Rust existe pour une raison : parfois, il faut sortir du filet de sécurité pour le FFI, la performance ou les opérations bas niveau. Et les bugs de logique, les conditions de concurrence (dans certains schémas) et les erreurs de conception existent dans tous les langages.Le pragmatisme compte. Des milliards de lignes de C++ éprouvé tournent en production aujourd’hui. Pour de nombreuses équipes, adopter les pratiques modernes du C++, de meilleurs outils et l’analyse statique est un chemin plus réaliste qu’une réécriture complète — et cela apporte de réelles améliorations en matière de sécurité.
Qu’en est-il de l’outillage ?
Les développeurs C++ modernes ont accès à de puissants outils de sécurité qui détectent des problèmes allant au-delà de ce que le langage lui-même impose :
| Outil | Objectif |
|---|---|
| AddressSanitizer (ASan) | Détecte les use-after-free, dépassements de tampon, débordements de pile |
| MemorySanitizer (MSan) | Détecte les lectures de mémoire non initialisée |
| UndefinedBehaviorSanitizer (UBSan) | Détecte les débordements signés, les déréférencements null, et plus encore |
| Valgrind | Détection de fuites mémoire et profilage |
| Clang-Tidy | Analyse statique avec suggestions de modernisation |
| C++ Core Guidelines | Directives industrielles standard pour un C++ moderne et sûr |
Compiler les « mauvais » exemples de cet article avec -fsanitize=address signalerait immédiatement les problèmes à l’exécution. L’outillage existe — la question est de savoir si les développeurs l’utilisent.
En résumé
C++ et Rust abordent la sécurité mémoire sous des angles différents :
- Rust fait de la sécurité le comportement par défaut et exige un opt-out explicite avec
unsafe. - C++ donne au développeur un contrôle total et offre la sécurité à travers sa bibliothèque standard, RAII et des idiomes bien établis.
La plupart des problèmes de sécurité mémoire dans les bases de code C++ proviennent de l’utilisation de schémas obsolètes — new/delete bruts, tableaux de style C, fonctions comme strcpy — plutôt que des outils modernes que le langage fournit depuis plus d’une décennie.
La bonne nouvelle, c’est que la voie à suivre pour le C++ est claire et bien documentée :
- Utilisez les pointeurs intelligents (
std::unique_ptr,std::shared_ptr) au lieu denew/deletebruts - Utilisez les conteneurs standard (
std::string,std::vector,std::array) au lieu des tampons de style C - Adoptez RAII pour toute gestion de ressources
- Exploitez les sanitizers (ASan, MSan, UBSan) et l’analyse statique dans les pipelines CI
- Suivez les C++ Core Guidelines
Les deux langages sont des outils puissants, et chacun a sa place dans le développement logiciel moderne. Le meilleur choix dépend de votre projet, de votre équipe et de vos contraintes — pas des débats sur Internet.
Si cet article vous a été utile ou vous a fait réfléchir, n’hésitez pas à le partager. Et si vous avez un point de vue différent, j’aimerais beaucoup le lire dans la section commentaires ci-dessous. Les meilleures discussions en ingénierie se font quand on argumente avec du code, pas seulement avec des opinions.
Commentaires