De l’utilisation de &= et |=

Article original : About &= and |= | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Il m’arrive assez souvent de voir, sur les projets assez vieux, l’utilisation des opérateurs &= et |= pour mettre à jour un code d’erreur lors d’appels successifs de fonction.
Comme par exemple :

bool cr = true;
cr &= DoOneThing();
cr &= DoOneOtherThing();
cr &= DoALastThing();
if (cr)
    std::cout << "Success !" << std::endl;

Cette écriture, bien concise et pratique, permet a priori d’éviter l’écriture plus verbeuse :

bool cr = true;
cr = cr && DoOneThing();
cr = cr && DoOneOtherThing();
cr = cr && DoALastThing();
if (cr)
    std::cout << "Success !" << std::endl;

Il faut faire attention cependant, car derrière l’emploi de &= et |= il y a un certain nombre de subtilités qu’il est important que chacun comprenne.

Définition des opérateurs &= et |=

Commençons par nous pencher sur ce que signifie réellement les opérateurs &= et |=.
Dans cette section, j’utiliserai &= pour l’exemple, mais |= fonctionne de manière strictement identique.

&= est un opérateur arithmétique binaire, qui réalise l’opération et l’assignation du ET binaire.
Plus précisiémement, écrire ceci :

v1 &= v2;

est équivalent à écrire cela :

v1 = v1 & v2;

hormis que v1 ne sera évalué qu’une seule fois.
Pour plus de précision, consultez le standard C++11 5.17 Assignment and compound assignment operators ou le lien suivant : https://en.cppreference.com/w/cpp/language/operator_arithmetic

Les opérations arithmétiques binaires (qui utilisent les symboles &|^<< et >>) effectuent des opérations bit-à-bit sur chaque paramètre de l’opération.

Exemple :

int a = 0b110;
int b = 0b011;
int c = a & b; // 010
int d = a | b; // 111
int e = a ^ b; // 101

Écrire a &= b; fait donc un ET binaire entre a et b et assigne le résultat à a.

Différences entre &= et &&, |= et ||

Les opérateurs de comparaison && et || sont, eux, des opérateurs logiques. Ils sont donc différents des opérateur & et | qui sont des opérateurs arithmétiques.
L’utilisation des opérateur &= et |= dans un cadre logique peut donc diverger de ce à quoi on peut s’attendre. Voici deux exemples illustrant des comportement inatendus.

Exemple 1

Prenons le code suivant :

#include <iostream>
 
bool foo()
{
    std::cout << "call foo()" << std::endl;
    return true;
}
 
int main() {
    bool c = true;
    std::cout << "Using |=" << std::endl;
    c |= foo();
    std::cout << "Using = and ||" << std::endl;
    c = c || foo();
    std::cout << "End" << std::endl;
    return 0;
}

La fonction foo() retourne toujours true, tout en écrivant sur la sortie standard pour indiquer qu’elle a été appelée.

Dans le main() on crée un booléen c, initialisé à true, et sur lequel on va successivement utiliser l’opérateur arithmétique binaire |= et l’opérateur logique ||.

Voici le résultat sur la sortie standard :

Using |=
call foo()
Using = and ||
End

On remarque que dans le cas de |= la fonction foo() est appelée, alors que ce n’est pas le cas dans le cas de ||.

Cela vient du fait qu’en C++, les opérateur logique sont lazy, c’est à dire que si le paramètre de droite ne change pas le résultat de l’opération, il ne sera pas évalué.
Ici, comme c est déjà true, alors que foo() soit true ou false ne changera pas le résultat : il sera forcément true.

Le cas de |= est une autre affaire. |= n’est pas un opérateur logique : il effectue une opération arithmétique. Le résultat qu’il donne ne dépend pas de l’évaluation logique des paramètres, mais de leur valeur réelle en binaire. Il n’y a pas de sens donc pour lui d’être lazy. Dans le cas d’utilisation des opérateurs arithmétiques binaire, tous les paramètres seront toujours évalués.

Que vous vouliez ou pas que le paramètre de droite soit toujours évalué dépend de vous et de votre contexte. Cependant, dans l’esprit de la plupart des développeur, les opérations logiques sont lazy et ils auront tendance à adopter ce point de vue par défaut.

Exemple 2

Voici un autre exemple qui, même s’il est plus rare en pratique, peut créer de réels problèmes difficiles à investiguer.

#include <iostream>
 
int main() {
    int c1 = true;
    int c2 = 0b10;
 
    if (c1)
        std::cout << "c1 is true" <<std::endl;
    else
        std::cout << "c1 is false" <<std::endl;
 
    if (c2)
        std::cout << "c2 is true" <<std::endl;
    else
        std::cout << "c2 is false" <<std::endl;
 
    if (c1&&c2)
        std::cout << "c1&&c2 is true" <<std::endl;
    else
        std::cout << "c1&&c2 is false" <<std::endl;
 
    c1 &= c2;
 
    if (c1)
        std::cout << "c1&=c2 -> c1 is true" <<std::endl;
    else
        std::cout << "c1&=c2 -> c1 is false" <<std::endl;
 
    return 0;
}

Ici, on créé deux variable : c1, qui vaut true, et c2, qui vaut 0b010.

S’ensuivent quatre blocs effectuant des évaluations logiques.
– On commence par évaluer logiquement c1.
– On évalue de la même manière c2.
– On évalue l’opération logique c1 && c2.
– On effectue l’opéraion arithmétique binaire c1 &= c2 et on évalue c1.

Voici le résultat affiché sur la sortie standard :

c1 is true
c2 is true
c1&&c2 is true
c1&=c2 -> c1 is false

Détaillons en détail le pourquoi et le comment :

Premièrement, c1 est évalué comme vrai. Ce qui est attendu, vu qu’on l’a initialisé à true.

Deuxièmement, c2 est évalué comme vrai. En effet, c2 est un entier différent de 0, il est donc considéré comme logiquement vrai.

Troisièmement, c1 && c2 est évalué comme vrai. On vient de voir que c1 et c2 sont tous les deux vrais, et donc l’opération logique && renvoie vrai.

Quatrièment, on fait l’opération c1 &= c2. En faisant cela, on effectue l’opération c1 & c2 et on affecte le résultat à c1.
Hors, d’après le standard, true vaut 1 en tant qu’entier. c1 & c2 équivaut donc à écrire 0b01 & 0b10. Le résultat de cette opération est bien 0b00, car bit-à-bit, l’opération ET binaire est fausse sur tous les bits.
La valeur 0 étant évaluée à false lors d’une opération logique, c1 est bien faux.

Ce n’est pas un cas qu’on rencontrera si souvent, mais il existe plein de cas pratiques, principalement dans du code legacy, où on peut renvoyer une valeur autre que 1 en guise de valeur vraie.

Conclusion

L’emploi de &= et |= va faire ce qu’on attend de lui dans 99% des cas. Seulement c’est ce 1% qui va causer des bugs complexe à détecter et investiguer.

De plus, j’aimerais soulever un grave problème de sémantique dans ces cas-là. Le fait d’utiliser une opération arithmétique au lieu d’une opération logique trompe le lecteur (ou le force à faire un effort mental supplémentaire) et empêche le compilateur de faire certaines optimisations (rappelez-vous que le compilateur est plus intelligent que nous et saura mieux optimiser, pour peu qu’on lui en laisse l’occasion).

Écrire a = a && b; plutôt que a &= b; ne prends que quelques caractères supplémentaires, mais saura vous préserver des bugs, rendra votre code plus intègre et permet au compilateur de comprendre ce que vous essayez de faire.

Voilà pour cette semaine ! Si vous avez des remarques constructives à faire, que vous soyez d’accord ou pas, n’hésitez pas à le dire en commentaire.

NB : Le compilateur utilisé pour ces exemples et clang, avec les options --std=c++20 -O3

Article original : About &= and |= | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre