Comprendre les références

Cet article est destiné à celles et ceux qui connaissent à la fois les pointeurs et les références mais qui ont de la difficulté à naviguer de l'un à l'autre ou à bien saisir les nuances dans le recours aux opérateurs que sont *, & et ->. Ce n'est toutefois pas un article pensé pour des débutant(e)s, alors si vous ne connaissez pas les pointeurs au préalable, vous risquez de le trouver difficile d'approche. Je ne discute pas non plus dans cet article des références sur des rvalue (pour cela, je vous invite à lire sur la sémantique de mouvement).

Qu'on soit un(e) informaticien(ne) néophyte ou d'expérience, il peut arriver que certains détails subtils nous échappent. Ainsi, nombreuses et nombreux sont celles et ceux qui se questionnent sur les références et les pointeurs en C++. Les similitudes du point de vue syntaxe et du point de vue de l'utilité peuvent expliquer cette confusion. Ainsi, voici un bref article pour illustrer les similitudes et les différences entre les références et les pointeurs.

Nature des indirections

Les pointeurs sont bien connus des programmeuses et des programmeurs, et ce depuis longtemps. En langage C, les références n'existent pas et les pointeurs servent à titre d'abstraction fondamentale pour représenter une adresse typée. Ainsi, pour parler de l'adresse d'un int, on utilisera un pointeur sur un int.

Cette capacité de mentir est un atout précieux en programmation système mais permet aux individus malicieux (ou maladroits) de contourner les règles de types du langage. C'est une des raisons pour lesquelles les pointeurs sont à la fois désirables et indésirables, selon le contexte et les applications. Concrètement, leur absence empêche la réalisation de plusieurs familles de programmes sérieux, mais leur utilisation entraîne une exigence accrue de rigueur.

Le pointeur est un concept fécond, puissant et – par le fait-même – dangereux, ce qui lui donne (trop) mauvaise presse. Il permet à la fois de représenter un lieu et la nature de ce que le programme est en droit de s'attendre à y trouver :un pointeur de float mènera vers un float à moins que la programmeuse ou que le programmeur n'ait menti au compilateur avec une conversion explicite de types ou à moins que le programme contienne des erreurs d'arithmétique sur les pointeurs.

Un pointeur, à moins d'être constant, peut mener vers différents objets du même type au cours de son existence. Pour cette raison, tous les langages utilisent une variante du concept de pointeur nul, soit une valeur signifiant pointeur non initialiséen C++, le pointeur nul est traditionnellement modélisé par l'adresse 0, et plus récemment par nullptr. Concrètement, si p est un pointeur sur un X, alors p + 1 signifie l'adresse du prochain X à partir de p. Pour cette raison, C et C++ implémentent les tableaux comme des suites contiguës en mémoire d'objets du même type et accéder à un élément d'un tableau dans ces langages n'est qu'une simple opération arithmétique, faisant des tableaux une structure de données primitive, très brute mais d'une efficacité considérable. Pour en savoir(un peu) plus, référez-vous à la section donnant un bref aperçu de l'arithmétique de pointeurs.

Une référence offre plusieurs des avantages des pointeurs mais représente aussi, à plusieurs égards, une gamme moins élevée de risques. Une référence doit être liée à une entité du bon type dès sa déclaration, et mènera au même endroit durant toute son existence. Une référence est plus opaque qu'un pointeur en ce sens qu'il n'est pas possible de savoir où elle se trouve en mémoire (prendre l'adresse d'une référence ne permet pas de connaître son adresse à elle mais donne plutôt l'adresse de ce vers quoi elle réfère) et au sens où elle ne permet pas de réaliser de l'arithmétique sur des adresses. Pour les programmes, une référence est un alias sur son référé.

Ce que dit le standard (n4604 [dcl.ref],p. 4-5) à ce sujet est :

It is unspecified whether or not a reference requires storage.

There shall be no references to references, no arrays of references, and no pointers to references. The declaration of a reference shall contain an initializer except when the declaration contains an explicit extern specifier, is a class member declaration within a class definition, or is the declaration of a parameter or a return type. A reference shall be initialized to refer to a valid object or function. [Note: in particular, a null reference cannot exist in a well-defined program, because the only way to create such a reference would be to bind it to the "object" obtained by indirection through a null pointer, which causes undefined behavior. [...A] reference cannot be bound directly to a bit-field. — end note]

Au sens de C++, un objet est une zone mémoire adressable. Techniquement, une référence n'est pas un objet; elle n'a pas sa propre adresse, et le compilateur peut carrément optimiser le code pour l'ignorer et accéder directement au référé si le contexte le permet.

Dans un programme orienté objet (OO), les indirections que sont les références et les pointeurs permettent d'exprimer des opérations en termes de généralisations : si r est une référence sur un X et si p est un pointeur sur un X, alors on sait que r réfère non pas à un X mais bien à quelque chose qui est au moins un X, donc que r peut référer à quelque chose de plus précis, comme par exemple à un enfant de X, et on sait que p pointe au moins vers un X (mais peut-être aussi vers un dérivé de X).

Ceci permet entre autres de définir des abstractions puissantes comme des classes abstraites et du polymorphisme, pour exprimer des algorithmes applicables à des familles entières de classes (par exemple à tout ce qui est au moins Affichable). La programmation contemporaine serait bien mal en point sans le concept d'accès indirect aux entités des programmes.

Les langages comme Java et les langages .NET utilisent des références mais dans un sens proche de celui des pointeurs en C++ : elles ne permettent pas l'arithmétique sur des adresses mais sont amovibles et peuvent pointer sur plusieurs entités distinctes au cours de leur existence.Cet article se concentre sur le cas de C++ qui supporte les deux concepts plutôt que de se limiter à un seul.

Syntaxe des pointeurs et des références

Commençons par défricher la question de la syntaxe des pointeurs et des références, source de bien des mots et de bien des maux.

Le code à droite montre comment il est possible de déclarer un pointeur sur un int. Le pointeur p n'est pas initialisé, ce qui peut être risqué (sa valeur n'est pas connue au préalable) alors que q est ce qu'on nomme un pointeur nul, au sens où il pointe vers une adresse considérée illégale par convention, peu importe le type du pointeur.

int *p;
int *q = 0;
int *qq = nullptr;

Le symbole * comme préfixe à une donnée (variable ou constante) lors de sa déclaration fait d'elle un pointeur. Le * apparaît entre le type et le nom de la donnée et son type, et s'associe au nom, pas au type. Ainsi, dans le code à droite, p est un pointeur sur un entier et q est un entier.

int *p,
    q;

Pour faire pointer un pointeur vers une adresse précise, deux options sont possibles :

  • Si le lieu vers lequel le pointeur doit mener est une donnée, qu'il s'agisse d'une variable ou une constante, alors il est possible de prendre l'adresse de cette donnée en utilisant l'opérateur &
  • Si le lieu vers lequel le pointeur doit mener est une adresse spécifique, alors il suffit d'affecter cette adresse au pointeur souhaité. L'affectation de l'adresse 0 à un pointeur est un cas particulier de cette approche
int i = 3;
int *p = nullptr; // p est nul
p = &i; // p pointe sur i
// p mène à l'adresse 0x1000... à vos risques et périls!
p = reinterpret_cast<int*>(0x1000);

Notez que la constance ne peut être brisée par l'utilisation de pointeurs (du moins pas sans mentir au compilateur en utilisant une conversion explicite de types).

Il est possible d'augmenter la sécurité (de faire mener un pointeurs vers un objet const sur un pointé qui ne l'est pas) mais pas l'inverse – on ne peut faire pointeur un pointeur vers un objet non const vers une donnée elle-même const.

const int VAL = 5;
int i = 3;
int *q = &VAL; // illégal, bris de sécurité
const int *pp = &i; // légal, gain de sécurité
const int *qq = &VAL; // légal, sécurité maintenue

Pour accéder au contenu pointé par un pointeur, il faut le déréférencer à l'aide de l'opérateur * à un seul opérande. Ainsi, l'extrait de programme proposé à droite affichera 3.

Notez que déréférencer un pointeur nul mène à un comportement indéfini. En pratique, ça va habituellement « planter raide ».

Remarquez que l'astérisque placé entre le type et le nom à la déclaration de p signifie que p est un pointeur, alors que l'astérisque placé avant le nom de p une fois celui-ci déclaré signifie déréférencer p (accéder au contenu vers lequel p pointe).

int i = 3;
int *p; // p est un pointeur de int
p = &i; // p pointe vers i
cout << *p; // déréférencer p

De manière alternative, avec un pointeur sur un struct ou une class), il est possible d'utiliser l'opérateur d'accès à un membre (l'opérateur ->) plutôt que de déréférencer un pointeur puis d'utiliser l'opérateur . pour accéder à un attribut ou à une méthode de l'entité pointée.

Dans l'exemple à droite, chaque affichage est équivalent et présentera le texte "Nb. car. : 4" à l'écran.

string s = "Allo";
string *p = &s;
// accès « direct » à la méthode
cout << "Nb. car. : " << s.size() << endl;
// accès par déréférencement de pointeur
cout << "Nb. car. : " << (*p).size() << endl;
// accès à travers l'opérateur ->
cout << "Nb. car. : " << p->size() << endl;

Il est possible de prendre l'adresse d'un pointeur, ce qui donne un pointeur sur un pointeur. Tout compilateur C ou C++ permettra au moins six niveaux d'indirection de cette manière (et souvent plus de six).

int i = 3;
int *p = &i; // p est un pointeur de int
int **q = &p; // q est un pointeur de pointeur de int
int ***qq = &q; // et ainsi de suite

Dans le cas des références, on remarquera à la fois des similitudes et des différences avec les exemples sur les pointeurs proposés plus haut.

Par exemple, dans le code proposé à droite, r est une référence illégale du fait qu'elle n'est pas liée à un référé dès sa déclaration alors que rr est légale car elle réfère à i.

int i = 3;
// illégal: une référence doit être liée
// à quelque chose dès sa définition
int &r;
// légal: rr réfère à i
int &rr = i;

Le symbole & comme préfixe à une donnée (variable ou constante) lors de sa déclaration fait d'elle une référence. Le & apparaît entre le type et le nom de la donnée et son type, et s'associe au nom, pas au type.

Ainsi, dans le code à droite, r est une référence sur un entier et rr est un entier. La nuance entre les deux est que modifier r modifiera en fait i, alors que rr est totalement distincte de i (outre le cas de sa valeur initiale).

int i = 3;
int &r = i,
    rr = i;

Les références ne peuvent être déplacées ou liés à une autre entité que celle auxquelles elles réfèrent lors de leur définition.

Elles ne peuvent pas non plus briser la sécurité des objets auxquels elles réfèrent (outre un mensonge de la programmeuse ou du programmeur comme par exemple une conversion explicite de types).

const int VAL = 5;
int i = 3;
int &r = VAL; // illégal, bris de sécurité
const int &rr = i; // légal, gain de sécurité
const int &rrr = VAL; // légal, sécurité maintenue

Contrairement aux pointeurs, avec lesquels il est possible d'avoir un pointeur sur un pointeur, il est illégal en C++ d'avoir recours à une référence sur une référence.

La confusion de la majorité des gens pour ce qui est des pointeurs et des références relève de la présence dans les deux cas du symbole & (qu'on nomme, selon les sources, perluette ou esperluette). La nuance entre référence sur et adresse de est relativement simple :

  • Le symbole & détermine une référence lorsqu'il est utilisé dans une déclaration ou dans une définition, donc entre un type et un nom. Dans l'exemple à droite, r est une référence parce que le symbole & précède le nom r et suit le type int
int i = 3;
int &r = i;
  • Notez que la déclaration d'un paramètre dans un sous-programme est une déclaration à part entière. À droite, r est une référence constante sur un int et rr est une référence non constante sur un float
void f(const int &r, float &rr);
  • Le symbole & détermine un pointeur lorsqu'il est utilisé comme préfixe à une donnée nommée et déjà définie. Dans l'exemple à droite, &i dénote l'adresse de i car la variable i a été définie au préalable
int i = 3;
int *p = &i;

Voilà pour la syntaxe.

Bref aperçu de l'artihmétique de pointeurs

Note : pour en savoir plus, voir ../AuSecours/Bref-pointeurs.html

L'arithmétique sur les pointeurs permet d'aller à une adresse précise en mémoire à partir d'une adresse donnée. Cette arithmétique repose en partie sur les types impliqués dans chacun des calculs.

Dans l'exemple proposé à droite, p pointe sur PREMIERS auquel ont aurait ajouté 2 fois la taille d'un int (car PREMIERS est l'adresse du premier élément du tableau, ce qui équivaut à &PREMIERS[0]). C'est pourquoi en afficher le contenu pointé affichera 5.

const int PREMIERS [] = {
   2, 3, 5, 7, 11
};
const int *p = PREMIERS + 2;
cout << *p; << endl; // affichera 5

Pour une réflexion sur la puissance du concept de pointeur, voir cette entrevue avec Alex Stepanov, concepteur de la bibliothèque STL.

Vous remarquerez sans doute que le compilateur ne peut rien faire contre les débordements de capacité de tableaux dû au fait que les opérations sur des tableaux sont, fondamentalement, des opérations sur des adresses, donc de la vulgaire arithmétique sur des pointeurs. Là se situe toute la puissance et tout le danger derrière ce concept.

De manière générale, si tab est un tableau de type T et si i est un entier positif, alors tab[i] équivaut à *(tab + i) et tab + i équivaut à &(tab[i]).

Ceci signifie que les tableaux peuvent être traités comme de vulgaires (!) pointeurs et que l'inverse est aussi vrai – fondamentalement, ce sont deux facettes d'une même idée. Il est donc possible (mais très dangereux) de mentir au compilateur et d'essayer d'écrire à des endroits arbitraires en mémoire. Ceci permet entre autres choses d'écrire des programmes capables d'accéder à des lieux spécifiques en mémoire et d'entrer en contact avec le matériel de l'ordinateur.

Si vous écrivez des horreurs comme celle montrée dans l'exemple proposé à droite, votre code plantera sans doute violemment et ce sera bien fait pour vous!

int *p = 0x10001000; // disons
*(p + 0x40) = 3; // OUCH!!!

Mécanique des références

Avec les références, l'arithmétique de pointeurs est interdite. C'est en général une bonne chose (et, quand le besoin de faire de l'arithmétique de pointeurs surgit... alors on utilise des pointeurs, tout simplement). Règle générale, donc, cherchez à privilégier les références quand vos programmes nécessitent des accès indirects (des indirections) aux données.

Voyons un peu pourquoi les références ne permettent pas l'arithmétique de pointeurs.

#include <iostream>
int main() {
   using namespace std;
   int i = 3;
   int &j = i;
   // affichera 3,3
   cout << i << "," << j << endl;
   j = 4;
   // affichera 4,4
   cout << i << "," << j << endl;
   i = 5;
   // affichera 5,5
   cout << i << "," << j << endl;
}

L'élément le plus simple quant à l'usage des références est probablement l'un des moins connus : l'utilisation de références en tant que variables toutes simples.

Dans le petit programme ci-dessus, la variable j est une référence menant au même endroit que la variable i. Sur le plan de l'utilisation, on peut donc dire que j est une sorte d'alias pour: bien qu'en pratique, j contienne en fait l'adresse de i, le programme peut être rédigé en considérant à toutes fins pratiques j et i comme parlant de la même chose, comme référant à la même donnée – d'où le nom de référence.

Puisque i est un entier et puisque j est une référence sur i, modifier i ou modifier j signifie modifier la donnée en un même lieu. L'extrait de programme offert en exemple en fait clairement la démonstration : modifier i ou modifier j puis afficher le « contenu » démontre clairement que les « valeurs » des deux variables sont identiques après modification, donc que la donnée peut être modifiée à travers i comme à travers j.

Les guillemets français du paragraphe précédent ne sont pas accidentels, et visent à mettre en relief une nuance entre la syntaxe et la nature des deux entités que sont l'entier i et la référence sur un entier j.

Comme cherche à le démontrer cet article du Code Project, une référence peut être perçue sur le plan structurel comme un pointeurs constant vers un pointé qui peut ne pas l'être – vers un lieu fixé dès la déclaration. Cependant, le compilateur fait un peu de sucre syntaxique pour nous et allège fortement l'écriture de programmes utilisant des références au lieu d'avoir recours à des pointeurs constants. Cependant, notez que ce n'est qu'une illustration, et rien ne dit qu'une référence soit implémentée ainsi en pratique. Une référence est un alias, pas un pointeur.

L'exemple suivant aura le même effet que l'exemple précédent, mais définira j comme étant un pointeur constant (notez que cela n'est pas la même chose qu'un pointé constant – ici, pas le droit de modifier j mais il est légal de modifier *j, donc là où pointe j).

#include <iostream>
int main() {
   using namespace std;
   int i = 3;
   int * const j = &i;
   // affichera 3,3
   cout << i << "," << *j << endl;
   *j = 4;
   // affichera 4,4
   cout << i << "," << *j << endl;
   i = 5;
   // affichera 5,5
   cout << i << "," << *j << endl;
}

Prendre l'adresse d'une référence ne donnera toutefois pas l'adresse de la référence mais bien l'adresse du référé. Cette manoeuvre syntaxique du compilateur est en partie ce qui réduit les risques de manoeuvre dangereuses de la part des programmeuses et des programmeurs.

Ainsi, dans l'extrait de programme proposé à droite, les deux adresses affichées seront les mêmes, et ce bien que i et r soient en pratique deux entités différentes.

int i = 3;
int &r = i;
// deux fois « la même chose »
cout << &i << ' ' << &r << end;;

Avec des pointeurs, en retour, la situation est évidemment différente puisque le pointeur a, sémantiquement, une existence distincte de ce vers quoi il pointe.

int i = 3;
int *p = &i;
// deux adresses différentes
cout << &i << ' ' << &p << endl;

Lectures complémentaires

Quelques liens pour enrichir le propos :


Valid XHTML 1.0 Transitional

CSS Valide !