Les conteneurs : l’embarras du choix

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Quand on parle de conteneurs en C++, c’est std::vector qui remporte la palme de la plus utile (et utilisée) d’entre toutes (juste devant l’occasionnelle std::map pour les fois où on a besoin d’une association clé-valeur1). Ainsi, c’est facile d’oublier que d’autres types de conteneurs existent.

Chaque conteneur a ses forces et ses faiblesses. Si vous avez tendance à oublier quelles elles sont pour chacun, cet article est un bon début.

Avertissement : Tous les conteneurs C++ ne sont pas listés ici, juste ceux qui sont les plus utiles (d’après moi). Si vous voulez aller plus loin, vous trouverez deux liens en addendum qui vous renverront vers des documentations plus complètes.

Comment choisir un conteneur

Critères

Premier™ critère : séquentiel ou associatif

La première question que vous devez vous poser est : est-ce que mon conteneur est séquentiel ou associatif ?

Dans les conteneurs séquentiels, les données sont organisées de manière ordonnée et séquentielle, avec chaque valeur à la suite de la précédente. En mémoire, ce n’est pas toujours contigüe (et souvent ça ne l’est pas), mais en pratique, vous accédez à une valeur avec l’indice de sa position dans le conteneur.

Contrairement aux conteneurs séquentiels, les conteneurs associatifs ne conservent pas les données en tant que séquence, mais en associant la valeur à une clé. À la place d’utiliser un indice pour accéder à la valeur, on utilise la clé.

Critères des conteneurs séquentiels

  • Est-ce que la taille du conteneur est statique ? Si oui, c’est un std::array que vous cherchez (et les autres critère ne sont de toute façon pas importants). Dans tout autre cas de figure, vous devez vous en référer aux autres critères.
  • Est-ce que la taille du conteneur va beaucoup varier ? Ce critère est important pour l’allocation mémoire. Si vous faites beaucoup varier la taille d’un conteneur qui n’est pas prévu pour ça, vous pourrez ressentir des ralentissements et une surutilisation de la mémoire2.
  • Est-ce que l’ordre est important ? Ce critère concerne les structures de données où on ne peut ajouter et retirer des valeurs qu’au début ou à la fin (c’est à dire les structures FIFO et FILO).
  • Est-ce que vous avez besoin d’insérer/supprimer des valeurs aux extrémités du conteneur (c’est-à-dire au début et à la fin) ? Certains conteneurs sont plus efficaces que d’autres pour ces opérations.
  • Est-ce que vous avez besoin d’insérer/supprimer des valeurs au milieu de la structure ? Tout comme le critère précédent, parfois vous devez faire beaucoup d’ajout et de suppressions, mais au milieu de la structure. C’est l’apanage d’autres types de conteneurs.
  • Est-ce que vous avez besoin de trouver le nième élément ? En fonction du conteneur, la recherche n’est pas toujours optimale.
  • Est-ce que vous avez besoin de fusionner des collections ? En fonction du conteneur, la fusion de deux collections n’est pas toujours optimale non plus (certaines ont besoin de réallouer la mémoire et d’autres non).

Critères des conteneurs associatifs

  • Il y a deux sortes de structures associatives : celles qui associent une clé à une valeur (éventuellement de types différents), ce sont les map, et celles pour lesquelles la clé est la valeur, et ce sont les set.
  • Par défaut, les clés sont uniques. Mais il existe une variante pour lesquelles les clés peuvent avoir plusieurs associations. Ce sont les multimap/multiset.
  • La structure interne de ces conteneurs peut être implémentée de deux façons. Par défaut, elles sont ordonnées par clé. Mais elles peuvent aussi être non-ordonnées et utiliser une clé de hachage. Ce sont les versions unordered_ de chacun des conteneurs.

Note : la distinction ordonné/non-ordonné est importante (voire primordiale) pour les conteneurs associatifs. La plupart du temps, les conteneurs associatifs ordonnés sont implémentés sur des arbres binaires équilibrés, tandis que les non-ordonnés sont des tables de hachage. Cela a un impact sur les performances et sur le fait que, pour les tables de hachage, le type de valeur n’a pas besoin d’implémenter un opérateur de comparaison.

La matrice des conteneurs

Voici deux tableaux – un pour les conteneurs séquentiels et un pour les conteneurs associatifs.

Chaque tableau représente quels critères s’appliquent le mieux pour chaque conteneur3.

Conteneurs séquentiels

Conteneurs associatifs

Résumé sous forme de diagramme de flux

Voici un résumé simple à lire, sous forme d’un diagramme de flux, qui permet de choisir quel conteneur choisir en toute circonstance4 : Joe Gibson’s data structure selection flowchart.

Qu’en est-il de la réalité des gens véritables ?

Comme je l’ai mentionné dans l’introduction, dans la vraie vie std::vector et std::map sont la plupart du temps largement suffisants. Avec ça en tête, à quel point est-il utile de chercher le conteneur « optimal » à chaque fois ?

Sémantiquement, chaque conteneur est lié à la manière dont on l’utilise. Quand vous voyez deque, vous pensez « insérer et supprimer aux extrémités », quand vous voyez queue, vous pensez « insérer au début et supprimer à la fin », quand vous voyez list, vous pensez « insérer et supprimer au milieu ».

On voit chaque conteneur en fonction de comment on les utilise, plus que à quel point ils sont efficaces dans telle ou telle situation (ce qui est lié mais pas équivalent). Il y a beaucoup d’opérations qui sont disponibles dans plusieurs conteneurs à la fois. Par exemple, on peut très facilement ajouter et enlever des valeurs à la fin d’un vector, de même que dans un deque (dans les deux cas, on utilise les fonctions membre push_back et pop_back).

Vous avez peut-être envie d’objecter : « Mais ajouter et supprimer des valeurs à la fin d’un vecteur peut être très inefficace ! » En théorie, oui. Mais en pratique, ce n’est le cas que si vous vous trouvez dans une section critique du code. La plupart du temps, si vous êtes dans les 80% du Principe de Pareto, utiliser un vector plutôt qu’un deque n’aura aucun effet sur les performances.

Laissez-moi alors vous poser la question : si un vector fonctionne bien et n’impacte pas les performances, pourquoi utiliser un autre conteneur ?

Le vecteur est une des structures les plus faciles à comprendre grâce à leur similarité avec les bons-vieux-tableaux. Pour la plupart des développeurs, std::vector est le conteneur qu’ils savent le mieux utiliser. On ne devrait pas complexifier le code le plus mondain ni le rendre plus dur à lire, alors que ce n’est pas nécessaire.

Bien sûr, dès que vous avez des besoins spécifiques, vous devez utiliser le conteneur le plus approprié, mais ça n’arrive pas si souvent.

(J’ai pris std::vector comme exemple ici, mais c’est aussi valable pour std::map à propos des conteneurs associatifs)

Et l’optimisation alors ?

L’optimisation doit être un comportement a posteriori, et non a priori.

Quand vous écrivez du code, vous ne devez pas penser aux performances. Vous devez l’écrire de la manière la plus claire possible.

C’est seulement après que vous pouvez penser à l’impact de ce code sur les performances globales. En faisant éventuellement des benchmarks, des analyses statiques et/ou dynamiques pour savoir si il doit être optimisé et selon quels critères d’optimisation (temps d’exécution ? Utilisation de la mémoire ? etc.).

Si le code que vous écrivez n’est pas dans les 20% du Principe de Pareto, vous ne devriez même pas penser aux performances. S’il est dans ces 20%, vous devez y penser après l’avoir écrit.

Une approche plus efficace

Laissez-moi vous présenter le diagramme de flux suivant, dans le même esprit que celui de Joe Gibson, qui résume les propos de la section précédente :

Le principe est simple. Vous avez deux questions à vous poser :

  1. Est-ce que je peux faire ce dont j’ai besoin avec un vector ou une map ?
  2. Est-ce que je suis dans une section critique du code ?

Si vous répondez « Oui » à la première, et « Non » à la deuxième, alors vous devriez utiliser std::vector ou std::map.

Conclusion

Il est plus qu’utile de connaître les différences entre les conteneurs du C++. Cependant, dans la vie quotidienne, il est plus pratique de ne pas trop y penser. C’est une erreur assez commune de sur-réfléchir à des problématiques qui ont des solutions simples. Mais ces connaissances ne sont pas perdues : de temps en temps, vous serez dans une situation assez spécifique qui nécessitera des connaissances avancées sur les conteneurs.

Le Principe de Pareto peut aussi s’appliquer à cela : plus de 80% des outils qu’on connaît sont utiles dans moins de 20% des situations.

Merci de votre attention et à la semaine prochaine !

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Sources et ressources

Le diagramme de flux de Gibson vient d’un repo GitHub où vous pourrez trouver d’autres informations utiles sur les conteneurs : cpp-cheat-sheet/Data Structures and Algorithms.md at master · gibsjose/cpp-cheat-sheet · GitHub.

Il existe aussi une autre fiche-résumé, plus dense, parlant des conteneurs (et d’autres sujets également) : C++ Cheat Sheets & Infographics | hacking C++ (hackingcpp.com).

Le diagramme de flux pour le choix des structures de données de Joe Gibson


Joe Gibson’s data structure selection flowchart (source: GitHub – gibsjose/cpp-cheat-sheet: C++ Syntax, Data Structures, and Algorithms Cheat Sheet)

Notes

  1. En vérité, tout le monde peut se mettre d’accord sur le fait que std::unordered_map est meilleur que std::map. Mais en pratique, il y a bien plus d’usages indiscriminé de map que de sa contrrepartie.
  2. De plus, ce n’est pas juste la taille globale qui importe, mais aussi la taille maximale locale. Par exemple, si un conteneur ne dépassera pas 20 valeurs dans une section critique du code, on peut pré-allouer ces 20 cases pour éviter toute réallocation sauvage, tout en conservant la flexibilité d’une structure dynamique.
  3. Bien sûr, chaque conteneur peut faire la plupart des opérations indiquées. Mais les tableaux résument lesquels sont les plus efficaces pour chacune.
  4. Malheureusement le diagramme ne présente pas les versions unordered_ des conteneurs associatifs. Mais vous pouvez y palier simplement en vous demandant : « Les valeurs doivent être ordonnées ? Si oui, map/set ; Si non, unordered_map/unordered_set« .

Des switch-case plus jolis

Article original : Prettier switch-cases | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

J’ai appris cette syntaxe au cours d’une présentation de la CppCon de 2021, donnée par Herb Sutter : Extending and Simplifying C++: Thoughts on Pattern Matching using `is` and `as` – Herb Sutter – YouTube. On peut aussi retrouver sa présentation sur son blog : Sutter’s Mill – Herb Sutter on software development.

Contexte

Mettons que vous ayez un bloc switch-case sans plongeon1 (c’est important), comme ceci) :

enum class Foo {
    Alpha,
    Beta,
    Gamma,
};
 
int main()
{
    std::string s;
    Foo f;
 
    // ...
    // Do things with s and f 
    // ...
 
    switch (f)
    {
        case Foo::Alpha:
            s += "is nothing";
            break;
        case Foo::Beta:
            s += "is important";
            f = Foo::Gamma;
            break;
        case Foo::Gamma:
            s += "is very important";
            f = Foo::Alpha;
    }
     
    // ...
}

Rien de particulièrement compliqué ici : on ajoute un suffixe à la chaîne en fonction de la valeur de f, parfois en modifiant f au passage.

Maintenant, mettons qu’on ajoute un littéral Delta à l’énumération Foo, qui fait exactement la même chose que Gamma avec une petite différence dans la chaîne. Il y a une bonne chance pour qu’on écrive ceci :

enum class Foo {
    Alpha,
    Beta,
    Gamma,
    Delta,
};
 
int main()
{
    std::string s;
    Foo f;
 
    // ...
    // Do things with s and f 
    // ...
 
    switch (f)
    {
        case Foo::Alpha:
            s += "is nothing";
            break;
        case Foo::Beta:
            s += "is important";
            f = Foo::Alpha;
            break;
        case Foo::Gamma:
            s += "is very important";
            f = Foo::Alpha;
        case Foo::Delta:
            s += "is not very important";
            f = Foo::Alpha;
    }
 
    // ...
}

Le nouveau bloc case est certainement du copier-coller. Mais avez-vous remarqué le bug qui s’est introduit ?

Comme dans première version, le développeur n’a pas jugé nécessaire de rajouter un break à la fin du bloc Gamma (puisque c’était le dernier élément), quand on a copié-collé ce bloc on l’a laissé sans break. Du coup, quand on va passer dans le code de Gamma, on va toujours plonger dans le code de Delta.

Nouvelle syntaxe

La syntaxe présentée dans cet article permet (entre autre) de rendre ce genre de fautes moins fréquent, et de faire en sorte que le code soit un peu plus clair.

La voici :

switch (f)
{
    break; case Foo::Alpha:
        s += "is nothing";
    break; case Foo::Beta:
        s += "is important";
        f = Foo::Alpha;
    break; case Foo::Gamma:
        s += "is very important";
        f = Foo::Alpha;
    break; case Foo::Delta:
        s += "is not very important";
        f = Foo::Alpha;
}

Cela consiste à mettre l’instruction break; juste avant chaque case.

Cela peut sembler étrange au premier regard, car le tout premier break est inutile et il n’y en a pas à la fin, mais cette syntaxe est fonctionnelle et surtout confortable.

En effet, si vous commencez tous vos blocs avec un break; case XXX:, vous êtes garantis de ne jamais faire de plongeon, même avec des hordes de copier-coller.

Avantages

Le premier avantage est celui d’éviter le genre de bug que j’ai mentionné plus haut, où on oublie un break ce qui cause un plongeon indésirable. Et même si vous ne faites pas de copier-coller, l’oubli du break sera visuellement évident puisque votre case ne sera pas aligné avec les autres.

Mais le réel avantage (d’après moi) est que cette syntaxe est, dans sa globalité, visuellement plus agréable. Pour chaque case, on économise une ligne de code (où devrait être le break; normalement), et il sera évident pour quiconque regarde le code que ce switch-case ne contient aucun plongeon.

Bien sûr, la beauté est subjective, y compris en programmation2. Cependant, des choses comme un meilleur alignement, des intentions plus claires et l’économie de lignes3 sont, il me semble, des critères plutôt objectifs.

Avertissement

La première fois que j’ai vu cette syntaxe, j’ai rapidement compris son fonctionnement et son intérêt. Je sais ceci dit qu’il y a plusieurs personnes qui ont dû se les faire expliquer.

Mais c’est presque toujours le cas quand on introduit une nouvelle syntaxe.

Gardez-donc à l’esprit que si vous voulez l’utiliser dans un code partagé, elle risque d’embrouiller vos collègues. Soyez-sûr·e de bien l’expliquer (que ce soit en personne ou en commentaire) pour qu’elle soit rapidement prise en main.

En conclusion

Ça ne va certainement pas changer votre vie quotidienne ni même votre vision du C++, mais je voulais la partager, car je l’aime beaucoup.

C’est une brique de plus dans la construction d’un code plus agréable à lire.

Merci de votre attention et à la semaine prochaine !

Article original : Prettier switch-cases | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Le terme « plongeon » est utilisé ici comme traduction de « fallthrough » en anglais, qui dans le contexte d’un switch-case renvoie au fait qu’on peut se dispenser du mot-clé break pour qu’un bloc case « plonge » dans un autre, comme dans l’exemple suivant :
switch (a)
{
    case 0:
        ++b; // Dans le cas où a == 0, on fait ++b puis on plonge dans le cas 1
    case 1:
        ++b;
        break; // Ici il y a un break, donc on ne plonge pas dans le cas par défaut
    default:
        --b;
}
  1. Surtout en programmation, oserai-je même dire.
  2. L' »économie de lignes » n’est une bonne chose que lorsqu’elle défausse des expressions peu instructives, comme c’est le cas avec une ligne qui contient uniquement l’instruction break;. Jamais vous ne m’entendrez dire que les gros one-liners sont préférables à un code plus détaillés (parce qu’ils ne le sont tout simplement pas). Réunir le break et le case laisse le code respirer. De plus, vous pouvez toujours laisser une ligne vide à l’endroit où était le break auparavant — votre code n’en sera que plus agréable.

Différentes manières pour convertir un enum en string

Article original : Best ways to convert an enum to a string | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Une problématique particulièrement récurrente que les développeurs C++ rencontrent (tous niveaux confondus) est comment afficher la valeur d’un enum de manière claire et lisible.

Le problème est qu’il n’y a pas une seule solution objectivement meilleure que les autres. Cela dépend beaucoup des contraintes liées à l’environnement, les besoins spécifiques et, comme toujours, la version de C++ utilisée.

Cet article compile une petite liste de manière d’ajouter la réflexivité aux enums.

NB : si vous connaissez une autre manière de faire qui a son propre intérêt, n’hésitez pas à la partager en commentaire.

Bien sûr, idéalement on voudrait que cette réflexivité soit statique. Dans la plupart des cas listés ci-dessous, on aura recours au mot-clé constexpr.

La librairie Magic Enum

Magic Enum est une librairie qui fait exactement ce qu’on cherche à faire : elle ajoute de moyens de donner la réflexivité aux enums.

Avec elle, vous pouvez convertir statiquement en chaîne de caractère (et accessoirement depuis une chaîne de caractère) des enums que vous avez définis. En gros, elle implémente l’enum_cast. Elle permet aussi d’itérer sur la liste des littéraux d’un enum.

Vous pourrez la trouver ici : GitHub – Neargye/magic_enum: Static reflection for enums (to string, from string, iteration) for modern C++, work with any enum type without any macro or boilerplate code

Inconvénients

  • C’est une librairie tierce.
  • Ne fonctionne qu’en C++17
  • Vous avez besoin d’une version spécifique de votre compilateur pour qu’elle fonctionne (Clang >= 5, MSVC >= 15.3 et GCC >= 9)
  • Elle a d’autres contraintes liées à son fonctionnement interne (consultez la page Limitations de sa documentation pour plus d’informations : magic_enum/limitations.md at master · Neargye/magic_enum · GitHub)

Utiliser une fonction dédiée (avec exception)

Version statique

constexpr est un mot-clé fabuleux qui nous permet de définir des expressions de manière statiques. Quand on l’utilise dans le type de retour d’une fonction, cela permet au compilateur d’évaluer statiquement le résultat de cette fonction.

Dans cette version, j’ai placé exception dans le default du switch-case pour s’assurer que si on ajoute un littéral mais qu’on oublie de mettre à jour la fonction, l’exception soit levée pour nous prévenir.

#include <iostream>
 
enum class Esper { Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek };
 
constexpr const char* EsperToString(Esper e) throw()
{
    switch (e)
    {
        case Esper::Unu: return "Unu";
        case Esper::Du: return "Du";
        case Esper::Tri: return "Tri";
        case Esper::Kvar: return "Kvar";
        case Esper::Kvin: return "Kvin";
        case Esper::Ses: return "Ses";
        case Esper::Sep: return "Sep";
        case Esper::Ok: return "Ok";
        case Esper::Naux: return "Naux";
        case Esper::Dek: return "Dek";
        default: throw std::invalid_argument("Unimplemented item");
    }
}
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Version dynamique

Le problème est qu’avoir plusieurs return dans une fonction constexpr est une fonctionnalité disponible à partir du C++14. Si vous utilisez une version antérieure, vous devez retirer le constexpr et rendre la fonction dynamique pour qu’elle compile :

#include <iostream>
 
enum class Esper { Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek };
 
const char* EsperToString(Esper e) throw()
{
    switch (e)
    {
        case Esper::Unu: return "Unu";
        case Esper::Du: return "Du";
        case Esper::Tri: return "Tri";
        case Esper::Kvar: return "Kvar";
        case Esper::Kvin: return "Kvin";
        case Esper::Ses: return "Ses";
        case Esper::Sep: return "Sep";
        case Esper::Ok: return "Ok";
        case Esper::Naux: return "Naux";
        case Esper::Dek: return "Dek";
        default: throw std::invalid_argument("Unimplemented item");
    }
}
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Si vous utilisez une version antérieure aux C++11, vous pouvez remplacer l’enum class par un simple enum pour que cela fonctionne.

Inconvénients

  • Avoir plusieurs return dans une fonction constexpr est C++14 (pour la version statique)
  • Il faut écrire une fonction par enum est ladite fonction est très verbeuse.
  • Lève une exception

Utiliser une fonction dédiée sans exception)

Version statique

Parfois, on préfère un code qui ne peut pas lever d’exception, quoi qu’il arrive. Ou peut-être êtes-vous comme moi et compilez votre code avec -Werror. Dans les deux cas, vous pouvez écrire la même fonction que dans l’exemple précédent, sans lever d’exception.

Vous avez juste à surveiller les warnings quand vous ajoutez des éléments à votre enum.

#include <iostream>
 
enum class Esper { Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek };
 
constexpr const char* EsperToString(Esper e) noexcept
{
    switch (e)
    {
        case Esper::Unu: return "Unu";
        case Esper::Du: return "Du";
        case Esper::Tri: return "Tri";
        case Esper::Kvar: return "Kvar";
        case Esper::Kvin: return "Kvin";
        case Esper::Ses: return "Ses";
        case Esper::Sep: return "Sep";
        case Esper::Ok: return "Ok";
        case Esper::Naux: return "Naux";
        case Esper::Dek: return "Dek";
    }
}
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Version dynamique

Une fois de plus, la version dynamique sans constexpr :

#include <iostream>
 
enum class Esper { Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek };
 
const char* EsperToString(Esper e) noexcept
{
    switch (e)
    {
        case Esper::Unu: return "Unu";
        case Esper::Du: return "Du";
        case Esper::Tri: return "Tri";
        case Esper::Kvar: return "Kvar";
        case Esper::Kvin: return "Kvin";
        case Esper::Ses: return "Ses";
        case Esper::Sep: return "Sep";
        case Esper::Ok: return "Ok";
        case Esper::Naux: return "Naux";
        case Esper::Dek: return "Dek";
    }
}
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Inconvénients

  • Avoir plusieurs return dans une fonction constexpr est C++14 (pour la version statique)
  • Il faut écrire une fonction par enum est ladite fonction est très verbeuse.
  • Il y a un risque de ne pas voir les warnings quand un élément est ajouté sans que la fonction soit mise à jour.

Avec une macro

Les macros peuvent faire beaucoup de choses que le code dynamique ne peut pas faire. Voici deux implémentations de réflexivité d’enum avec une macro.

Version statique

#include <iostream>
 
#define ENUM_MACRO(name, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10)\
    enum class name { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10 };\
    const char *name##Strings[] = { #v1, #v2, #v3, #v4, #v5, #v6, #v7, #v8, #v9, #v10};\
    template<typename T>\
    constexpr const char *name##ToString(T value) { return name##Strings[static_cast<int>(value)]; }
 
ENUM_MACRO(Esper, Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek);
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Version dynamique

Très similaire à la version statique, mais celle-ci est compatible avec du code antérieur au C++11 (constexpr et enum class sont des mots-clés apparus en C++11) :

#include <iostream>
 
#define ENUM_MACRO(name, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10)\
    enum name { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10 };\
    const char *name##Strings[] = { #v1, #v2, #v3, #v4, #v5, #v6, #v7, #v8, #v9, #v10 };\
    const char *name##ToString(int value) { return name##Strings[value]; }
 
ENUM_MACRO(Esper, Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek);
 
int main()
{
    std::cout << EsperToString(Kvin) << std::endl;
}

Inconvénients

  • Utilise une macro (je pourrais écrire un article parlant des macros et de leur toxicité au sein d’une codebase — j’en ai déjà un peu parlé et le referai, mais ce n’est pas le sujet ici et maintenant. Cependant, gardez à l’esprit que si vous ne savez pourquoi les macros peuvent être dangereuses, alors vous ne devriez pas les utiliser).
  • Vous avez besoin d’écrire une macro différente chaque fois que vous avez besoin d’un enum avec un nombre d’éléments différents (et elle devra avoir un nom différent, ce qui est pénible).

Avec une macro et Boost

On peut contourner l’inconvénient du « nombre d’éléments dans l’enum » en utilisant des fonctionnalités de Boost.

Version statique

#include <iostream>
#include <boost/preprocessor.hpp>
 
#define PROCESS_ONE_ELEMENT(r, unused, idx, elem) \
  BOOST_PP_COMMA_IF(idx) BOOST_PP_STRINGIZE(elem)
 
#define ENUM_MACRO(name, ...)\
    enum class name { __VA_ARGS__ };\
    const char *name##Strings[] = { BOOST_PP_SEQ_FOR_EACH_I(PROCESS_ONE_ELEMENT, %%, BOOST_PP_VARIADIC_TO_SEQ(__VA_ARGS__)) };\
    template<typename T>\
    constexpr const char *name##ToString(T value) { return name##Strings[static_cast<int>(value)]; }
 
ENUM_MACRO(Esper, Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek);
 
int main()
{
    std::cout << EsperToString(Esper::Kvin) << std::endl;
}

Dans cette version (un peu plus complexe que les précédentes), PROCESS_ONE_ELEMENT « convertit » l’élément en une version « stringifiée » (à l’aide de BOOST_PP_STRINGIZE) et BOOST_PP_SEQ_FOR_EACH_I permet d’itérer sur tous les éléments de __VA_ARGS__ (qui est le pack de paramètres de la macro).

Version dynamique

Une version quasi-identique, mais une fois encore sans constexpr et sans enum class pour être compatible pré-C++11 :

#include <iostream>
#include <boost/preprocessor.hpp>
 
#define PROCESS_ONE_ELEMENT(r, unused, idx, elem) \
  BOOST_PP_COMMA_IF(idx) BOOST_PP_STRINGIZE(elem)
 
#define ENUM_MACRO(name, ...)\
    enum name { __VA_ARGS__ };\
    const char *name##Strings[] = { BOOST_PP_SEQ_FOR_EACH_I(PROCESS_ONE_ELEMENT, %%, BOOST_PP_VARIADIC_TO_SEQ(__VA_ARGS__)) };\
    const char *name##ToString(int value) { return name##Strings[value]; }
 
ENUM_MACRO(Esper, Unu, Du, Tri, Kvar, Kvin, Ses, Sep, Ok, Naux, Dek);
 
int main()
{
    std::cout << EsperToString(Kvin) << std::endl;
}

Inconvénients

  • Utilise une macro (…)
  • Utilise Boost

NB: bien que ce soit techniquement une librairie tierce, Boost est plus facilement intégrable sur un projet que les librairies plus méconnue comme Magic Enum.

En résumé

Voici un tableau résumant les avantages et inconvénients de chaque méthode :

NomEst statique ?Est générique ?Librairie tierce ?Utilise des macros ?Est exception-safe?
Magic EnumYes (C++17)YesYes (Magic Enum)NoNo
Function w/ exceptionYes (C++14)NoNoNoNo
Function w/o exception Yes (C++14)NoNoNoYes
MacroYes (C++11)NoNoYesYes
Macro & BoostYes (C++11)YesYes (Boost)YesYes

Je le répète : si vous connaissez une méthode intéressante de convertir un enum en string, partagez-la en commentaire !

Merci de votre attention et à la semaine prochaine !

Article original : Best ways to convert an enum to a string | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Savoir choisir entre un setter et un reference-getter

Article original : How to choose between a setter and a reference-getter? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Contexte

Quand on implemente une classe, on a souvent besoin d’implémenter un accesseur pour un ou plusieurs attributs de cette classe pour les modifier.

Il y a deux manières de faire cela :

  • Implémenter un setter, une méthode qui prend comme argument une nouvelle valeur à destination de l’attribut.
  • Implémenter un reference-getter, une méthode qui renvoie une référence vers l’attribut lui-même.

Voici un petit exemple qui montre comment accéder à l’attribut bar en utilisant les deux méthodes :

class Foo {
    int m_bar;
 
public:
    // Setter
    void set_bar(int bar) {
        m_bar = bar;
    }
 
    // Reference-getter
    int & get_bar() {
        return m_bar;
    }
};
 
int main() {
    Foo foo;
 
    // Editing via setter
    foo.set_bar(42);
 
    // Editing via reference-getter
    foo.get_bar() = 84;
 
    return 0;
}

Certains d’entre vous objecteront peut-être qu’il existe d’autres manières d’accéder à un attribut en écriture, mais je soutiens qu’il s’agira toujours d’une variation d’une de ces deux méthodes.

Le setter

Un setter est une interface lecture-seule sur une classe. Vous lui fournissez une valeur et la classe est mise à jour en conséquence.

Souvent (mais pas toujours), cela va plus ou moins directement mettre à jour l’attribut en copiant/movant le paramètre.

Exemples

// Most simple setter
void set_foo(int foo) {
    m_foo = foo;
}
// A setter that performs a test before edition
void set_foo(Foo foo) {
    if (foo.is_valid())
        m_foo = foo;
}
// A move-setter
void set_big_foo(BigFoo && big_foo) {
    m_big_foo = std::forward<BigFoo>(big_foo);
}

Le reference-getter

Un reference-getter est une méthode qui renvoie directement une référence sur l’attribut qu’on veut éditer.

C’est particulièrement pratique sur un attribut qui est un objet sur lequel on peut appeler des méthodes non-constantes.

Exemples

// Here is the implementation of the reference-getter
Foo & MyClass::get_foo() {
    return m_foo;
}
 
// ...
 
// Used to edit an attribute
myClass.getFoo().bar = 42;
 
// Used to call a non-const method
myClass.getFoo().udpate();

Comment choisir ?

C’est assez simple quand on arrive à distinguer les différences entre les deux.

Le setter est nécessaire quand on veut recréer la valeur et la place à la place de l’existante. C’est recommandé quand on modifie des valeurs très simple (entiers, flottants, pointeurs, etc.) ou si vous avez besoin d’un objet tout neuf. De plus, on doit utiliser un setter quand on veut explicitement interdire la lecture de l’attribut (écriture-seule).

Le reference-getter est nécessaire quand ce sont les données de l’attribut qui sont modifiées (et non l’attribut lui-même). Souvent, on l’utilise pour modifier une partie seulement de l’attribut ou pour appeler des fonctions de modification dessus.

En d’autres mots, le setter remplace la valeur et le reference-getter modifie la valeur.

Exemple

Prenez ce code:

#include <vector>
 
using namespace std;
 
struct Item
{
    bool validity;
    int value;
};
 
class Foo
{
public:
    Foo(size_t size) :
        m_max_size(size),
        m_data(size, {true, 0})
    {}
 
    void set_max_size(size_t max_size) {
        m_max_size = max_size;
    }
 
    Item & get_item(size_t index) {
        return m_data.at(index);
    }
 
    size_t get_data_size() const {
        return m_data.size();
    }
 
private:
    bool m_max_size;
    std::vector<Item> m_data;
};
 
static void set_foo_size(Foo & foo, size_t new_size)
{
    foo.set_max_size(new_size);
    for (size_t i = new_size ; i < foo.get_data_size() ; ++i)
        foo.get_item(i).validity = false;
}

Ici, nous avons une simple petite classe qui détient une collection de données (des Item). Ces items peuvent être valident ou invalides (true est valide, false est invalide).

Puis, on implémente une petite fonction qui change la taille max de la collection. On choisit de ne pas enlever les éléments mais à la place de les rendre invalides.

On accède à m_max_size via un setter parque que c’est une donnée élémentaire (un size_t) qui est remplacée quand on change la taille de la collection.

On accède à chaque Item de m_data en utilisant un reference-getter car on ne veut juste modifier l’item, ni plus ni moins.

Alternative

On aurait pu faire autrement pour mettre à jour la validité, en utilisant un setter plus spécifique, comme ceci :

class Foo {
 
        // ...
 
        void set_item_validity(size_t index, bool validity) {
                m_data.at(index).validity = validity;
        }
 
        // ...
 
};

Procéder ainsi empêche de modifier la value de l’Item. De fait, la décision d’utiliser cette alternative dépendra entièrement de votre implémentation.

Cependant, il faut considérer comme mauvaise pratique le fait d’implémenter un setter pour validity et value. Le faire pour un data-bucket de deux attributs n’est pas conséquent, mais plus votre codebase grossira plus vous serez pollué·e par des accesseurs inutiles. Vous avez besoin d’un accès complet ? Implémentez un reference-getter.

En conclusion

Cela peut sembler être un sujet trivial, mais je vois beaucoup de confusion entre les deux méthodes aujourd’hui. Soyez vigilant·e·s et gardez en tête que les deux méthodes existent.

Merci de votre attention et à la semaine prochaine !

Article original : How to choose between a setter and a reference-getter? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Est-ce que toutes les variables devraient être const par défaut ?

Article original : Should every variable be const by default? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Un peu de terminologie

Dans le titre de l’article, j’utilise le mot const car const est un mot-clé que tous les développeurs C++ connaissent (enfin j’espère). Cela dit, le concept qui est adressé ici porte un nom plus générique, il s’agit de l’immuabilitéconst est un mot-clé utilise dans certains langages (comme le C++ ou le Javascript par exemple) mais la notion d’immuabilité existe aussi dans d’autres langages (qui parfois n’ont même pas de mot-clé équivalent à const, comme en Rust par exemple).

Ainsi, afin d’être inclusif, j’utiliserai les mots immuable et immuabilité à la place des mots constant et constance.

Nota bene : en C++ le mot-clé const ne représente pas exactement la notion d’immuabilité. Par exemple, vous pouvez utiliser le mot-clé const dans le prototype d’une fonction pour indiquer que vous n’allez pas le modifier, mais vous pouvez tout à fait passer une variable muable à cette fonction en guise de paramètre. Cela étant dit, cet article s’en réfère à const pour son caractère à rendre les données immuables, donc je vous saurais gré d’ignorer les cas où const ne s’en réfère pas à des données qui sont immuables de manière inhérente. C’est aussi une autre raison pour laquelle je préfère dire immuable et non pas constant.

Quelques très bonnes raisons de rendre vos variables immuables

Sécurité

Ce qu’on appelle la const-correctness (qui peut être maladroitement traduit en correction de constance, avec « correction » au sens de « qui est correct ») est une notion importante en C++ qui correspond au fait que si vous spécifiez qu’une variable est immuable, alors elle ne sera pas modifiée.

La const-correctness est atteinte lorsque vous utilisez le mot-clé const pour indiquer quelles variables ne doivent pas être modifiées. Ainsi, vous ne pourrez pas modifier par inadvertance des données qui n’ont pas à l’être et cela donne une indication sémantique aux autres développeurs et au compilateur.

Documentation

Ces cinq petits caractères en disent long. Si vous vous trouvez dans un code digne de confiance, la présence -ou l’absence- du mot-clé const donne des informations importantes sur les intentions derrière telle ou telle variable.

Optimisation

Le compilateur peut (et va) optimiser le code en fonction des directives d’immuabilité

En sachant si telle ou telle variable est immuable, le compilateur pourra faire des suppositions et prendre des raccourcis.

Si vous voulez en savoir plus à ce sujet je vous conseille le talk de J. Turner : Jason Turner: Practical Performance Practices – YouTube

Sources

Si vous n’êtes toujours pas convaincu(e) que l’immuabilité est utile (au bas mot), ou si vous êtes juste curieux(se), vous pouvez lire les articles suivants :

Y a-t-il des inconvénients à utiliser l’immuabilité dès que possible ?

Comme l’immuabilité est une restriction technique, il n’y a aucun inconvénient technique à l’utiliser.

Cependant, il peut y avoir des inconvénients plus subjectifs. Premièrement, cela augmente un peu la verbosité du code. Même s’il ne s’agit que de cinq caractères (qui dans la plupart des IDE prennent la même couleur que le type associé), certaines personnes peuvent trouver cela gênant. Il y a également une forme d’inertie qui peut apparaître : si à un moment donné vous avec besoin que la donnée soit muable, alors vous devrez aller chercher sa déclaration et la modifier. Certains considèrent cela comme une mesure de sécurité, d’autres comme un inconvénient.

Est-ce que ces arguments sont suffisants pour ne pas utiliser l’immutabilité dès qu’on le peut ? C’est à vous de juger, mais en mon humble avis, je ne pense pas.

Et les autres langages ?

D’autres langages ont effectivement rendu les variables immuables par défaut.

C’est le cas du Rust. En Rust, vous n’avez pas d’équivalent du mot-clé const, toutes les variables sont immuables par défaut. Au contraire, vous avez le mot-clé mut (pour mutable, la traduction anglaise de muable) que vous devez apposer devant chaque variable muable.

Par exemple, le code suivant ne compile pas en Rust :

fn main() {
    let foo = 2;
    println!("Foo: {}", foo);
    foo += 40; // error[E0384]: cannot assign twice to immutable variable `foo`
    println!("Foo: {}", foo);
}

Mais il faut plutôt écrire ceci :

fn main() {
    let mut foo = 2;
    println!("Foo: {}", foo);
    foo += 40;
    println!("Foo: {}", foo);
}

La raison mentionnée dans le manuel Rust est la suivante (je traduis) : « C’est un des nombreux coups de pouce que le Rust vous donne pour écrire du code qui tire profit de la sécurité […] que le langage a à offrir. »

Rust est un langage moderne qui vise à produire du code sécurisé tout en étant performant. Dans cette optique je suis d’accord avec le choix qu’il a fait de rendre les variables immuables par défaut pour la sécurité que cela apporte.

Est-il raisonnable de changer le standard pour rendre toutes les variables immuables par défaut ?

Si un jour fatidique, le C++ committee décide de rendre toutes les variables immuables par défaut… personne ne migrera vers cette nouvelle version du standard.

La rétro-compatibilité est importante pour adoucir les migrations. Il y a des exemples historiques de suppression de fonctionnalité du standard, mais à chaque fois il y avait une très bonne raison pour cela (la plupart du temps étant que cette fonctionnalité était obsolète depuis des lustres).

On ne peut pas sérieusement penser à enlever const du standard et le remplacer par un mut juste pour égard à ce qui est, en définitive, du sucre syntaxique.

Que faire alors ?

Pour ma part, j’utilise le mot-clé const systématiquement sans réfléchir, chaque fois que je déclare une variable. Je ne l’enlève que lorsque j’écris une instruction qui requiert effectivement que la variable soit muable (ou quand le compilateur me crie que j’essaye de modifier une variable constante). C’est un peu brut, mais selon moi c’est le meilleur moyen d’acquérir le bon réflexe de mettre const dès que l’on peut.

Bien sûr, vous pensez sans doute qu’on peut être plus malin et en essayant de prévoir si la variable sera mutable dès sa déclaration, mais je peux garantir que vous ne serez pas 100% fiables et vous laisserez passer des opportunités de rendre des données immuables (du moins, tant que vous n’aurez pas pris cette bonne habitude).

Voici donc mon conseil : jusqu’à ce que vous ayez le réflexe de mettre des const partout où vous les pouvez, utilisez const systématiquement et pour toutes les variables. Au pire vous aurez quelques erreurs de compilation facile à corriger, au mieux vous attraperez une bonne habitude.

Et quid de constexpr ?

constexpr est un mot-clé qui permet d’indiquer qu’une expression peut être évaluée au moment de la compilation. Le but est d’essayer de gagner du temps d’exécution en faisant un plus gros travail à la compilation.

En un sens, c’est une immutabilité plus forte que const. Tout ce que j’ai dit dans cet article peut s’appliquer à constexpr : utilisez-le dès que c’est possible.

Cependant, contrairement à const, je ne vous conseille pas d’être spécialement trop zélé(e)s avec constexpr.
Il est plus rare de pouvoir l’utiliser et les cas d’utilisation sont un peu plus évidents que son homologue. Mais gardez bien en tête d’utiliser constexpr dès que possible.

Merci de votre attention et à la semaine prochaine !

Article original : Should every variable be const by default? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Les exceptions sont des goto déguisés

Article original : Exceptions are just fancy gotos | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

À propos de goto

Retour aux bases : pourquoi est-ce que les goto sont mauvais ?

Pour répondre à cette question, il me suffirait de vous jeter un lien vers l’article de E. Dikjstra, Go To Statement Considered Harmful (arizona.edu), qui explique plutôt bien pourquoi les goto sont l’incarnation du mal, mais j’ai envie d’aller un peu plus loin.

Je vais donner ici une liste de raison pour lesquelles il ne faut pas utiliser goto, résumant ainsi l’article susmentionné tout en y apportant une touche de modernisation :

  1. Les goto sont des structures de contrôle non structurées, contrairement aux autres structures de contrôle en C ++. Les if et les boucles sont géographiquement liées au bloc de code qu’ils contrôlent (avec leur propre cycle de vie). Quand on les lit, on comprend aisément où le bloc de contrôle commence et où il finit. Les fonctions, qui sont une autre structure de contrôle, sont des points d’accès. Elles suivent un contrat dont le compilateur assure la validité. On leur donne une entrée, elles nous renvoient une sortie. Les goto, eux, ne sont pas structurés : ils correspondent à un aller simple dans le code, sans être géographiquement proche et sans assurer un système d’entrée-sortie comme les fonctions. Cela est sujet à produire du code spaghetti peu maintenable.
  2. Considérant ce que cela implique, le goto est probablement le mot-clé le moins sécurisé du langage. Je ne donnerais pas d’exemple expansif dans cet article, mais le nombre de choses autorisées avec les goto est surprenamment élevé. Si vous n’êtes pas assez vigilant, vous pourrez provoquer des crashs, parfois aléatoires, du jardinage mémoire, des comportements indéfinis, etc.
  3. Ici et maintenant, à l’ère du C ++ moderne, plus personne n’utilise les goto. Ça signifie que presque plus personne n’en est familier. La plupart des développeurs sait qu’il ne faut pas l’utiliser, mais assez peu d’entre eux savent pourquoi. De ce fait, si jamais d’aventure ils en croisent un dans une codebase, ils vont soit tenter de refactorer le code (avec le risque qu’ils comprennent la fonctionnalité de travers et causent une régression), soit le laisser en plan sans essayer de le comprendre. Étant donné qu’il est compliqué à utiliser, le fait que personne n’utilise le goto sur une base régulière est un argument de plus contre son utilisation.

Il existe des contextes spécifiques dans certains langages où le goto peut être considéré comme une bonne pratique, mais pas en C ++ moderne.

À propos des structures de contrôle et du code spaghetti

Dans la section précédente, le premier point (qui constitue l’argument principal contre le goto) parle de « structure de contrôle » et de « code spaghetti ».

De manière imagée, on pourrait voir l’exécution d’un programme comme une corde que l’on déroule le long du code, à mesure qu’il est exécuté. Tant que ce sont des instructions classiques qui sont exécutées, la corde se déroule normalement. Mais lorsqu’on atteint une structure de contrôle, cela se passe un peu différemment.

Quand l’exécution atteint une boucle for ou while, alors la corde effectue elle-même des boucles autour du bloc de code concerné. Quand l’exécution atteint une conditionnelle (if ou else), alors la corde passe au-dessus du code si nécessaire (en fonction de la condition vérifiée) pour continuer juste après.

Quand l’exécution atteint un appel de fonction, alors un brin de la corde se détache, forme un lacet qui va suivre le corps de la fonction puis revenir à la corde, là où elle l’a quitté.

Par contre, quand l’exécution atteint un goto, alors parfois la corde n’aura d’autre choix que d’être tendue à travers tout le code, pour atteindre le label associé. S’il y a plusieurs goto dans le code, alors la corde sera tendue à plusieurs endroits et dans tous les sens.

Si vous avez de bonnes structures de contrôle, alors vous pourrez suivre aisément votre corde du début à la fin, sans jamais être perdu. Mais si vos structures de contrôle sont chaotiques, alors la corde va se croiser partout et partir dans tous les sens. Elle ressemblera alors à un plat de spaghetti. C’est ça qu’on appelle avoir un code spaghetti.

Comme le goto a tendance à tendre la proverbiale corde à travers tout le code, son utilisation est facilement sujette à du code spaghetti.

Mais est-ce que le goto est vraiment malveillant ?

En prenant en compte tout ce que l’on vient de dire, ne pourrait-on pas quand même imaginer une manière sécurisée d’utiliser le goto ? Après tout, goto n’est malveillant que s’il provoque du code spaghetti. Mais si on réalise un code qui n’utilise le goto que localement, dans un espace contrôlé, alors le code ne sera pas spécialement spaghetti-esque, n’est-ce pas ?

Il existe effectivement des designs qui utilisent le goto pour rendre le code plus clair qu’il ne le serait avec d’autres structures de contrôle.

L’exemple le plus classique est celui des boucles imbriquées :Capture1.P

//...
 
bool should_break = false;
for (int i = 0 ; i < size_i ; ++i)
{
    for (int j = 0 ; j < size_j ; ++j)
    {
        if (condition_of_exit(i,j))
        {
            should_break = true;
            break;
        }
    }
    if (should_break)
        break;
}
 
//...

Si on écrit le même code avec un goto, ça sera un peu plus court et plus clair :Capture2.PNG888x314

// ...
 
for (int i = 0 ; i < size_i ; ++i)
{
    for (int j = 0 ; j < size_j ; ++j)
    {
        if (condition_of_exit(i,j))
            goto end_of_nested_loop;
    }
}
end_of_nested_loop:
 
// ...

Vous voyez ? goto n’est pas si inutile que ça !

Du coup, devrait-on utiliser ce mot-clé dans ces cas très spécifiques où il rend le code plus clair ? Je ne pense pas.

Il est au final assez compliqué de trouver des exemples variés où le goto est meilleur que les autres structures de contrôle, et les boucles imbriquées similaires à celle présentée ici sont rares. Et même si, dans cet exemple, le code est plus court, personnellement je ne le trouve pas plus clair pour autant. Il y a 2 niveaux de bloc de différence entre le goto et son label, ce qui à la lecture est contre-intuitif. De plus, dans tous les cas le facteur humain reste un gros problème.

Donc, est-ce que goto est vraiment malveillant ? Non, mais il reste, dans l’absolu, une très mauvaise pratique.

À propos des exceptions

Les exceptions : une manière moderne de casser les structures de contrôle

Les exceptions sont une manière de gérer les cas d’erreur. Elles peuvent aussi être utilisées comme une structure de contrôle standard, vu qu’on peut personnaliser ses propres exceptions, puis les lever et les attraper à loisir.

J’aime bien m’imaginer les exceptions comme étant des if qui pendouillent dans le code : d’une part si tout se passe bien, le code se déroule de manière linéaire, sans accroc, mais d’autre part, si une erreur survient, alors on lance la balle dans les airs en espérant que quelque chose dans les strates supérieures du programme la rattrape.

Les exceptions cassent les structurent de contrôle standard. Reprenons l’image de la corde déroulée le long du code : quand vous appelez une fonction qui peut lever une exception, alors un brin de corde se détache pour exécuter la fonction (comme avant), mais vous n’avez aucune garantie que le brin sera retourné à votre corde à l’endroit où vous avez appelé la fonction. Il pourra être raccordé n’importe où au-dessus, dans la pile d’appel. Le seul moyen d’empêcher ce scénario est de trycatch toutes les exceptions quand vous appelez la fonction, mais c’est un luxe qui n’est possible que si vous savez quoi faire dans tous les cas dégradés possibles.

De plus, quand vous écrivez une fonction dans laquelle vous levez possiblement une exception, vous n’avez aucun moyen de savoir si et où elle sera attrapée.

Même si ce n’est pas aussi mauvais que goto, parce qu’on a plus de contrôle dessus, il est très facile d’écrire du code spaghetti en usant d’exceptions. Avec ce recul, on peut même considérer que les exceptions sont « des goto modernes ».

On peut même écrire l’exemple des boucles imbriquées avec des exceptions :

//...
 
try
{
    for (int i = 0 ; i < size_i ; ++i)
    {
        for (int j = 0 ; j < size_j ; ++j)
        {
            if (condition_of_exit(i,j))
                throw;
        }
    }
}
catch (const std::exception& e)
{ /* nothing */ }
 
//...

Je ne recommanderais pas une telle écriture cependant. Les exceptions sont malfaisantes.

Est-ce que les exceptions sont vraiment malfaisantes ?

… le sont-elles ? Pas vraiment.

Dans la section précédente, j’ai statué sur le fait que les goto ne sont pas à proprement parler malveillantes mais qu’elles sont très sujettes aux fautes et aux erreurs. Elles n’en valent juste pas la peine. C’est pareil pour les exceptions : elles ne sont pas malfaisantes, c’est juste une feature.

En mon humble opinion, contrairement aux goto, il existe des moyens sécurisés d’utiliser les exceptions.

Une différence majeure entre les exceptions et les goto

Pour comprendre comment bien utiliser les exceptions, il faut comprendre les différences qu’elles ont avec le goto.Les exceptions, contrairement aux goto, n’envoient pas l’exécution à un endroit totalement indéterminé, elle envoie l’exécution vers le haut. Cela peut être de quelques blocs (comme dans l’exemple des boucles imbriquées) ou sur plusieurs appels de fonctions.

Quand utiliser les exceptions ?

Envoyer l’exécution du programme vers le haut reste assez indéterminé malgré tout, et dans la plupart des cas cela brise la continuité du programme.

Dans la plupart des cas.

Il existe une situation spécifique où vous voulez que l’exécution se stoppe abruptement : quand quelqu’un utilise une fonctionnalité d’une manière qui provoque un comportement indésirable.

Quand vous écrivez une fonctionnalité, vous voulez avoir la possibilité de tout stopper pour éviter un cas dégradé dangereux, et à la place de finir le traitement renvoyer l’exécution vers le haut en indiquant « Quelque chose d’imprévu s’est produit, je ne peux pas continuer ». Non seulement cela va prévenir les comportements indésirables, mais cela va aussi indiquer à l’utilisateur qu’il n’a pas correctement utilisé la fonctionnalité, le forçant à se corriger ou à gérer le cas d’erreur.

Quand vous rédigez une fonctionnalité, il y a un mur virtuel entre vous et l’utilisateur, avec un petit trou symbolisé par l’interface de la fonctionnalité. C’est à chacun d’entre vous de gérer correctement le comportement de la corde proverbiale de chaque côté du mur.

Une fonctionnalité peut d’ailleurs très bien être une grosse librairie ou une simple classe. Du moment qu’il y a un niveau d’encapsulation, il est valide d’utiliser des exceptions comme faisant partie de l’interface.

Un bon exemple de cela est la méthode at () de std ::vector < >. Le but de cette méthode est de renvoyer le énième élément du vecteur, mais il y a un risque que l’utilisateur demande un élément hors-limite. Dans ce cas, lever une exception est le moyen qu’a std ::vector < > d’empêcher un comportement indéfini. Si l’utilisateur capture l’exception, alors il peut écrire le code à exécuter en cas d’indice hors-limite. Sinon, le programme sera stoppé, lui indiquant qu’il a commis une erreur et le forcera à sécuriser son code ou à gérer le cas dégradé.

Dans tous les cas, vous devez documenter toute exception que vous levez.

Conclusion

En résumé, les bonnes pratiques à employer vis-à-vis des exceptions peuvent se résumer en trois points :

  • N’utilisez pas les exceptions comme des structures de contrôle.
  • Vous pouvez utiliser les exceptions comme partie de l’interface d’une fonctionnalité.
  • Vous devez documenter toute exception que vous levez.

Merci de votre attention et à la semaine prochaine !

Article original : Exceptions are just fancy gotos | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Comment gérer le dépassement d’entier

Article original : Dealing with integer overflows | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Le dépassement d’entier (integer overflow dans la langue de Ritchie) peuvent être considérablement embêtants. Il n’y a pas de manière simple et fiable de les détecter, encore moins de les détecter statiquement, et ils peuvent rendre votre logiciel inconsistant.

Cet article parlera indistinctement du dépassement des entiers signés et non-signés. Même si le dépassement des entiers non-signés n’est pas un comportement indéfini (contrairement au dépassement des entiers signés), 99.9% du temps vous ne voulez ni l’un, ni l’autre.

Comment détecter les dépassements d’entier ?

Même si elle sont peu nombreuses, il existe effectivement des manières de détecter les dépassements d’entier. Le souci est qu’elles sont soit complexes, soit peu fiables.

Dans cette section, je vais vous présenter quelques manières de détecter les dépassements d’entier (si vous en connaissez d’autres, n’hésitez pas à le partager en commentaire pour que tout le monde puisse en profiter)

UBSan

Undefined Behavior Sanitizer, ou UBSan pour les intimes, est un outil dynamique de vérification des comportements indéfinis.

Il est capable de détecter les dépassements d’entier sous la forme d’une option de compilation. Notez que même s’il est conçu pour détecter les comportements indéfinis, il nous fait le plaisir de détecter également les dépassements de entiers non-signés (qui, pour rappel, ne sont pas indéfinis).

Voici, à titre d’exemple, lesdites options de compilations appliquée au compilateur clang :

clang++ -fsanitize=signed-integer-overflow -fsanitize=unsigned-integer-overflow

Cet outil est implémenté pour clang et GCC :

Je peux me tromper, mais je n’ai pas réussi à trouver d’intégration de cet outil sur d’autres compilateurs.

Le principal inconvénient d’un outil dynamique comme celui-ci est qu’il faut re-compiler et lancer des essais exhaustifs du logiciel pour que l’outil puisse détecter un éventuel problème. Si les essais ne couvrent pas assez de cas de figure, il est possible qu’un dépassement d’entier passe entre les mailles du filet.

Écrire des test unitaires adéquats

Si votre projet implémente des tests unitaires (je ne m’étendrais pas sur le fait qu’il le devrait, car les développeurs ne sont pas toujours décisionnaires sur ce sujet), alors vous avez à votre disposition une manière assez directe de prévenir les dépassements d’entier.

Il vous suffit, pour toute fonctionnalité où c’est pertinent, de fournir en entrée de cette fonctionnalité de très grands entiers. Vous n’avez plus qu’à vérifier que le résultat reste cohérent, qu’une exception est levée, qu’un code d’erreur est mis à jour, ou quoique ce soit que la fonctionnalité est sensée faire dans ce cas.

Si vous ne savez pas quel résultat la fonctionnalité est sensée renvoyer parce que l’entrée est trop grosse pour être traitée et qu’elle ne gère pas ces cas d’erreur, alors cette fonctionnalité est dangereuse. Vous devez implémenter les cas d’erreur pour vous assurer qu’aucun dépassement n’aura lieu.

Parfois, détecter un dépassement d’entier potentiel peut nécessiter un refactoring assez lourd. N’ayez pas peur de le faire : il vaut mieux prévenir que guérir.

Ne mettez pas votre code dans une situation où il pourrait faire un dépassement

Le meilleur moyen d’être sûr qu’il n’y aura pas de dépassement et de prévenir toute possibilité qu’il survienne. Si vous être certain que votre code ne peux pas provoquer de dépassement d’entier, vous n’aurez pas besoin de les détecter.

La section suivante va vous fournir quelques pratiques pour vous aider dans cette optique.

Comment prévenir le dépassement d’entier ?

Utilisez des entiers de 64 bits

Une très bonne manière d’éviter le dépassement est d’utiliser int64_t pour implémenter les entiers. Dans la plupart des cas, les entiers de 64 bits ne provoqueront pas de dépassement, contrairement à leurs homologues de 32 bits et moins.

Veuillez noter que j’utilise des entiers signés comme exemple mais que toute cette section vaut aussi pour les entiers non-signés.

Il y a en réalité très peu de désavantages à utiliser int64_t plutôt que int32_t. La plupart du temps, vous n’aurez pas besoin de vous inquiéter de l’écart de performance ou de l’écart de taille entre les deux types. Seulement si vous travaillez sur des système embarqués ou sur des algorithmes de traitement de données pour pourriez avoir à vous en soucier.

Notez que avoir des entiers plus grands ne veut pas nécessairement dire que les calculs seront plus longs, et avec tout le panel de pointeurs / références / forwarding que le C++ nous fournit, on n’a pas souvent besoin de copier des grosses structure de données.

Dans tous les cas, même si vous avez des contraintes de performance ou de taille, gardez en tête la maxime suivante :

D’abbord la sécurité, ensuite les performances.
– Chloé Lourseyre, 2021

Essayer de suroptimiser et risquer ce faisant un dépassement d’entier est toujours pire que d’écrire du code sécurisé puis d’utiliser des outils pour cibler les morceaux de code qui doivent être optimisés.

Donc, ma recommandation finale est que, sauf contre-indication spécifique, vous utilisiez des int64_t (et des uint64_t) pour coder vos entiers.

À propos des performances de int64_t

J’ai fait tourner quelques opérations arithmétiques (addition, multiplication et division) sur un outil de benchmarking avec à la fois des int32_t et des int64_t pour voir s’il y avait des différences notables.

En utilisant clang et sur deux niveaux d’optimisation différents, voici les résultats :

Cela ne vous surprendra peut-être pas, mais dans tous les cas c’est la division la plus lente. Nonobstant les divisions, vous noterez qu’il n’y a pas de différence sensible entre les entiers 64-bits et les entiers 32-bits. Par contre, si vous effectuez des divisions, les entiers 32-bits seront plus adaptés (mais le simple fait d’utiliser des divisions démoli vos performance, donc c’est un gain en demi-teinte).

Petit rappel sur les types de base

Peut-être vous êtes vous demandé pourquoi, depuis le début de cette section, j’utilise les types int32_t et int64_t et non les bon vieux int et long. La raison à cela, c’est tout simplement parce que la taille de ces deux types dépend de votre environnement.

En effet, les seules contraintes que le standard applique sur les tailles des int et long sont les suivantes :

  • Les int doivent faire au moins 16-bits.
  • Les long doivent faire au moins 32-bits.
  • La tailles des int doit être supérieure ou égale à la taille des short et inférieure ou égale à la taille des long.
  • La taille des long doit être supérieure ou égale à la taille des intbool et wchar_t et inférieure ou égale à la taille des long long.

À cause de cela, évitez autant que possible d’utiliser les int et les long quand vous voulez éviter les dépassements d’entier.

Ne présumez jamais que, parce qu’une valeur est dans les limites, elle ne provoquera pas de dépassement d’entier

Mettons que vous avez une variable int32_t my_val qui représente un donnée qui sera toujours contenue entre 0 et un milliard (1 000 000 000). Comme la valeur max d’un int32_t est 2^31-1 (2 147 483 647), on pourrait penser que la variable ne provoquera jamais de dépassement.

Mais, un jour fatidique, un développeur écrira, sans rien soupçonner :

#include <cstdlib>
 
const int32_t C_FOO_FACTOR = 3;
 
int32_t evaluate_foo(int32_t my_val)
{
    // foo is always C_FOO_FACTOR times my_val
    return my_val * C_FOO_FACTOR;
}

Vous l’avez vu ? Dépassement d’entier. En effet, il existe des valeur de my_val qui, quand elles sont multipliées par 3, causent un dépassement d’entier.

À qui la faute ? Doit-on toujours vérifier qu’une valeur est dans un intervalle adéquat avant toute opération arithmétique ? Comment faire ?

Et bien, il y a une pratique assez simple à réaliser qui vous aidera à prévenir la plupart des cas de figure concernés. Quand vous aurez à stocker un entier plutôt grand, même si en lui-même il ne peut pas dépasser, mettez-le dans une structure plus grande.

Par exemple, je ne met jamais une valeur qui peut être plus grande que 2^15 dans une donnée dont la valeur maximale est de 2^31. Ainsi, même si la valeur est multipliée avec elle-même, elle ne provoquera pas de dépassement d’entier.

Avec cette méthode, on peut garder les petites valeurs dans de plus petites structures de donnée sans effet de bord. Dans l’exemple mentionné, C_FOO_FACTOR pourra rester un int32_t, et le résultat de toute opération avec lui sera promu s’il est associé avec un type de plus grande taille dans une opération arithmétique.

E.g. :

#include <cstdlib>
 
const int32_t C_FOO_FACTOR = 3;
 
int64_t evaluate_foo(int64_t my_val)
{
    // foo is always C_FOO_FACTOR times my_val
    return my_val * C_FOO_FACTOR; // The result of the multiplication is a int64_t
}

Utilisez auto

Oui, auto peut parfois vous sauver la vie, en particulier si vous n’êtes pas 100% sûr des type que vous manipulez.

Par exemple :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int64_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    int32_t foo_bar = compute_bar() + C_FOO;
 
    // Probably no overflow here
    auto better_foo_bar = compute_bar() + C_FOO;
}

Ici, auto est utile car il prévient l’erreur commise ligne 10, où le résultat de l’opération compute_bar() + C_FOO, qui est un int64_t, est rétrogradée en int32_t quand elle est assignée. Ligne 13, auto devient un int64_t, donc aucun dépassement ne peut avoir lieu.

(Note : convertir un entier en un type trop petit pour le contenir est un dépassement d’entier)

Il y a aussi un autre cas spécifique, qui ne survient pas spécialement souvent, où auto peut être utile. Considerez le code suivant :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int32_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    auto foo_bar = compute_bar() + C_FOO;
}

Ici, la valeur de retour de compute_bar() est int32_t. Mais si plus tard, l’auteur de cette fonction, voyant que int32_t est trop petit, change le type de retour en un int64_t, comme ceci :

#include <cstdlib>
 
int32_t  C_FOO = 42;
 
int64_t compute_bar();
 
int main()
{
    // Big risk of overflow overflow here
    auto foo_bar = compute_bar() + C_FOO;
}

Alors l’auto sera automatiquement « promu » en int64_t lui aussi, évitant une conversion implicite qui pourrait résulter en un dépassement d’entier.

Si on avait utilisé int32_t à la place de auto dès le départ, alors il y aurait eu un risque que l’auteur de la fonction compute_bar() ne corrige pas le type de la variable foo_bar, sans qu’aucun avertissement ni aucune erreur ne survienne à la compilation. Du coup, avoir auto dans ce cas nous a fait éviter le pire.

En conclusion

Faites toujours attention, quand vous manipulez des grands entiers, d’utiliser des grands type (bien plus grand que nécessaire, par sécurité). Utilisez auto quand vous ne savez pas ce que vous manipulez, et utilisez des analyseurs si vous pensez que votre code peut contenir un dépassement d’entier. Et bien sûr, comme toujours, écrivez de bons tests unitaires.

Si vous connaissez d’autres manières de prévenir ou de détecter des dépassement d’entier, n’héistez pas à le partager en commentaire.

Merci de votre attention et à la semaine prochaine !

Article original : Dealing with integer overflows | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre