Nous allons maintenant revenir sur l’instanciation et la désinstanciation des données, car ce sont les deux événements qui délimitent leur période de validité.
L’instanciation d’une donnée de type fondamental est constitué de deux phases.
En ce qui concerne la désinstanciation, le programme désalloue l’espace réservé pour la donnée.
Dans le cas d’une allocation automatique, l’instanciation a lieu au moment de la définition de la variable, et la désinstanciation a lieu lorsque l’on sort du bloc dans lequel elle est définie.
|
|
Dans l’exemple ci-dessus :
sum
est instancié à la ligne 3 et désinstancié à la ligne 12i
est instancié à la ligne 5 et désinstancié à la ligne 9 (après la dernière itération de la boucle)twice
est instancié à la ligne 7 et désinstancié à la ligne 9 (à la fin de chaque itération)Dans le cas d’une allocation dynamique, l’instanciation a lieu à l’appel à new
(ou new[]
) et la désinstanciation à l’appel à delete
(ou delete[]
).
|
|
Dans ce nouvel exemple :
value
est instanciée ligne 1 et désinstanciée ligne 6,ptr
(le pointeur !) est instanciée ligne 3 et désinstanciée ligne 6,*ptr
puis *five
) est instancié ligne 4 et désinstancié ligne 54,five
(encore le pointeur) est instanciée à la ligne 10 et désinstanciée ligne 15.Pour les types-structurés (c’est-à-dire class
et struct
), le comportement à l’instanciation et désinstanciation est un peu différent.
À l’instanciation :
À la désinstanciation :
Prenons l’exemple suivant :
|
|
Voici ce qu’il se passe au moment de l’instanciation de p
(ligne 23) :
Person
,Person
à deux paramètres qui :
_name
:
p
).std::string
à un paramètre._age
:
p
).3
._surname
:
p
).std::string
à un paramètre.Jean is born
dans la console).Notez bien ici que _name
est instancié en premier, puis _age
, puis _surname
, malgré l’ordre dans lequel les attributs apparaissent dans la liste d’initialisation du constructeur.
En effet, c’est l’ordre de définition des attributs dans la classe qui fait foi et détermine qui est instancié avant qui.
Lorsqu’un attribut est mentionné dans la liste d’initialisation, il est initialisé en fonction de ce qui est spécifié dans cette liste. En l’absence de cette mention, l’initialisation se fait à partir du class-initializer. Si ce dernier n’est pas défini, le constructeur par défaut est invoqué pour un type structuré, tandis que pour un type fondamental, rien n’est fait.
Voici maintenant ce qu’il se passe au moment de sa désinstanciation (ligne 25) :
Person
qui :
Jean is dead
dans la console),_surname
:
std::string
._surname
._age
, c’est-à-dire qu’il désalloue l’espace qui lui est réservé,_name
:
std::string
._name
.p
.Pour résumer, en plus de l’allocation et la désallocation mémoire, l’instanciation et la désinstanciation d’un objet de type-structuré comprennent sa construction et sa destruction.
Ces deux étapes entraînent récursivement l’instanciation et la désinstanciation de ses attributs.
Une référence est un alias d’une donnée.
Contrairement à la définition d’une variable classique, la définition d’une référence ou la sortie du bloc dans lequel elle est définie n’a absolument aucun impact sur l’instanciation ou la désinstanciation de la donnée :
|
|
La donnée représentée par a
, mais également par b
, est instanciée ligne 3 et désinstanciée ligne 11.
La ligne 6 n’a absolument aucun impact sur la mémoire : aucune nouvelle instanciation n’est réalisée.
Idem pour la ligne 8, rien n’est désinstancié, la donnée correspondant à a
est toujours bien présente.
En revanche, il faut faire attention au cas inverse : conserver une référence sur une donnée qui va être désinstanciée !
Voici un exemple :
|
|
Ici, default_name
est instancié ligne 3 et désinstancié ligne 5 (lorsqu’on sort de la fonction fcn
).
Dans le cas où name
est vide, on renvoie une référence sur default_name
, qui n’a par conséquent plus d’espace mémoire attribué une fois revenu dans la fonction main
.
Ce qui est affiché dans la console à la ligne 11 est donc indéterminé (et encore, moyennant que le programme ne crash pas 😬).
On utilise le terme dangling-reference pour parler de cette situation. C’est un problème que l’on rencontre souvent, surtout lorsqu’on est débutant.
L’accès à une donnée (en écriture ou en lecture) est valide si et seulement si cet accès est effectué après son instanciation et avant sa désinstanciation.
Pourquoi ? Eh bien, tout simplement parce que le support d’existence d’une donnée est le segment mémoire dans lequel elle est écrite, et ce segment est réservé au cours de l’instanciation puis libéré à la désinstanciation.
C’est d’ailleurs la cause des dangling-references, que nous avons présentées dans le paragraphe précédent.
La période entre ces deux événements est appelée durée de vie de la donnée, ou encore lifespan en anglais.
Si l’accès à la donnée est fait en dehors de sa durée de vie, le comportement du programme est indéterminé (= undefined behavior).
Dans le cas d’un accès en lecture, si le programme ne génère pas immédiatement une segfault, vous pourrez vous retrouver avec une valeur complètement aléatoire (ce ne sera pas forcément la dernière valeur portée par la donnée).
Dans le cas d’un accès en écriture, on aura au mieux une segfault qui permettra de localiser rapidement à quel endroit du code l’accès invalide a été fait, et dans le pire des cas on écrira dans une zone mémoire désormais allouée à une autre donnée.
Il devient alors extrêmement difficile de comprendre d’où vient le problème…
Petit exercice, dans le code ci-dessous, essayez d’anticiper quelles seront les valeurs affichées dans la console :
#include <iostream>
#include <vector>
void fcn1()
{
int* ptr = nullptr;
{
int a = 1200;
ptr = &a;
}
std::cout << *ptr << std::endl; // => ?
}
struct Values
{
std::vector<int> values = { 1, 2, 3 };
};
void fcn2()
{
Values* ptr = nullptr;
{
Values values;
ptr = &values;
}
std::cout << ptr->values.size() << std::endl; // => ?
std::cout << ptr->values[1] << std::endl; // => ?
}
void fcn3()
{
int* ptr = new int(1200);
delete ptr;
std::cout << *ptr << std::endl; // => ?
}
int& fcn4()
{
int v = 4;
return v;
}
int main()
{
fcn1();
fcn2();
fcn3();
std::cout << fcn4() << std::endl; // => ?
return 0;
}
Ouvrez maintenant le code dans Compiler Explorer et regardez ce qu’il se passe vraiment.
Changez ensuite de compilateur et sélectionnez x86-64 gcc 13.2
.
Que constatez-vous ?
Le terme undefined behavior signifie littéralement “comportement indéfini”.
En réalité, il s’agit d’un comportement indéfini par le standard.
Cela comporte donc les cas où le programme ne donne pas les mêmes résultats d’une exécution à l’autre (fcn3
), mais également les cas où le comportement semble stable, jusqu’à ce que l’on change de compilateur ou de machine (fcn2
et fcn4
).
Et comme on ne peut pas tester son code sur tous les compilateurs et toutes les machines, même si certaines instructions ont l’air de toujours se comporter comme on le voudrait (fcn1
), il faudra éviter d’écrire du code dont le comportement n’est pas garanti par le standard.
new
(ou new[]
).delete
(ou delete[]
).