Gestion de la mémoire ✱

Dans l’univers C++, il y a deux types de mémoire :

  • La pile (stack) stockant les variables locales et les paramètres des fonctions.

  • Le tas (heap) stockant les données allouées dynamiquement.

La pile

Fonctionnement

Lors d’un appel de fonction, les paramètres et les variables locales de cette fonction sont créées sur la pile. Cela concerne aussi bien les types fondamentaux (int, float) que les types structurés comme les objets. Une fois la fonction terminée, ces variables sont libérées et retirées de la pile. Ainsi, apparaît un phénomène d’empilement et de dépilage des variables au fur et à mesure de l’exécution du programme. Voici un exemple :

struct Point
{
   int x,y;
   Point(int _x, int _y) { x=_x; y=_y; }
};

void test(int v)
{
   Point B(4,5);
   int k = v + B.x;
   std::cout << k;
}

int main()
{
   Point A(1,2);
   test(A.x);
   return 0;
}
../_images/pile.png

Lors de l’entrée dans la fonction main(), la variable locale A est créée dans la pile en tout premier. La fonction test() nécessite la création d’un seul argument v sur la pile. Cette fonction utilise deux variables locales qui seront créées en haut de la pile : B et k. Une fois cette fonction terminée, les deux variables sont libérées. L’argument v n’ayant plus d’utilité, il est aussi détruit dans la foulée. Une fois la fonction main() terminée, la variable A est détruite et la pile est maintenant revenue à son état initial.

Intérêt

La pile permet une gestion efficace des variables :

  • Le programme n’a pas d’allocation mémoire à faire. Il sait que l’espace mémoire au dessus de la pile est immédiatement disponible.

  • Les variables allouées sur la pile sont automatiquement libérées, il n’y a rien à faire de particulier.

Avertissement

La pile ne permet pas de stocker des tableaux de taille variable, la taille doit être connue à la compilation. Ainsi la syntaxe suivante ne peut fonctionner :

../_images/error.png

La taille de pile

La pile a été conçue pour stocker les « petites variables », comme les indices de boucles, les valeurs passées en arguments… Par défaut, au démarrage du programme un espace mémoire unique est alloué pour la pile : 1Mo sous Windows et 8Mo sous Linux&MacOS. Par conséquent, la pile n’est pas faite pour accueillir de gros objets car cela mettrait son fonctionnement en péril.

Par exemple, on peut considérer qu’un programme standard est composé de fonctions nécessitant 1ko chacune pour stocker ses variables locales et que la profondeur d’appels des fonctions de notre programme peut aller jusqu’à 100. Ainsi, sur cet exemple, 100ko de mémoire va être consommé sur la pile soit 10% de sa taille totale sous Windows.

Note

Il faut être conscient que si l’on remplit la pile en entier, rien n’a été prévu pour gérer cette situation et c’est une erreur générale qui est levée et qui arrête net le programme. Cette erreur nommée : stack overflow est d’ailleurs à l’origine du nom d’un célèbre forum informatique !

En quoi cela vous concerne ? Effectivement 99% du temps, cela n’a pas d’impact pour vos programmes, mais un jour vous allez créer un tableau bi ou tridimensionnel sur la pile en écrivant ceci :

int T[400][400];

Cette ligne, plutôt banale, a cependant pour effet de consommer 400x400x4 = 640ko de mémoire, soit les 2/3 de la taille de la pile sous Windows ! Il suffit d’agrandir un peu la taille de ce tableau pour faire crasher le programme :

../_images/stackoverflow.png

Le tas

Intérêt

Pourquoi allouer des données sur le tas :

  • Le volume des données est variable.

  • Le volume des données est important.

  • La durée de vie des données s’étend au delà de la fonction qui les a créées.

Le tas n’a pas de taille maximale imposée par le compilateur. Cette fois, sa seule limite est la mémoire RAM disponible sur l’ordinateur.

Les fuites mémoire

Vous n’avez peut-être jamais entendu cette expression, mais pour le programmeur C++, c’est le stress absolu !! En effet, la gestion des données sur le tas est plus complexe car le programmeur doit s’occuper de :

  • L’allocation des données en écrivant une syntaxe spécifique.

  • Symétriquement, de la libération des données en utilisant une syntaxe spécifique aussi.

A la base, rien d’impossible, mais, il y a un mais, comme les données allouées dynamiquement ont une vie qui s’étend au delà de la fonction qui les a chargées, quand et où doit-on les libérer ? Cette question est complexe et il n’y a pas de mécanique simple. Ainsi, le programmeur reporte la question à plus tard et il oublie tout simplement de s’en occuper. Et sur les milliers d’allocations présentes dans le code, le développeur oubliera d’écrire la libération associée à certaines d’entre elles. Même si ces zones mémoire ne seront plus utilisées par le programme, elles restent toujours réservées et donc inutilisables par le système. C’est un peu comme si la mémoire vive de l’ordinateur diminuait avec le temps, d’où la métaphore de la fuite !

Comme exemple, nous avons créé une allocation mémoire sans écrire sa libération associée et cela à l’intérieur d’une boucle infinie, voici la graphique de l’occupation de la mémoire durant l’exécution de ce test :

../_images/memoire.png

Stratégie

Le programmeur C++ moderne se débrouille aujourd’hui pour ne plus avoir à gérer les libérations des objets alloués dynamiquement. Depuis C++11, il dispose d’autres outils lui permettant de contourner les anciennes mécaniques. Nous vous imposerons donc de travailler en utilisant uniquement cette nouvelle manière de coder.

Vous serez donc obligés de rompre avec l’utilisation des mots-clefs :

  • new et delete pour les hatibutés du C++ original

  • malloc et free pour les habitués du langage C

Et au cas où il y aurait un doute :

Les mots-clefs new et delete SONT INTERDITS

Les fonctions malloc et free SONT INTERDITES

La solution

Un design innovant

Pour gérer toutes notres contraintes d’allocation mémoire dynamique, nous allons utiliser la classe modèle vector. Pourquoi un tel choix ? A ceci, plusieurs raisons :

  • Les opérations de copie/redimensionnement sont déjà implémentées.

  • La taille d’un objet vector peut s’adapter dynamiquement en fonction des besoins.

  • La classe vector est optimisée pour des opérations complexes.

  • La libération de la mémoire est gérée automatiquement.

Ne sous-estimez pas la complexité de la gestion des ressources mémoire engendrée par un objet. Prenons deux exemples avec des objets Matrice100x100 :

Cas 1 : affectation sur un objet préexistant

Cette affectation pose problème :

A = B         // copie vers un objet préexistant

En effet, si elle est mal codée, cette affectation peut déclencher :

  • La destruction du buffer de données de la matrice A

  • La création d’un nouveau bufffer de données pour A

  • La recopie des valeurs de B vers A

Il faut donc faire attention et gérer spécifiquement ce cas pour faire en sorte qu’il n’y ait pas de désallocation/réallocation déclenchées inutilement mais uniquement une recopie. Vous pourrez toujours dire que c’est évident ! Mais est-ce évident pour le compilateur ???

Cas 2 : retour d’un objet temporaire

M100x100 fnt(...)
{
   ...
   M100x100 R = ...
   ...
   return R;
}

int main()
{
   ...
   M100x100 T = fnt(...);
}

Si le compilateur n’arrive pas à optimiser, il va déclencher le scénario basique suivant :

  • Création d’un objet local R avec initialisation

  • L’instruction return retourne l’objet R qui devient un objet temporaire

  • Création de l’objet T

  • Recopie de l’objet temporaire vers le nouvel objet T

  • Destruction de l’objet temporaire

Il y a de nombreuses opérations superflues déclenchées. Si le programmeur a fourni au compilateur toutes les optimisations possibles pour cette situation, voici ce que le compilateur arrivera à produire :

  • Création d’un objet local R avec initialisation

  • Après l’instruction return, transfert des données de R qui deviennent propriété de T (opération en temps constant)

  • Et c’est tout !

Conclusion

Utiliser un objet vector pour stocker des données est un choix sûr et c’est aussi un choix synonyme d'efficacité et de sécurité au sens où toutes les configurations piégeuses ont été traitées et optimisées. D’ailleurs, Bjarne Stroustup en personne, a édicté un principe de développement connu sous le nom de règle RC-0. Cette règle conseillée pour les développeurs C++ modernes précise que :

Règle RC-0 : Si vous pouvez éviter de définir des opérateurs pour gérer des situations complexes - FAITES LE !

Ainsi, si dans la STL existe une classe répondant à vos besoins, ne réinventez pas la roue ! En conclusion de ce chapitre :

Toutes les allocations mémoire dynamiques se feront à travers l'utilisation de containers de la STL : vector, array...

Quizzz

  • En C++, il existe deux types de mémoire : le heap et le tas.

  • En C++, la taille du tas est fixé à l’initialisation du programme.

  • La pile est destinée à accueillir les variables temporaires.

  • Une erreur stack overflow se produit lorsqu’une valeur entière stockée sur la pile dépasse la valeur maximale autorisée.

  • Si les variables a,b,c et d sont créées dans cet ordre sur la pile, alors la variable a sera la première détruite.

  • La règle RC0 indique qu’il est préférable de réutiliser des containers déjà fournis dans la STL afin d’éviter de reprogrammer des opérateurs complexes.