Références, pointeurs et leurs amis

Une personne a demandé sur OpenClassroom la différence entre int&, int* et int&& et à quoi correspond la move semantic. J’ai fait un longue réponse dans cette discussion et je voulais conserver ma réponse pour plus tard. Pas parce que ma réponse est parfaite et qu’elle mérite d’être conservée. C’est surtout un premier jet pour essayer d’expliquer ces notions de façon pédagogique et surtout du point de vue utilisateur et pas du fonctionnement interne (ne pas expliquer les références à partir des pointeurs et adresse mémoire par exemple – même si la compréhension de la mécanique interne est intéressante, elle doit venir dans un second). Cette explication a besoin d’être encore travaillée et discutée.

La discussion sur OpenClassroom

Remarque générale : expliquer les références à partir des pointeurs, c’était bien au début du C++, quand les gens maîtrisaient les pointeurs , c’est plus simple d’expliquer comme cela. Mais de nos jours, je crois pas que cela la meilleure pédagogie.

La raison est qu’avec cette pédagogie, on se focalise sur comment cela fonctionne en interne et pas sur comment les utiliser correctement.

Explication selon le point de vue utilisateur

Il est possible en C++ de manipuler un objet indirectement, en créant une variable qui « fait référence » à ce objet. Il existe différentes manière de « faire référence » à un objet, que l’on peut classer en fonction des propriétés. Supposons que l’on a une variable x qui fait référence à un objet y (on écrira de façon générique « x @ y » pour indiquer cela, mais cette notation n’est pas un code C++ valide, c’est pour les explications).

  • lire et modifier un objet auquel on « fait référence ». Il sera toujours possible de lire un objet auquel on « fait référence », mais pas toujours de la modifier.
1
2
3
cout << x; // on lit la valeur de y
x += 5; // on modifie la valeur de y
  • « faire référence » à un objet ou à rien. Certaines façon de « faire référence » permettent de « faire référence » à un objet ou de « faire référence » à rien du tout. D’autres façon de « faire référence » doivent obligatoirement concerner un objet valide.

Dans le cas d’une façon de « faire référence » qui peut « faire référence » à rien du tout, il faut donc obligatoirement vérifier que l’objet est valide avant de l’utiliser, en utilisant if si l’objet peut être nul dans un contexte donné ou assert s’il ne peut pas.

1
2
3
4
5
6
7
8
9
10
11
12
// x ne peut jamais faire référence à rien
x += 5;
// x peut faire référence à rien, mais on s'attend à ce que
// ça ne soit pas le cas dans ce contexte
assert(x);
x += 5;
// x peut faire référence à rien
if (x) {
    x += 5;
}
  • changer l’objet auquel on « fait référence ». Dans certain cas, on peut changer l’objet auquel on « fait référence », dans d’autre non.
1
2
3
x @ y; // x fait référence à y
x @ z; // x fait maintenant référence à z
  • « faire référence » à un objet temporaire ou non. Si on écrit par exemple « i = 1+2; ». i est une variable en mémoire, on peut connaitre l’adresse de cette variable en utilisant l’opérateur adresse-of : « &i ». Par contre, la valeur 3, calculé à partir de l’expression « 1+3 » n’existe pas en mémoire, c’est une valeur temporaire dans le processeur. L’expression « 1+2 » est donc un objet temporaire.

Un autre exemple de temporaire est l’appel d’une fonction. Dans « i = f(); », l’expression « f() » est la valeur retournée par la fonction, c’est une valeur temporaire.

« Faire référence » a un objet temporaire permet de dire au compilateur : 1. que l’on souhaite continuer à utiliser l’objet temporaire et qu’il ne faut pas le détruire tout de suite. 2. qu’il ne sera plus utilisé après que j’ai fini de l’utiliser, le compilateur peut donc faire toutes les optimisations qu’il veut pour me faciliter son utilisation, sans avoir crainte de rendre l’objet invalide pour un autre utilisateur de cet objet.

Imaginons que l’on a besoin d’un objet qui est accessible à un endroit (A) et que l’on souhaite avoir cet objet (et pas y faire référence) à un autre endroit (B). Dans le cas général, il faut que l’objet continue d’être accessible dans A, donc pour l’avoir dans B, il faut copier l’objet. Dans le cas d’un objet temporaire (rvalue), le compilateur sait que l’objet ne sera plus utilisé dans A, il peut donc éviter la copie et simplement déplacer (move sementic) l’objet dans B (et bien sûr, déplacer un objet est plus rapide que de copier cet objet en général).

La fonction std::move permet de dire explicitement au compilateur qu’il peut manipuler un objet « normal » (non temporaire, lvalue) comme si c’était un objet temporaire (donc en pratique convertir une lvalue en rvalue). Il peut donc faire les optimisations qu’il souhaite. Par contre, il est de la responsabilité du développeur de ne plus utiliser l’objet qui a été déplacé avec std:move, sous peine d’avoir un comportement indéterminé.

Après cette longue introduction, voyons les différentes façon de « faire référence ».

Type Syntaxe Peut modifier
l’objet référencé
L’objet référencé
peut être nul
Changer d’objet
référencé
Objet temporaire
Référence int & x = y;
x += 5;
cout << x;
Oui Non Non Non
Référence constante int const& x = y;
cout << x;
Non Non Non Non
Pointeur int * x = &y;
x = &z;
(*x) += 5;
cout << (*x);
Oui Oui Oui Non
Pointeur constant int * const x = &y;
(*x) += 5;
cout << (*x);
Oui Non Non Non
Pointeur vers constant int const * x = &y;
x = &z;
cout << (*x);
Non Oui Oui Non
Pointeur const vers const int const * const x = &y;
cout << (*x);
Non Non Non Non
Rvalue reference int && x = f();
cout << x;
Non Non Non Oui

Le fait que les pointeurs peuvent être nuls fait que l’on va préférer les références, qui apportent une garantie plus forte.

Et bien sûr, je ne peux pas parler des pointeurs nus sans faire une remarque sur les pointeurs intelligents (mais c’est une problématique transverse). Les pointeurs nus (présentés ici) présentent d’autres problèmes de gestion de la mémoire. Il est préférable d’utiliser des pointeurs intelligents au lieu des pointeurs nus :

  • unique_ptr (et make_unique) pour les objets dont on est responsable et que l’on ne partage pas ;
  • shared_ptr (et make_shared) pour les objets dont on est responsable et que l’on partage ;
  • weak_ptr pour les objets dont on n’est pas responsable et qui sont partagé.

J’espère que ces explications sont claires et sans trop d’erreurs ou simplifications :)

Début du message (partie à supprimer et à ne pas lire)

Tous ces syntaxes (T&, T*, T&&, mais aussi T const&, T cont*, T *const, T const*const)) permettent de « faire référence » à un objet et donc de pouvoir le manipuler. Quand on écrit quelque chose comme « x @ y » , on peut dire que x « fait référence » à y et l’on peut manipuler (le lire ou le modifier, selon les conditions) x à la place de y.

  • la référence & permet de lire et modifier l’objet, celui dit doit obligatoirement exister. Il n’est pas possible de changer l’objet auquel on fait référence.
  • la référence constante const& permet uniquement de lire un objet, qui doit obligatoirement exister. Il n’est pas possible de changer l’objet auquel on fait référence.
  • le pointeur * peut faire référence à un objet ou à rien (nullptr = pointeur nul). Quand il fait référence à un objet, il peut le lire et le modifier. Avant d’utiliser une référence, il faut obligatoirement vérifier que le pointeur « fait référence » à un objet et n’est pas nul. Il faut donc systématiquement mettre un if (si on pense que le pointeur peut être null dans un contexte donné) ou un assert (si on peut que le pointeur ne peut pas être null dans un contexte donné). On peut changer l’objet auquel on « fait référence ».
  • Le pointeur vers un objet constant const*. Idem que le pointeur, sauf que l’on ne peut que lire l’objet auquel on « fait référence ».
  • Le pointeurs constant *const. Idem que le pointeur *, mais on ne peut pas modifier l’objet auquel on « fait référence ».
  • Le pointeur constant vers un objet constant. On ne peut que lire l’objet et on ne peut pas modifier l’objet auquel on « fait référence ».
  • La rvalue-reference permet de « faire référence » à un objet temporaire, qui n’existe que comme intermédiaire de calcul. Ce type d’objet n’existe pas en mémoire, on ne peut pas utiliser l’opérateur « adresse-of » dessus

C’est un peu confus tout cela. Reprenons à 0.

Publicités

2 commentaires sur « Références, pointeurs et leurs amis »

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s