Passer un enum par paramètre : mauvaise pratique ?

Article original : Passing an enum by parameter | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Mettons que vous ayez une fonction qui prend deux paramètres :

  • Le premier est un enum décrivant un « mode d’utilisation » de la fonction.
  • Le second est une valeur numéraire servant de paramètre de calcul.

En l’occurence, prenons l’exemple suivant :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
void increase_and_square(const ChangeMode& m, int v)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
// main function
int main()
{
    const int a = 42;
    increase_and_square(ChangeMode::Before, a);
    increase_and_square(ChangeMode::After, a);
}

Ici, la fonction increase_and_square() fonctionne légèrement différemment selon la valeur du « mode ».
C’est un comportement plutôt commun qui peut survenir sous de nombreuses formes différentes.

Néanmoins, cette implémentation-ci n’est pas la plus optimisée. Une meilleure façon d’obtenir ce comportement est d’utiliser un template sur l’enum ChangeMode plutôt que de le passer en paramètre :

Comme ceci :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
template<ChangeMode m>
void increase_and_square(int v)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
// main function
int main()
{
    const int a = 42;
    increase_and_square<ChangeMode::Before>(a);
    increase_and_square<ChangeMode::After>(a);
}

Cette implémentation est meilleure sous tous les angles :

  • Le nombre d’opération effectuées à la compilation : bien sûr, on y retrouve le principal avantage des templates qui est d’évaluer des expressions à la compilation, ce qui économise du temps d’exécution.
  • Optimisations du compilateur : comme on effectue plus d’opération à la compilation, le compilateur sera plus à même d’optimiser le code, ce qui dans les fait lui permettra de se départir des conditionnelles if.
  • Taille de l’exécutable : plus surprenamment, l’exécutable du code templaté est plus petit que l’autre. C’est parce que le fait de supprimer les if permet de grandement réduire la taille des fonctions.

Pour illustrer cela, voici l’instanciation des tempates que le compilateur génère :

void increase_and_square<ChangeMode::Before>(int v)
{
    ++v;
    v = v*v;
    std::cout << v << std::endl; 
}
 
void increase_and_square<ChangeMode::After>(int v)
{
    v = v*v;
    ++v;
    std::cout << v << std::endl; 
}

Elles sont bien plus simples que la grosse fonction qui contient les deux if.

Si vous n’êtes toujours pas convaincu, voici le nombre d’instructions assembleur générées par le compilateur dans les deux cas (en utilisant godbolt.org avec le compilateur clang est le options de compilation --std=c++20 -O3) :

  • Avec paramètre : 123 instructions
  • Avec template : 76 instructions

La version templatées est plus concise, plus rapide et plus belle.

Avantage : avoir deux valeurs par défaut disjointes

Utiliser un template nous offre un autre avantage : la capacité de préciser une valeur par défaut à la fois pour le mode et pour le paramètre, et ce de manière disjointe.

On peut ainsi écrire cela :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
template<ChangeMode m=ChangeMode::Before>
void increase_and_square(int v = 2)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
int main()
{
    const int a = 42;
    increase_and_square(a);
    increase_and_square<ChangeMode::After>();
    increase_and_square();
}

Cette notation est intéressant car le mode et le paramètre ont en réalité une signification sémantique différente, cela a donc du sens qu’elles aient un comportement disjoint comme celui-là.

Limitations

Au final, pourquoi tout ça marche aussi bien ? Tout simplement parce que le « mode » est un enum avec peu de valeurs possibles.

Si jamais nous avions un plus gros enum (comme par exemple avec cinq valeurs ou plus) ou un autre type au lieu d’un enum, alors la version templatée serait bien pire que la version paramétrée.

De manière générale, la pratique décrite dans cet article ne vous sera pas très utile, mais dans le cas spécifique où vous avez un « mode d’utilisation », alors prenez le temps de réfléchir à quelle est la meilleure pratique à suivre.

À propos de sémantique

Je concluerai cet article en parlant un peu de sémantique.

Ce qu’on a accompli ici pourrait être considéré comme une surcharge de la fonction increase_and_square(). En effet, on a réalisé plusieurs implémentations de cette fonction (enfin, le template l’a réalisé pour nous) qui décrit un comportement similaire même si légèrement différent. C’est exactement l’utilité des surcharges d’opérations.

C’est aussi pour ça que l’enum est décrit comme un « mode » : il sert plus à décrire comment la fonction fonctionne que ce n’est un véritable paramètre.

Merci de votre attention et à la semaine prochaine !

Article original : Passing an enum by parameter | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

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