La gestion de la mémoire est fondamentale en C++. Contrairement à des langages qui cachent une grande partie de ce fonctionnement, C++ permet de manipuler directement des adresses, des pointeurs, la durée de vie des objets et des allocations dynamiques.
Pour le développement système, ces notions sont particulièrement importantes : buffers, structures d’API, échanges avec le système d’exploitation, parsing binaire, mémoire partagée et code bas niveau reposent constamment sur une bonne compréhension de la mémoire.
1. Modèle mémoire : pile et mémoire dynamique
Mémoire automatique
Une variable locale classique possède généralement une durée de vie automatique.
Code: Select all
void fonction()
{
int valeur = 42;
}
On associe couramment ce fonctionnement à la pile (stack), même si le standard C++ décrit surtout des durées de stockage et ne garantit pas littéralement que toute variable automatique se trouve physiquement sur une pile.
Avantages :
- gestion automatique ;
- allocation très rapide en pratique ;
- pas besoin de libération manuelle ;
- durée de vie simple à comprendre.
Une allocation dynamique permet de créer une zone dont la durée de vie n’est pas directement limitée au bloc courant.
Code: Select all
int* ptr = new int(42);
Il faut distinguer deux choses :
- ptr : la variable pointeur elle-même ;
- *ptr : l’objet situé à l’adresse contenue dans ptr.
2. Adresses et pointeurs
Un pointeur est une variable contenant une adresse mémoire.
Code: Select all
int valeur = 42;
int* ptr = &valeur;
Code: Select all
&valeur
Code: Select all
*ptr = 100;
On peut se représenter la situation ainsi :
Code: Select all
ptr
|
v
+-------+
| 100 | valeur
+-------+
nullptr
Un pointeur qui ne désigne aucun objet devrait généralement être initialisé avec nullptr.
Code: Select all
int* ptr = nullptr;
Code: Select all
*ptr = 5;
3. Allocation avec new et libération avec delete
new crée dynamiquement un objet et retourne son adresse.
Code: Select all
int* ptr = new int;
Code: Select all
int* ptr = new int(42);
Code: Select all
std::cout << *ptr;
Code: Select all
delete ptr;
ptr = nullptr;
Mettre ensuite le pointeur à nullptr évite de conserver volontairement une adresse devenue invalide.
Règle fondamentale
Une allocation effectuée avec new doit être libérée avec delete.
Code: Select all
int* ptr = new int(42);
delete ptr;
4. Tableaux dynamiques : new[] et delete[]
Il est possible d’allouer dynamiquement plusieurs objets.
Code: Select all
int* tableau = new int[100];
On peut accéder aux éléments avec [].
Code: Select all
tableau[0] = 10;
tableau[1] = 20;
Code: Select all
delete[] tableau;
tableau = nullptr;
Code: Select all
new -> delete
new[] -> delete[]
Pour un tableau d’objets, delete[] doit également permettre l’appel du destructeur de chacun des éléments concernés.
Dans du C++ applicatif moderne, un std::vector est généralement préférable à un tableau dynamique manuel lorsque l’objectif est simplement d’obtenir une collection redimensionnable. Cependant, comprendre new[] et delete[] reste indispensable pour comprendre la mémoire, les anciens codes et certaines interfaces bas niveau.
5. Tableaux multidimensionnels et disposition mémoire
Un tableau multidimensionnel classique peut être stocké dans une seule zone contiguë.
Code: Select all
int matrice[3][4];
Code: Select all
ligne 0 : [0][0] [0][1] [0][2] [0][3]
ligne 1 : [1][0] [1][1] [1][2] [1][3]
ligne 2 : [2][0] [2][1] [2][2] [2][3]
Une autre technique consiste à utiliser plusieurs allocations et des pointeurs.
Code: Select all
int** matrice = new int*[3];
for (int i = 0; i < 3; ++i)
matrice[i] = new int[4];
La libération doit suivre la structure des allocations.
Code: Select all
for (int i = 0; i < 3; ++i)
delete[] matrice[i];
delete[] matrice;
6. Arithmétique des pointeurs
L’arithmétique des pointeurs permet de se déplacer entre les éléments d’un tableau.
Code: Select all
int tableau[4] = {10, 20, 30, 40};
int* ptr = tableau;
Code: Select all
ptr + 1
Le déplacement dépend du type pointé.
Pour un int* :
Code: Select all
ptr + 1
On peut écrire :
Code: Select all
*(ptr + 2)
C’est conceptuellement équivalent à :
Code: Select all
tableau[2]
7. Relation entre tableaux et pointeurs
Tableaux et pointeurs sont fortement liés, mais ils ne sont pas identiques.
Code: Select all
int tableau[4] = {1, 2, 3, 4};
On parle souvent de array-to-pointer decay.
Ainsi :
Code: Select all
int* ptr = tableau;
Code: Select all
int* ptr = &tableau[0];
Par exemple :
Code: Select all
sizeof(tableau)
Alors que :
Code: Select all
sizeof(ptr)
Passage à une fonction
Avec une déclaration telle que :
Code: Select all
void traiter(int tableau[])
La fonction ne récupère donc pas automatiquement la longueur du tableau.
C’est pourquoi les interfaces bas niveau utilisent fréquemment le couple :
Code: Select all
pointeur + taille
Code: Select all
void traiter(const void* buffer, size_t taille);
8. Pointeurs vers pointeurs
Un pointeur peut lui-même être pointé par un autre pointeur.
Code: Select all
int valeur = 42;
int* ptr = &valeur;
int** pptr = &ptr;
Code: Select all
pptr -> ptr -> valeur
Code: Select all
**pptr
9. Pointeurs invalides et dangling pointers
Un dangling pointer est un pointeur qui conserve l’adresse d’un objet dont la durée de vie est terminée.
Code: Select all
int* ptr = new int(42);
delete ptr;
Faire :
Code: Select all
std::cout << *ptr;
On peut réduire certains risques en écrivant :
Code: Select all
delete ptr;
ptr = nullptr;
10. Opérations mémoire bas niveau
C++ permet également de manipuler directement des blocs de mémoire.
Des opérations classiques permettent notamment :
- de copier des octets ;
- de déplacer des octets ;
- de remplir une zone ;
- de comparer des zones.
Code: Select all
memcpy
memmove
memset
memcmp
Copie un nombre déterminé d’octets d’une zone source vers une zone destination.
Code: Select all
std::memcpy(destination, source, taille);
- source contient suffisamment d’octets lisibles ;
- destination contient suffisamment d’espace accessible ;
- les objets concernés permettent ce type de copie brute ;
- les zones ne se chevauchent pas d’une manière interdite par memcpy.
memmove sert à déplacer/copier des octets lorsque les zones peuvent se chevaucher.
memset
Remplit une zone mémoire octet par octet.
Code: Select all
std::memset(buffer, 0, taille);
memcmp
Compare le contenu brut de deux zones sur un nombre d’octets donné.
Comparer la représentation binaire de deux objets n’est pas toujours équivalent à comparer leur valeur logique, notamment à cause du padding et des représentations internes.
11. Gestion mémoire personnalisée
Une allocation dynamique générale possède un coût.
Dans certains programmes très sensibles aux performances, on peut vouloir contrôler précisément la manière dont les allocations sont effectuées.
Exemples :
- moteurs de jeux ;
- serveurs haute performance ;
- systèmes embarqués ;
- code temps réel ;
- composants système.
Principe :
Code: Select all
+------------------------------------------------+
| bloc réservé |
+--------+--------+--------+--------+-------------+
| objet1 | objet2 | libre | objet3 | libre ... |
+--------+--------+--------+--------+-------------+
Object pools
Un object pool conserve des emplacements ou objets réutilisables.
Au lieu de détruire/allouer continuellement :
Code: Select all
créer -> utiliser -> détruire
créer -> utiliser -> détruire
Code: Select all
prendre dans le pool
utiliser
remettre dans le pool
12. Les erreurs mémoire les plus importantes
Fuite mémoire
Une fuite apparaît lorsqu’une ressource allouée n’est plus accessible mais n’a pas été libérée.
Code: Select all
void fuite()
{
int* ptr = new int(42);
}
Dans un programme long, la répétition de telles fuites peut faire augmenter continuellement la consommation mémoire.
Accès hors limites
Code: Select all
int tableau[4];
tableau[10] = 42;
C’est un comportement indéfini pouvant provoquer :
- un crash ;
- une corruption silencieuse ;
- une modification d’autres données ;
- une vulnérabilité de sécurité.
Une erreur classique consiste à réserver moins de mémoire que ce qui sera écrit.
Exemple conceptuel :
Code: Select all
char buffer[8];
Le programme écrit alors hors du buffer.
Double libération
Code: Select all
int* ptr = new int;
delete ptr;
delete ptr;
Use-after-free
Code: Select all
int* ptr = new int(42);
delete ptr;
std::cout << *ptr;
Pointeur non initialisé
Code: Select all
int* ptr;
*ptr = 42;
C’est une erreur grave.
13. Pourquoi ces erreurs sont dangereuses
Les bugs mémoire sont particulièrement difficiles parce qu’une erreur peut ne pas provoquer immédiatement un crash.
Une écriture hors limites peut modifier une autre donnée et le crash apparaître beaucoup plus tard.
Exemple conceptuel :
Code: Select all
buffer A
buffer B
structure C
Le symptôme observé peut donc sembler totalement indépendant de l’endroit où l’erreur initiale s’est produite.
C’est une raison pour laquelle le debugging mémoire est une compétence importante en développement système.
14. Détection des fuites et erreurs mémoire
Sous Windows avec Visual C++, le CRT debug heap peut aider à détecter certaines fuites liées aux allocations qu’il suit.
On rencontre notamment les mécanismes de diagnostic du CRT permettant d’obtenir un rapport des allocations non libérées.
Sous Linux, Valgrind est un outil classique permettant de détecter notamment :
- fuites ;
- lectures invalides ;
- écritures invalides ;
- utilisations de mémoire libérée.
- buffer overflow ;
- heap-use-after-free ;
- stack-use-after-scope dans certains cas ;
- double free et autres erreurs d’accès mémoire.
15. Garbage collection
Un garbage collector cherche automatiquement les objets qui ne sont plus accessibles afin de récupérer leur mémoire.
C++ ne repose pas par défaut sur un garbage collector obligatoire comme certains environnements managés.
Le C++ privilégie notamment :
- durées de vie déterministes ;
- destruction automatique des objets locaux ;
- RAII ;
- conteneurs ;
- smart pointers lorsque l’ownership dynamique le justifie.
16. Ownership et RAII
Une question centrale en gestion mémoire est :
Code: Select all
Qui possède cette ressource ?
Avec un pointeur brut seul :
Code: Select all
int* ptr;
- possède l’objet ;
- observe simplement l’objet ;
- doit le libérer ;
- ne doit surtout pas le libérer.
Ils appliquent également le principe RAII : la durée de vie de la ressource est attachée à celle d’un objet C++ qui se charge de sa libération.
17. std::unique_ptr
std::unique_ptr représente généralement une propriété exclusive.
Code: Select all
std::unique_ptr<int> ptr = std::make_unique<int>(42);
Lorsque ptr est détruit, la ressource est automatiquement libérée.
On peut généralement écrire plus simplement :
Code: Select all
auto ptr = std::make_unique<int>(42);
Deux unique_ptr ne peuvent pas posséder indépendamment la même ressource via une simple copie.
Le code suivant est interdit :
Code: Select all
auto a = std::make_unique<int>(42);
auto b = a;
Code: Select all
auto a = std::make_unique<int>(42);
auto b = std::move(a);
Cette sémantique permet d’exprimer clairement :
Code: Select all
cette ressource a exactement un propriétaire
Pour créer un objet géré par unique_ptr, on privilégie généralement :
Code: Select all
auto ptr = std::make_unique<int>(42);
Code: Select all
std::unique_ptr<int> ptr(new int(42));
Pour un tableau :
Code: Select all
auto buffer = std::make_unique<std::byte[]>(1024);
19. Custom deleters
Un smart pointer n’est pas limité aux ressources qui doivent être libérées avec delete.
Il peut recevoir un destructeur personnalisé, appelé custom deleter.
C’est particulièrement intéressant en développement système.
Supposons qu’une bibliothèque C retourne une ressource qui doit être libérée par une fonction spécifique.
Conceptuellement :
Code: Select all
Resource* r = create_resource();
destroy_resource(r);
Le principe est :
Code: Select all
acquisition
|
smart pointer
|
fin de durée de vie
|
fonction de libération appropriée
Le même concept peut servir avec des ressources système lorsqu’une abstraction C++ adaptée est souhaitée.
20. std::shared_ptr
std::shared_ptr représente une propriété partagée.
Code: Select all
auto a = std::make_shared<int>(42);
auto b = a;
Un compteur de références est maintenu dans un bloc de contrôle associé.
Conceptuellement :
Code: Select all
a ----+
|
+----> objet
|
b ----+
compteur = 2
Code: Select all
compteur = 1
Lorsque le dernier shared_ptr propriétaire disparaît :
Code: Select all
compteur = 0
make_shared
On privilégie généralement :
Code: Select all
auto ptr = std::make_shared<Objet>();
Coût
shared_ptr est plus complexe que unique_ptr.
Il nécessite notamment la gestion du bloc de contrôle et du comptage de références.
Il ne faut donc pas utiliser shared_ptr simplement parce qu’il paraît plus pratique. Il sert lorsque la propriété est réellement partagée.
21. std::weak_ptr
weak_ptr observe une ressource gérée par shared_ptr sans devenir propriétaire de cette ressource.
Code: Select all
std::weak_ptr<int> faible;
Code: Select all
auto fort = std::make_shared<int>(42);
std::weak_ptr<int> faible = fort;
Pour essayer d’obtenir temporairement un shared_ptr valide :
Code: Select all
auto ptr = faible.lock();
if (ptr)
{
std::cout << *ptr;
}
weak_ptr est notamment utile pour casser certains cycles de propriété.
Exemple problématique :
Code: Select all
A possède B
B possède A
L’un des liens peut alors être non propriétaire avec weak_ptr.
22. Passage des smart pointers aux fonctions
Le type du paramètre doit refléter ce que la fonction veut faire.
Si une fonction doit prendre possession d’un unique_ptr, l’ownership peut être transféré.
Code: Select all
void prendre(std::unique_ptr<int> ptr);
Code: Select all
prendre(std::move(ptr));
Si la fonction veut seulement utiliser l’objet sans devenir propriétaire, il n’est pas toujours nécessaire de lui transmettre le smart pointer lui-même.
On peut par exemple travailler avec une référence ou un pointeur non propriétaire selon l’interface voulue.
Même principe pour shared_ptr : le passer par valeur signifie généralement que la fonction obtient une part de propriété pendant la durée correspondante.
L’ownership doit guider le choix du type.
23. Retour des smart pointers
Une fonction peut retourner un unique_ptr.
Code: Select all
std::unique_ptr<int> creer()
{
return std::make_unique<int>(42);
}
Un shared_ptr peut également être retourné lorsque le modèle de propriété doit réellement être partagé.
24. Interopérabilité avec les API C
Le développement système utilise énormément d’API basées sur :
- pointeurs bruts ;
- void* ;
- buffers ;
- paramètres de sortie ;
- fonctions spécifiques de libération.
Il faut comprendre les deux mondes.
Une API C peut avoir une logique conceptuelle de ce genre :
Code: Select all
Resource* create();
void destroy(Resource*);
Dans du code bas niveau, la priorité reste de respecter exactement le contrat de l’API :
- qui alloue ?
- qui libère ?
- avec quelle fonction ?
- quelle est la taille du buffer ?
- combien de temps le pointeur reste-t-il valide ?
- l’API conserve-t-elle l’adresse après l’appel ?
25. out_ptr et inout_ptr
Certaines API C reçoivent l’adresse d’un pointeur afin d’y placer une ressource.
Conceptuellement :
Code: Select all
Resource** output
L’idée générale est d’adapter temporairement le smart pointer au format attendu par l’API, puis de lui faire reprendre correctement la propriété de la ressource retournée.
Il est surtout important de comprendre le problème sous-jacent : une API C peut vouloir modifier directement la valeur d’un pointeur appartenant à l’appelant.
26. Pointeurs bruts ou smart pointers ?
Un pointeur brut n’est pas mauvais en soi.
Il est indispensable dans de nombreux contextes bas niveau.
Code: Select all
void* buffer;
std::byte* data;
Structure* structure;
Un raw pointer est très adapté pour :
- observer une zone sans la posséder ;
- parcourir un buffer ;
- faire de l’arithmétique de pointeurs ;
- interagir avec des API C/Win32/POSIX ;
- manipuler des structures mémoire ;
- travailler dans des environnements où la STL n’est pas appropriée.
Il faut donc connaître les pointeurs bruts en profondeur ET comprendre les abstractions modernes.
27. Ce qu’il faut retenir pour le développement bas niveau
Les points essentiels sont :
- Un pointeur contient une adresse.
- & obtient une adresse et * permet de déréférencer un pointeur.
- La variable pointeur et l’objet pointé ont des durées de vie distinctes.
- new/delete et new[]/delete[] doivent être correctement appariés.
- Un tableau et un pointeur sont liés mais ne sont pas le même type.
- Un tableau est souvent converti en pointeur vers son premier élément.
- L’arithmétique des pointeurs dépend du type pointé.
- Un buffer doit toujours être accompagné d’une connaissance fiable de sa taille.
- Les accès hors limites provoquent un comportement indéfini.
- Une allocation perdue provoque une fuite mémoire.
- Utiliser une allocation après sa libération produit un use-after-free.
- Libérer deux fois la même allocation est une erreur grave.
- Les pointeurs invalides sont une source majeure de crashs et de vulnérabilités.
- Les opérations mémoire brutes travaillent généralement en octets et exigent de connaître précisément la disposition des données.
- Les allocations personnalisées et pools permettent de contrôler davantage les performances et la disposition mémoire.
- unique_ptr exprime généralement un propriétaire unique.
- shared_ptr exprime une propriété réellement partagée.
- weak_ptr observe une ressource partagée sans la maintenir en vie.
- Les custom deleters permettent d’appliquer RAII à des ressources qui utilisent une fonction de libération spécifique.
- Les API système utilisent toujours énormément de pointeurs bruts : les smart pointers ne remplacent donc pas la compréhension de la mémoire.
- Avant toute manipulation de ressource, demande-toi toujours : qui possède la ressource, quelle est sa taille, combien de temps est-elle valide et comment doit-elle être libérée ?
