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 = c
4.
À 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
- 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.
- 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.
- 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.
- 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 classeFoo
) est valide. Ça peut être pratique, si par exemple vous voulez renvoyer un code d’erreur.
- 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 blocif
, alors que dans les exemples du début, elle ne vit à travers toute l’étendue dumain
. Mais puisque c’est techniquement la même chose dans cet exemple particulier (parce qu’il n’y a rien après le blocif
), je n’ai jugé nécessaire de m’étendre sur la question.