Référence

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.

Définition: Lvalue et rvalue

Une expression dont la durée de vie est limitée à son évaluation représente une rvalue.
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.

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
    }

La syntaxe nous interdit de créer une référence sans l’initialiser. Ainsi, on ne peut écrire :

int main()
{
        int & a;      ==> syntaxe IMPOSSIBLE - il faut une initialisation
}

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’exécution de la ligne : 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.

REGLE : On ne doit pas retourner une référence sur une variable locale à une fonction.

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. C’est une faiblesse du langage.

Référence en lecture seule

Le qualificatif const sur une référence est utilisé pour que le compilateur interdise toute modification de l’élément référencé.

Utiliser une const référence sert :

  • de documentation pour le programmeur.

  • à protéger un argument de toute manipulation non désirée.

Voici un exemple :

#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
}

Lorsque vous lisez une déclaration de la forme :

void F(const T & obj);

vous savez que vous pouvez transmettre votre élément de type T sans risque qu’il soit modifié par cette fonction.

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.

Exercices

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.