Jusqu’ici, nous vous avons expliqué que contrairement au Java, les données que vous instanciez ne restent pas magiquement en vie tant que vous en avez besoin.
C’est donc à vous de garantir que vos données ne seront pas désinstanciées avant d’avoir fini de les utiliser.
Sur cette page, nous allons introduire le concept d’ownership, qui vous aidera à mieux architecturer votre code pour éviter de vous retrouver avec des dangling-references.
Littéralement, ownership signifie propriété (au sens de la possession de quelque chose).
En pratique, dans le domaine de la programmation, le owner (ou le propriétaire) d’une donnée est l’élément qui a la responsabilité de la désinstancier une fois qu’elle n’est plus utile au programme.
Nous allons vous montrer quelques exemples, afin que vous puissiez un peu mieux comprendre ce concept assez abstrait.
Au fur-et-à-mesure des exemples, nous établirons le graphe d’ownership correspondant, c’est-à-dire la représentation graphique illustrant les relations d’ownership présentes au sein du programme.
Une variable locale est désinstanciée lorsqu’on sort du bloc dans lequel elle est définie.
void fcn()
{
int a = 3;
}
Dans l’exemple ci-dessus, a
est désinstanciée à la sortie de fcn
.
On pourra donc dire que la donnée portée par a
est ownée par la fonction fcn
, ou encore que fcn
own a
.
La donnée portée par un attribut-valeur est désinstanciée lorsque l’instance de la classe est détruite.
struct MyStruct
{
int value = 0;
};
int main()
{
MyStruct s { 5 };
return 0;
}
Ici, la donnée portée par l’attribut value
de l’instance s
est ownée par s
.
On peut dire plus simplement que s
own s.value
.
Ici, ça devient un peu plus compliqué !
struct Driver
{
};
struct Car
{
Driver& driver;
};
int main()
{
Driver gontrand;
Car clio { gontrand };
return 0;
}
La donnée portée par clio.driver
correspond à la variable gontrand
définie dans le main
.
Le owner de clio.driver
n’est donc pas clio
, puisque la destruction de clio
n’entrainera pas la destruction de gontrand
.
Dans ce cas précis, c’est la fonction main
qui own le contenu de clio.driver
.
Lorsque le cycle de vie d’une ressource (mémoire, fichier, connexion réseau, etc) stockée à l’intérieur d’un objet est orchestré par les fonctions-membres de cet objet, alors ces ressources sont ownées par l’objet.
Prenons ici l’exemple d’un std::vector
.
std::vector<Driver> drivers;
drivers.emplace_back();
drivers.emplace_back();
drivers.emplace_back();
Chacun des Driver
faisant partie du tableau est stocké sur un segment-mémoire alloué dynamiquement par l’objet drivers
.
Ce segment sera libéré à la destruction de drivers
(ou plus tôt, en fonction des fonctions qu’on appelera sur l’objet), entraînant la désinstanciation des Drivers
.
On peut donc dire que drivers
own chacun des éléments de type Driver
ajoutés via l’appel à emplace_back
.
Si on considère qu’un pointeur est responsable du cyle de vie de la donnée pointée, on parlera de pointeurs-ownants.
int* create_int(int value)
{
auto* ptr = new int { value };
return ptr;
}
int main()
{
auto* five = create_int(5);
std::cout << *five << std::endl;
delete five;
return 0;
}
Dans le code ci-dessus, à l’intérieur de la fonction create_int
, ptr
est un pointeur-ownant.
En effet, on l’a défini dans l’objectif de stocker l’adresse d’un bloc mémoire fraîchement alloué pour stocker un entier.
Il est par conséquent responsable du cycle de vie de cet entier.
Comme ptr
est la valeur de retour de create_int
, cette responsabilité est transmise à five
au retour dans la fonction main
, faisant de lui un pointeur-ownant.
La responsabilité de la désinstanciation de l’entier étant attribué à five
, on a pensé à exécuter delete five
avant de sortir du main
.
Un pointeur-observant est un pointeur qui n’est pas ownant. Ils servent simplement à référencer des données pré-existantes.
Mais du coup, vous devez vous demander quel est leur intérêt, sachant qu’on a déjà les références…
Eh bien les références ne permettent pas de faire autant de choses que les pointeurs.
Par exemple, vous ne pouvez pas créer de référence qui ne référence rien, alors qu’un pointeur peut être vide :
int& ref_on_nothing;
// => Aïe, ça compile pas
int* ptr_on_nothing = nullptr;
// => Ok
Une fois une référence définie, celle-ci référencera la même donnée pour toujours, tandis qu’un pointeur est réassignable :
int& ref = data_1;
ref = data_2;
// => ref fait toujours référence à data_1, le contenu de data_2 a simplement été assigné à data_1
int* ptr = &data_1;
ptr = &data_2;
// => data_1 n'a pas changé et ptr pointe désormais sur data_2
Sachant qu’un pointeur-observant ne own pas son contenu, comment pourriez-vous dessiner le graphe d’ownership du programme suivant, une fois arrivé à la ligne 8 :
|
|
Les éléments de all_drivers
étant des pointeurs, on utilise un contour plein pour les cases du tableau.
En revanche, comme ces pointeurs ne contrôlent pas le cycle de vie de chacun des Drivers
, on les relie vers eux avec des flèches en pointillé.
Nous allons maintenant vous présenter quelques petits bouts de code.
Vous devrez dessiner le graphe d’ownership correspondant à l’état du programme aux instructions indiquées, et en déduire les éventuels problèmes s’il y en a.
Si vous souhaitez dessiner vos graphes sur ordinateur, vous pouvez utiliser draw.io.
Pour cela, vous pourrez vous appuyer sur les règles d’architecture suivantes :
|
|
Dessinez le graphe d’ownership à la ligne 3, lorsque le main
vient d’appeler get_max
.
Dessinez maintenant le graphe d’ownership associé au retour de la fonction get_max
dans le main
.
Il faut faire pointer la variable-référence max
sur v2
, et non sur b
.
En effet, b
n’est pas une donnée, juste un alias, on ne peut donc pas faire pointer une référence dessus.
Redessinez le graphe correspondant au retour de la fonction get_max
, mais en supposant que get_max
attende ses paramètres par valeur.
Le résultat est par contre toujours retourné par référence.
Les paramètres a
et b
sont maintenant des données.
La variable-référence max
pointera donc sur b
et plus sur v2
.
Quel problème est mis en avant par ce graphe ?
On a une référence qui pointe sur une donnée qui été désinstanciée : il s’agit d’une dangling-reference.
Or cette référence est utilisée ligne 12, on a donc un undefined behavior…
|
|
Établissez le graphe d’ownership du programme à la ligne 33.
Selon-vous, les pointeurs contenus dans ManyAnimals::animals
sont-ils ownants ou observants ?
Le destructeur de ManyAnimals
se charge de libérer la mémoire associée à chacun des pointeurs contenus dans l’attribut animals
.
Ces pointeurs étant utilisés pour gérer le cycle de vie des données pointées, il s’agit de pointeurs-ownants.
On représente donc les relations associées avec des flèches pleines.
Dessinez maintenant les modifications dans ce graphe une fois arrivé à la ligne 14 (c’est-à-dire pendant la sortie du main
, lorsque l’instance de many_animals
est en cours de destruction).
À la ligne 14, les instances de Animal
ont toutes été libérées.
La ligne 34 est commentée, car si elle ne l’est pas, le programme se termine avec une erreur.
Pour comprendre ce qu’il se passe, reprenez le graphe de la question 1, et ajoutez les modifications décrivant l’état du programme une fois arrivé dans la fonction display_animals
(ligne 20).
Quel est le problème dans ce graphe ?
L’argument est passé par valeur, on a donc une copie de la variable many_animals
.
En revanche, lorsqu’un pointeur est copié, l’élément pointé ne l’est pas.
Le problème ici, c’est qu’on a maintenant deux owners pour les données dog
, cat
et mouse
.
Dessinez maintenant le graphe à la sortie de la fonction display_animals
.
Expliquez ce qui pose problème et qui empêche le programme de se terminer correctement.
À la fin de display_animals
, on désinstancie le paramètre many_animals
, ainsi que les données qu’il own récursivement.
Les pointeurs contenus dans many_animals.animals
(la variable du main
) pointent donc sur des données invalidées.
À la destruction de many_animals
, les instructions delete a
vont par conséquent échouer.
Proposez une solution pour résoudre le problème et dessinez le graphe d’ownership associé.
Il suffit de changer la signature de display_animals
pour passer le paramètre par référence (qui peut d’ailleurs être constante).
Les références n’ayant pas d’impact sur le cycle de vie des données, lorsque l’on sort de la fonction, aucun élément ne sera désinstancié.
nullptr
) et est réassignable.