a = b = c, une conséquence singulière de l’associativité d’opérateurs

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cas d’étude

Si vous codez en C++ régulièrement, vous avez sans doute déjà rencontré la syntaxe suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo;
    if (my_foo = make_Foo())
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

D’un point de vue sémantique, c’est équivalent à la chose suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo = make_Foo();
    if (my_foo)
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

C’est le sujet d’aujourd’hui : l’assignation au sein d’une expression.

Comment ça marche ?

Quelle est la valeur d’une telle expression ?

Pour le savoir, il suffit de lancer le code suivant :

int main()
{
    int foo = 2;
    std::cout << (foo = 3) << std::endl;
    return 0;
}

La sortie standard nous affiche 3.

On peut donc en déduire que l’expression d’assignation est évaluée comme la variable assignée, après qu’elle le soit1.

Une typo catastrophique

Mettons qu’on a trois variables, a, b et c. Nous voulons que la valeur de a soit true si et seulement si b et c sont égales.

Pour cela, on va écrire ceci :

bool a, b, c;
// ...
a = b == c;

Mais, on n’est pas très loin d’une petite typographie qui peut tout changer :

bool a, b, c;
// ...
a = b = c;

Ce code va compiler et ne vous donnera pas le résultat attendu. Pourquoi ?

L’expression a = b = c sont deux opérations d’assignation au sein d’une même expression. Selon la Table de Précédence des Opérateurs en C++, l’associativité de = est de droite à gauche. Donc l’expression a = b = c est équivalente à a = (b = c).

Puisque (b = c) est évaluée (comme vu précédemment) comme la variable b après assignation, écrire a = b = c; est équivalent à écrire b = c; a = b;.

Si ensuite vous utilisez a en tant que booléen, il sera évalué à true si et seulement si c vaut également true.

Conclusion à propos de a = b = c

Il peut y avoir des cas où cette syntaxe (celle avec les deux = en une seule expression) peut être utile, mais la plupart du temps je la trouve absconse et déroutante.

Aujourd’hui, il n’y a pas de manière efficace de prévenir la typographie (les parenthèses ne vous sauveront pas dans ce cas). Tout ce qu’on peut faire, c’est ouvrir les yeux et utiliser des constantes autant que possible (oui, si b est const, alors la typo est attrapée à la compilation)2.

L’opération d’assignation renvoie une lvalue

Reprenons le a = b = c de tout à l’heure et ajoutons des parenthèses autour de a = b :

int main()
{
    int a = 1, b = 2, c = 3;
 
    (a = b) = c;
 
    std::cout << a << b << c << std::endl;
    return 0;
}

Ça compile et affiche la sortie suivante : 323.

Cela signifie qu’on a assigné à a la valeur de b, puis la valeur de c. L’expression a = b est bien une lvalue.

void foo(int&);
 
int main()
{
    int a = 1, b = 2;
 
    foo(a = b); // Compile parce que `a = b` est une lvalue
    foo(3); // Ne compile pas parce que `3` est une rvalue
 
    return 0;
}

Plus spécifiquement, l’opération d’assignation renvoie une référence vers la variable concernée.

Opération d’assignation pour les types personnalisés

Vous aurez peut-être déjà remarqué que l’opérateur operator= peut, d’après le standard, renvoyer n’importe quel type (je vous renvoie à la section Canonical implementations de operator overloading – cppreference.com pour plus de détails3).

Vous pouvez bien entendu renvoyer une référence vers l’objet assigné :

struct Foo
{
    Foo& operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c;
    return 0;
}

Vous pouvez aussi renvoyer une valeur plutôt qu’une référence :

struct Foo
{
    Foo operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Fonctionne aussi, mais fait une copie
    return 0;
}

Puisque le résultat est copié, l’assignation b = c devient une rvalue. Du coup, si vous essayez de prendre une référence de cette expression, vous avez une erreur de compilation :

struct Foo
{
    Foo operator=(const Foo& other) 
    { 
        val = other.val; 
        return *this; 
    }
    int val;
};
 
int main()
{
    Foo b = {1}, c = {2};
    Foo & a = b = c; // Ne compile pas parce qu'ici, (b = c) est une rvalue
    return 0;
}

Ce code compilerait si l’operator= renvoyait un Foo& plutôt qu’un Foo.

Vous pouvez également ne rien renvoyer du tout (en utilisant void comme valeur de retour). Dans ce cas, a = b = c ne compile plus du tout :

struct Foo
{
    void operator=(const Foo&) {  }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Ne compile pas parce que (b = c) ne renvoie rien
    return 0;
}

Ça peut être une bonne manière de prévenir la syntaxe a = b = c4.

À propos des déclarations

Il y a des cas spécifiques où vous pouvez écrire des déclarations au sein d’une autre instruction (un peu comme les assignations qu’on a vu au début).

Vous pouvez utiliser cette syntaxe spécifique dans la plupart des instructions de contrôle de flux (comme if, while, switch et, bien entendu, for) et dans les appels de fonction.

Par exemple, le tout premier exemple de cet article peut également être écrit comme suit :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    if (Foo * my_foo = make_Foo())
    {
        // ...  On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

Cependant, la déclaration elle-même n’est ni une lvalue, ni une rvalue.

Vous ne pouvez pas écrire ceci :

int main()
{
    int a = 1, c = 3;
    a = (int b = c); // Ne compile pas
 
    return 0;
}

Ni cela :

int main()
{
    int b = 2, c = 3;
    (int a = b) = c; // Ne compile pas
 
    return 0;
}

Les instructions où il est possible de faire des déclarations sont indiquées comme de « init-statements » dans le standard, comme ceci :

Conclusion générale

Les syntaxes comme a = b = c et if (a = b) sont intentionnelles et bien définies dans le standard. Cependant, elles sont étrangères à beaucoup de développeurs et développeuses et sont si rarement utilisées qu’elles peuvent être déroutantes.

Des bugs peuvent survenir à cause du fait que le symbole = ressemble beaucoup au digramme ==, soyez donc attentif à ça. Si vous voulez l’éviter avec vos types personnalisés, vous pouvez déclarer l’operator= pour qu’il renvoie void et qu’ainsi la syntaxe a = b = c devienne invalide. Mais ce n’est pas possible avec les types fondamentaux, et c’est une contrainte pas forcément bienvenue.

Merci de votre attention et à la prochaine fois!

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. En réalité, c’est évalué comme une référence à la variable et pas comme la valeur de la variable. Ce sera démontré plus loin dans l’article.
  1. Il est possible d’activer des warnings spécifiques pour prévenir certains cas (par exemple, -Wparentheses peut être utilisé sous GCC pour éviter l’assignation au sein d’un contrôle de flux), mais ça ne couvre pas tous les cas (typiquement, a = b = c n’a pas de warning associé) et parfois vous ne voudrez pas les activer, en fonction de votre affinité avec cette syntaxe.
  1. Le site cppreference.com dit que « par exemple, les opérateurs d’assignation renvoie par référence pour rendre possible la syntaxe a = b = c = d, parce que les opérateurs fondamentaux le permettent. ». Cependant, je n’ai trouvé aucune mention de cette intention spécifique dans la quatrième édition de The C++ Programming Language de Bjarne Stroustrup. Je suspecte que c’est une interprétation libre.
  1. Vous pouvez, comme vous vous en serez peut-être douté, renvoyer n’importe quel type, si vous avez des besoins spécifiques. Le prototype int operator=(const Foo&); (membre de la classe Foo) est valide. Ça peut être pratique, si par exemple vous voulez renvoyer un code d’erreur.
  1. Il y a une différence en terme de pragmatique sur la durée de vie des variables (qui n’est pas le sujet d’aujourd’hui), car dans l’exemple, la variable my_foo ne vit que le temps du bloc if, alors que dans les exemples du début, elle ne vit à travers toute l’étendue du main. Mais puisque c’est techniquement la même chose dans cet exemple particulier (parce qu’il n’y a rien après le bloc if), je n’ai jugé nécessaire de m’étendre sur la question.

Une réflexion sur « a = b = c, une conséquence singulière de l’associativité d’opérateurs »

  1. Bonjour (ou bonsoir),

    Les init-statements mentionnés dans cppreference sont en faite une fonctionnalité apparue en C++17 permettant d’initialiser une variable (entre autres) avant d’évaluer la condition (pour les «if», «switch», et à partir de C++20, pour les «for»-range).

    Par example, le premier example pourrait être réécrit:

    if (Foo* my_foo = make_Foo(); my_foo) {
    /* faire des trucs de foo */
    }

    où le « Foo* my_foo = make_Foo() » est l’«init-statement».

    Jason Turner a fait deux vidéos sur le sujet dans sa série C++ Weekly, épisodes 21 et 130 (j’aurais mis les liens mais je crains que ce commentaire soit masqué).

Laisser un commentaire