Copie et référence
La première question à se poser
Dans le code C++, il faut faire attention au signe = en se posant la question :
Que se passe-t-il lorsqu’on écrit : A = B ?
Dans tous les cas, la variable A va contenir une information identique à la variable B. Cependant, deux scénarios peuvent se produire :
Soit la variable A est une copie indépendante de la variable B et ces deux variables vont évoluer séparément dans le programme : toute action ou modification de l’une ne se reportera pas sur l’autre.
Soit la variable A est une référence à la variable B et, dans ce cas, elle est juste un alias vers la variable B : lorsque l’on utilise le nom A, on manipule en fait la variable originale B.
Note
Il existe un troisième scénario où la variable A est une copie partielle de la variable B. Dans ce cas, certains éléments de la variable A sont des copies et d’autres des références. A notre niveau, nous éviterons cette situation car elle est source de nombreux bugs.
La réponse à cette question n’est pas immédiate car elle dépend fortement du contexte et notamment du type de A et de B. Nous allons dans la suite examiner différents scénarios en les testant par des exemples.
Types fondamentaux et affectation
Veuillez exécuter le code suivant dans votre environnement préféré, puis analysez les affichages.
#include <iostream>
int main()
{
// type entier
int a = 10;
int b = a;
b++;
std::cout << a << " " << b << std::endl;
// type double
double c = 5;
double d = c;
d++;
std::cout << c << " " << d << std::endl;
// type bool
double e = true;
double f = e;
f = !f;
std::cout << e << " " << f << std::endl;
}
Pour chaque type testé, indiquez si l’affirmation correspondante est vraie ou fausse :
L’affectation sur des variables de type entier produit une copie. |
L’affectation sur des variables de type double produit une copie. |
L’affectation sur des variables de type booléen produit une copie. |
Structure et affectation
Examinons maintenant le type structure. Veuillez exécuter le code suivant dans votre environnement préféré, analysez ensuite les affichages.
#include <iostream>
struct v
{
int x;
int y;
};
int main()
{
// type structure
v B;
B.x = 10;
B.y = 20;
v A;
A = B; // le cas qui nous intéresse
A.x += 5;
A.y += 5;
// les affichages
std::cout <<B.x << " " << B.y << std::endl;
std::cout <<A.x << " " << A.y << std::endl;
}
Indiquez si l’affirmation suivante est vraie ou fausse :
L’affectation sur des variables de type structure produit une copie. |
Passage de paramètres et types fondamentaux
Le passage d’un argument à une fonction peut soit déclencher une copie soit mettre en place une référence. Nous allons donc vérifier ce qu’il se passe en exécutant le code suivant et en analysant les résultats.
#include <iostream>
void F(int a, double b, bool c)
{
std::cout << "Entrée de fonction : " << a << " " << b << " " << c << std::endl;
a += 1;
b += 1;
c = !c;
std::cout << "Sortie de fonction : " << a << " " << b << " " << c << std::endl;
}
int main()
{
// type entier
int a = 10;
double b = 20.0;
bool c = true;
std::cout << "Avant l'appel : " << a << " " << b << " " << c << std::endl;
F(a,b,c);
std::cout << "Après l'appel : " << a << " " << b << " " << c << std::endl;
}
Pour chaque type testé, indiquez si l’affirmation correspondante est vraie ou fausse :
Le passage de paramètres avec un type entier produit une copie. |
Le passage de paramètres avec un type double produit une copie. |
Le passage de paramètres avec un type booléen produit une copie. |
Passage de paramètres et structure
Le langage a-t-il voulu rester dans la logique des comportements précédents ou a-t-il choisi une autre stratégie ? Veuillez exécuter le code suivant, puis analysez les résultats.
#include <iostream>
struct v
{
int x;
int y;
};
void F(v a)
{
std::cout << "Entrée de fonction : " << a.x << " " << a.y << std::endl;
a.x += 1;
a.y += 2;
std::cout << "Sortie de fonction : " << a.x << " " << a.y << std::endl;
}
int main()
{
// type structure
v a;
a.x = 10;
a.y = 20;
std::cout << "Avant l'appel : " << a.x << " " << a.y << std::endl;
F(a);
std::cout << "Après l'appel : " << a.x << " " << a.y << std::endl;
}
Indiquez si l’affirmation suivante est vraie ou fausse :
Le passage de paramètres avec un type structure produit une copie des informations contenues dans la structure. |
Conclusion
Suite à nos tests, nous constatons que le C++ a choisi d’avoir un comportement homogène pour l’affectation et le passage d’arguments et ceci pour l’ensemble des types testés. En résumé, nous pouvons conclure par :
REGLE : En C++, l’affectation et le passage d’arguments se font toujours par copie.
Lvalue et rvalue
Cette section introduit des concepts particulièrement difficiles à maîtriser du langage C++. Leur présentation, en apparence accessible, ne saurait résumer toute leur complexité. De ce fait, nous n’allons pas donner leurs définitions exactes mais nous allons essayer de les présenter de manière pragmatique.
Toute information d’un programme est stockée en mémoire. Cependant, certaines entités ont des durées de vie courtes qui se limitent à leurs évaluations. Par exemple, lorsque l’on écrit (a+b)+c, nous allons tout d’abord évaluer le résultat de a+b. Ce résultat existe et il est stocké quelque-part en mémoire, nommons le r. L’étape suivante consiste à prendre ce résultat intermédiaire r et à calculer r+c. Une fois l’opération effectuée, r devient obsolète et cet élément est détruit. La mémoire qu’il occupait est à nouveau disponible. Ainsi le résultat r n’existe plus en mémoire, il ne peut plus être consulté, d’ailleurs, il n’a jamais eu officiellement de nom.
DEFINITION : Lvalue et rvalue
Une expression dont la durée de vie est limitée à l’évaluation de cette expression représente une rvalue. Par opposition une expression dont la durée de vie peut dépasser l’évaluation de cette expression est appelée une lvalue.
Prenons un exemple :
A = B + 1
L’écriture A correspond à la variable A dont la durée de vie perdure au delà de cette ligne de code. Cette variable à gauche du signe = correspond donc à une lvalue. L’expression B+1 désigne un résultat temporaire dont la durée de vie est éphémère, c’est une rvalue.
Note
On ne doit pas limiter la notion de lvalue aux variables. En effet, la définition des lvalues s’étend aux expressions, ce qui est plus large que l’écriture A=. Prenons un exemple : l’écriture T[i]= est une expression (le i-ème élément de T). Mais, c’est aussi une lvalue car sa durée de vie en mémoire dépasse l’évaluation de cette expression.
Avertissement
Historiquement, une lvalue désigne une expression pouvant se trouver à gauche (left) d’une affectation. Mais, une lvalue peut tout aussi bien se trouver à droite comme par exemple dans l’écriture A = A. Une rvalue désigne une expression que l’on trouve à droite (right) d’une affectation. Mais une r-value ne peut jamais se trouver à gauche. Par exemple, l’écriture B+1 = A lève une erreur.
Note
Rvalue et lvalue permettent de classer les expressions en deux groupes. Ne vous trompez pas, ce ne sont pas des types.
La majorité des fonctions disponibles dans le langage C++ retournent des rvalues. Il existe cependant quelques exceptions :
L’opérateur de pré-incrémentation s’applique sur une lvalue et renvoie une lvalue. Ainsi, on peut écrire : (++a) = 7 (sans intérêt, mais possible).
Les opérateurs d’affectation retournent une lvalue correspondant à la lvalue présente à gauche du signe =. Ainsi, on peut écrire (a = T[i]) += 5 ce qui a pour effet d’augmenter de 5 la variable a après son affectation par T[i].
Nous n’allons pas aller plus dans les détails car cela deviendrait très difficile. Cependant, nous tenions à parler de la notion de lvalue et de rvalue car les messages d’erreur du compilateur y font souvent allusion.
Une astuce pour vérifier si une expression peut être une lvalue consiste à écrire cette expression à gauche d’une affectation pour voir si cela a un sens :
a + b = 5; // a+b correspond à un résultat temporaire, impossible
abs(-a) = 5; // abs(-a) désigne un résultat temporaire, impossible
Indiquez si chaque affirmation est vraie ou fausse :
Lvalue et rvalue correspondent à des types |
Une lvalue peut se situer à droite et à gauche dans une affectation |
Une rvalue peut se situer uniquement à droite dans une affectation |
Une lvalue se définit par le caractère éphémère du résultat qu’elle représente |
Une lvalue permet de stocker un résultat |
Si T est un tableau, l’expression T[0] désigne une lvalue |
L’expression a+b désigne une lvalue |
Les références
Référence de variables
Pour créer une référence vers une variable existante, il suffit d’utiliser la syntaxe suivante :
SYNTAXE - Création d’une référence
Type & NomRef = lvalue;
Une référence définit un alias que l’on peut utiliser à la place d’une expression lvalue. Ce scénario inclut aussi le cas d’un alias vers une variable. Aucune copie n’est effectuée, la référence et la donnée originale désignent une même chose. Utiliser l’une ou l’autre est équivalent. Nous pouvons définir des références sur des variables mais aussi sur des lvalues plus complexes. Voici un exemple :
#include <iostream>
int main()
{
int a = 7;
int & b = a; // définition d'une référence vers la variable a
std::cout << b; // ==>> 7
int T[4] = { 1, 2, 3, 4};
int & c = T[2]; // définition d'une référence vers T[2]
std::cout << c; // ==>> 3
}
Avertissement
Il est cependant possible de lier une référence à une rvalue si cette référence est qualifiée de const. Ce choix est un choix de conception des créateurs du C++. Il n’a pas de raison d’être particulière, un autre choix aurait pu être fait. Ainsi vous pouvez trouver cette syntaxe : const int & ref = 4;
Note
Le qualificatif const sur une référence est habituellement utilisé pour que le compilateur interdise toute modification de l’élément référencé.
La syntaxe nous indique que nous devons créer et initialiser une référence au même moment. Il n’est donc pas permis d’associer dynamiquement une référence vers un autre éléments au cours de l’exécution. Prenons un exemple :
int main()
{
int b = 4;
int c = 10;
int & a; ==> syntaxe IMPOSSIBLE - il faut une initialisation
int & a = b; ==> ok
a = c; ==> syntaxe IMPOSSIBLE - pas de référence associée dynamiquement
}
Vérification
Veuillez exécuter le code suivant puis analyser les résultats et confirmer que la référence n’effectue pas de copie :
#include <iostream>
struct v
{
int x;
int y;
};
int main()
{
// type entier
int a = 10;
int & b = a;
b++;
std::cout << a << " " << b << std::endl;
// type struct
v B;
B.x = 10;
B.y = 20;
v &A = B;
A.x += 5;
A.y += 5;
std::cout << A.x << " " << A.y << " --- " << B.x << " " << B.y << std::endl;
}
Référence et passage d’arguments
Un paramètre de fonction qui correspond à une référence ne définit pas une nouvelle variable mais un alias renommant la donnée passée en argument.
Par conséquent, toutes les modifications effectuées sur la référence sont en fait effectuées sur la donnée passée en argument. Voici la syntaxe :
SYNTAXE - Passage d’un argument par référence :
… NomFonction(TypeVar & NomRef, …) { … }
Note
L’utilisation de références dans les paramètres de fonction permet de retourner plusieurs informations. L’instruction return ne permet elle que de retourner un unique élément ce qui peut parfois être limité.
Veuillez exécuter le code suivant puis analyser les résultats et confirmer qu’aucune copie n’est effectuée :
#include <iostream>
struct v
{
int x;
int y;
};
void F(int & b, v & B)
{
b += 1;
B.x += 2;
B.y += 3;
}
int main()
{
// type entier
int a = 10;
// type struct
v A;
A.x = 10;
A.y = 20;
std::cout << "Avant l'appel : " << a << " / " << A.x << " " << A.y << std::endl;
F(a,A);
std::cout << "Après l'appel : " << a << " / " << A.x << " " << A.y << std::endl;
}
Exercices
Fonctions disponibles
int F(int t) { t += 2; return t; } void I(int & t) { t += 7; } int Z(int & t) { t = 1; return 5; } int F(int & a, int & b) { return a+b; }
Exercice 1
int main() { int a; Z(a); I(a); std::cout << a; }
Exercice 2
int main() { int a = 8; a += F(a); I(a); std::cout << a; }
Exercice 3
int main() { int a = 8; std::cout << I(a+8); }
Exercice 4
int main() { int a = 8; int b = 3; std::cout << F(a,b); }
Exercice 5
int main() { int a = 8; int b = 3; std::cout << F(F(b,a)); }
Pour chaque exemple, indiquez l’affichage obtenu ou ERR si le programme émet une erreur :
Exercice 1
Exercice 4
Exercice 2
Exercice 5
Exercice 3
Exercices
Fonctions disponibles
int F(int & t) { t += 2; return t; }
void I(int & t) { t += 7; }
int & Z(int & t) { t = 0; return t; }
int & F(int & a, int & b) { return a; }
Exercice 6
int main()
{
int a = 8;
int b = 3;
std::cout << F(F(b,a));
}
Exercice 7
int main()
{
int a = 8;
std::cout << Z(a+1);
}
Exercice 8
int main()
{
int a = 8;
int b = 3;
Z(F(a,b))++;
std::cout << a;
}
Exercice 9
int main()
{
int a = 8;
std::cout << F(F(a));
}
Exercice 10
int main()
{
int a = 3;
int b = 2;
F(Z(a),Z(b))++;
std::cout << a;
}
Pour chaque exemple, indiquez l’affichage obtenu ou ERR si le programme émet une erreur :
Exercice 6
Exercice 9
Exercice 7
Exercice 10
Exercice 8
Les cas difficiles
Retour de référence depuis une variable locale
Techniquement, vous pouvez définir une fonction retournant une référence :
int & Test(int a)
{
int b = a * a + 3;
return b;
}
int main()
{
int a = 7;
int & c = Test(a);
c++; // cette ligne déclenche une erreur système
std::cout << c;
}
La syntaxe de ce programme est correcte. Le code compile, pourtant son exécution produit une erreur système inconnue. Que se passe-t-il ?
Dans la fonction Test, nous créons une variable b. Cela est autorisée.
Nous retournons ensuite une référence vers cette variable b.
Cette référence sert à initialiser la référence c maintenant associée à b.
L’instruction c++, pourtant correcte, produit une erreur système.
Remontons à la source du problème. La référence c est faite vers la variable locale b de la fonction Test(). Hors cette variable est une variable locale dont la durée de vie s’arrête à la fin de la fonction. Ainsi, cette variable est détruite et la référence retournée désigne une variable n’existant plus. Lorsque l’on essaye d’utiliser cette référence, c’est le crash. A noter que le comportement du programme est indéterminé dans ce genre de situation :
Le programme peut crasher avec ou sans message (souvent sans).
Une autre variable peut être modifiée à la place de b, produisant un bug incompréhensible plus tard dans le programme.
Une partie de la mémoire contenant du code peut être modifiée ce qui fait que le programme peut se bloquer.
Avertissement
On ne doit pas depuis une fonction retourner une référence sur une variable locale.
Une syntaxe d’appel ambiguë
Nous présentons dans le code ci-dessous, un passage par copie et un passage par référence :
#include <iostream>
void F1(int a) { a+= 1; std::cout << a << std::endl; }
void F2(int &a) { a+= 1; std::cout << a << std::endl; }
int main()
{
int a = 5;
F1(a); // passage par copie
F2(a); // passage par référence
}
Le code ne produit pas d’erreur, il semble fonctionner mais là n’est pas le problème. Examinons la définition des deux fonctions disponibles. L’une utilise un passage par copie et l’autre par référence. A ce niveau, tout est clairement explicité grâce à l’utilisation du signe &. Examinons maintenant les appels dans la fonction main(), nous avons : F1(a) et F2(a). A ce niveau, on ne constate aucune différence de syntaxe.
ATTENTION : En examinant un appel de fonction, il est impossible de déterminer si les variables sont passées par copie ou par référence.
Référence en lecture seule
Passer une variable par référence alors qu’elle n’est pas destinée à être modifiée crée une ambiguïté. La solution consiste à ajouter le qualificatif const devant la référence afin de demander au compilateur de garantir l’absence de modification de cette variable. C’est aussi pour le programmeur une information lui indiquant que ce passage par référence se fait sans modifier la variable d’origine. Voici un exemple montrant l’utilité des const reference :
#include <iostream>
void F1(int &a)
{
std::cout << a; // autorisée, il s'agit juste d'une lecture
a++; // autorisée, la référence est modifiée
}
void F2(const int &a)
{
std::cout << a; // autorisée, il s'agit juste d'une lecture
a++; // ERREUR de compilation, tentative de modification d'une const référence
}
Perte de performance
Lorsque l’on transmet un élément prenant de la place mémoire, il est préférable de le passer par référence afin d’éviter une recopie des données. Cependant, si vous effectuez un passage par copie, vous n’aurez pas de message d’erreur, mais vous pourrez constater un ralentissement du programme.
Pour chaque exemple, indiquez l’affirmation est vraie ou fausse :
Si une fonction retourne une référence sur une variable locale, cela la maintient en vie.
L’écriture F(a) correspond uniquement à un passage par copie.
Une const référence indique que l’on ne peut modifier l’élément référencé.
Le passage par référence permet de limiter le temps passé à faire des copies.