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

Laisser un commentaire