Est-ce que mon chat est Turing-complet ?

Article original : 
Traductrice : Chloé Lourseyre

Cet article est une retranscription d’un Lightning Talk que j’ai donné à l’occasion de la CppCon2021

On va parler d’un sujet plus léger cette semaine, mais malgré tout important : est-ce que mon chat est Turing-complet ?

Peluche, enchantée

Peluche est un chat tout doux qui, suite à un concours de circonstances, habite dans ma maison.

Elle sera notre sujet de test aujourd’hui

Est-ce que Peluche est Turing-complète?

C’est quoi, « Turing-complet » ?

On dit qu’une machine est Turing-complète si elle peut émuler une machine de Turing. Toute machine qui est Turing-complète peut exécuter n’importe quel programme informatique1.

Cela signifie que toute machine qui implémente les huit instructions ci-après est équivalente à n’importe quel ordinateur.

  • . et , : Gestion des entrées et des sorties.
  • + et - : Augmentation et diminution de la valeur contenue dans la cellule mémoire pointée2.
  • > et < : Déplacement vers la gauche ou vers la droite la cellule pointée sur le ruban de mémoire.
  • [ et ] : Faire des boucles.

Si on arrive à prouver que Peluche peut exécuter ces huit instructions, on prouvera qu’elle est Turing-complète.

Preuve de la Turing-complétude

Entrées et sorties

D’abord, j’ai essayé d’obtenir une réaction en utilisant mon doigt :

Suite à cela, elle s’est tournée vers moi, m’a fixée quelques secondes, puis s’est détournée de moi.

Donc voilà : je l’ai pokée et j’ai obtenu une réaction. Elle peut donc computer des entrées et renvoyer une sortie.

Entrée/sortie : check !

Augmentation et diminution d’une valeur en mémoire

L’autre jour, en rentrant du travail, j’ai découvert ceci :

Des croquettes partout…

Mais en y regardant de plus près, j’ai remarqué quelque chose d’intéressant. Si on numérote les dalles de carrelage comme suit :

Cela ressemble fortement à un ruban de mémoire, si le nombre de croquettes contenu correspond à la valeur mémorisée ! Et comme elle n’hésite pas à les manger à même le sol, elle peut tout aussi bien faire diminuer cette valeur.

Augmentation/diminution de mémoire : check !

Déplacement vers la gauche ou la droite du ruban de mémoire

Un jour lointain, je faisais la vaisselle lorsque j’ai accidentellement renversé de l’eau sur Peluche. Elle s’est mise à courir partout et a mis le bazar dans la cuisine.

Si vous regardez bien (en suivant la flèche rouge), vous remarquerez qu’elle a déplacé son bol à croquette.

De fait, cela signifie que lorsqu’elle renversera ses croquettes sur une autre dalle de carrelage que la première, c’est-à-dire ailleurs sur la bande mémoire.

Déplacement de la bande mémoire : check !

Les boucles

Bon, après ce bazar j’ai (évidemment) dû tout nettoyer.

Mais pas plus de cinq minutes après avoir fini de nettoyer, je me suis retrouvée devant ça :

Okay… Elle peut SANS AUCUN DOUTE faire des boucles.

Boucles : check !

Nous venons de prouver que Peluche est Turing-complète. Du coup, la question qui se pose est : comment l’exploiter pour effectuer des calculs de haute performance ?

Que faire avec elle ?

Peluche est Turing-complète : ça veut dire qu’on peut faire ce qu’on veut avec !

J’ai alors essayé de lui donner un morceau de code simple à exécuter, pour tester3:

😾😾😾😾😾😾😾😾
😿
🐈😾
🐈😾😾
🐈😾😾😾
🐈😾😾😾😾
🐈😾😾😾😾😾
🐈😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾
😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻🐾
😸
🐈🐈🐈🐈🐈🐈🐈🐈🐈🙀😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🙀😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🙀😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈😾😾🙀🐾🐾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐾🐾🙀😾😾
😻😻😻😻😻😻🙀

Le résultat était sans appel : elle n’a pas voulu bouger.

Au final, peut-être que les chats ne sont pas conçus pour exécuter du code ? Et ce, même s’ils sont Turing-complets ?

À propos de la « chat-formatique »

Blague à part, la chat-formatique (ou cat-computing) est le nom que je donne à cette pratique généralisée. D’expérience, il arrive assez souvent que quand quelqu’un découvre une nouvelle fonctionnalité du langage, iel commence à l’utiliser à tors et à travers. Juste parce qu’iel le peut.

Cependant, tout comme vous pouvez exécuter du code avec un chat4 mais ne devriez pas, ce n’est pas parce que vous pouvez utiliser une fonctionnalité que vous devriez.

En conclusion

La chat-formatique peut sembler être une erreur de débutant (et ça l’est), mais même le plus grand·e·s expert·e·s commettent des erreurs de débutant de temps à autre (et il n’y a pas de honte à cela).

Tous les trois ans, une nouvelle version du C++ est publiée. À chaque fois, ça me donne envie d’utiliser les nouvelles fonctionnalités partout, dans tous mes programmes. Bien que ce soit une opportunité pour gagner de l’expérience sur ces fonctionnalités, c’est aussi un terrain favorable à l’acquisition de mauvaises pratiques.

Demandez-vous toujours si une fonctionnalité est nécessaire5 avant de l’utiliser, sinon vous pourriez être en train de faire de la chat-formatique.

Vos savants étaient si préoccupés par ce qu’ils pourraient faire ou non qu’ils ne se sont pas demandé s’ils devraient le faire6.

Docteur Ian Malcolm, Jurassic Park

Aussi, la chat-formatique c’est de la maltraitance animale, ne le faites pas 😠 !

Merci de votre attention et à la semaine prochaine !

Article original : 
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Ceci est une définition simplifiée et vulgarisée, mais suffisante pour le bien cet article. Si vous voulez la vraie définition, suivez le lien suivant : Turing completeness – Wikipedia
  2. Je ne l’ai pas mentionné explicitement, mais une machine Turing a un « ruban de mémoire », contenant des « cellules de mémoire ». La machine pointe toujours vers une cellule, qui est désignée comme la cellule « pointée ».
  3. Vous n’arrivez peut-être pas à lire ce morceau de code — il s’agit d’un joli nouveau langage que j’ai baptisé « braincat ».
  4. Oui, je sais, dans la vraie vie, vous ne pouvez pas exécuter du code avec un chat. Mais pour le bien de la métaphore, essayez d’imaginer que vous pouvez.
  5. Bien, la « nécessité » survient quand il y a un bénéfice net à l’emploi d’une fonctionnalité. On ne parle pas ici de nécessité absolue mais de nécessité pratique.
  6. Ce n’est pas exactement phrase que prononce docteur Malcolm dans le film. En français, la phrase complète est : « Vos savants étaient si préoccupés par ce qu’ils pourraient faire ou non qu’ils ne se sont pas demandé s’ils en avaient le droit. ». Afin de mieux adhérer au contexte, j’ai pris la liberté de la retraduire depuis la version originale, qui est : « They were too busy wondering if they could to think about whether they should. »

L’appareil de Duff en 2021

Article original : Duff’s device in 2021 | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette année à la CppCon, Walter E. Brown a donné un Lightning Talk (un conférence-éclair) sur l’appareil de Duff (je mettrais un lien YouTube vers la vidéo correspondante dès qu’elle sortira).

L’appareil de Duff est un modèle assez vieux et je me suis dit : « À quel point est-il encore utile en 2021, avec le C++20 et tout ? ».

Ainsi naquit cet article

C’est quoi, l’appareil de Duff ?

Ce que j’appelle l’appareil de Duff renvoie à ce qu’on appelle Duff’s device en anglais. Ce mécanisme a été nommé d’après son créateur Tom Duff. Il correspond à une méthode de déroulage de boucle manuelle.

Le déroulage de boucle (ou loop unrolling) est une technique d’optimisation visant à réduire de temps d’exécution d’une boucle en « déroulant » manuellement son contenu afin de réduire le nombre de contrôle de boucle effectués. Le tout se fait au détriment de la taille du binaire généré.

Le principe de l’appareil de Duff est d’exécuter plusieurs fois le contenu de la boucle à la suite (généralement entre quatre et huit fois) au lieu d’une seule fois, ce qui permet de ne faire le contrôle de boucle qu’une fois toutes les quatre à huit computations.

Donc au lieu de faire ceci:

void execute_loop(int & data, const size_t loop_size)
{
    for (int i = 0 ; i < loop_size ; ++i)
    {
        computation(data);
    }
}

On cherche à faire quelque chose ressemblant un peu à cela:

void execute_loop(int & data, const size_t loop_size)
{
    for (int i = 0 ; i < loop_size/4 ; ++i)
    {
        computation(data);
        computation(data);
        computation(data);
        computation(data);
    }
}

Cependant, vous l’aurez peut-être remarqué, mais si loop_size n’est pas un multiple de 4, alors le nombre de computation() effectué n’est pas le bon. Pour palier à ce problème, l’appareil de Duff utilise la capacité du switch à « passer à travers » (ce qu’on appelle le switch fallthrough et qui ne peut pas avoir de traduction satisfaisante). Concrètement, ça ressemble à ceci:

void execute_loop(int & data, const size_t loop_size)
{
    size_t i = 0;
    switch(loop_size%4)
    {
        do{
            case 0: computation(data);
            case 3: computation(data);
            case 2: computation(data);
            case 1: computation(data);
            ++i;
        } while (i < (loop_size+3)/4);
    }
}

Ce code est un peu plus étrange que ce que vous avez l’habitude de voir, alors laissez-moi l’expliquer.

Au début de la fonction, on entre dans le switch et on vérifie alors le modulo de loop_size. En fonction du résultat, on arrive dans un des quatre case. Puis, grâce à ce « passage à travers », on fait un nombre de computation() différent en fonction du case dans lequel on est tombés. Cela permet de rectifier le problème de divisibilité par 4 qu’on a rencontré juste avant.

Ensuite, on arrive sur le while. Comme techniquement le switch nous a envoyé dans une boucle do while(), alors l’exécution retourne au do et on continue la boucle normalement.

Après la première occurrence, les labels de type case N sont ignorés, donc ça fait comme si on passait à travers à chaque fois.

Vous pouvez vérifier les calculs : cela fait qu’on réalise exactement loop_size computations.

Est-ce que l’appareil de Duff en vaut la peine ?

L’appareil de Duff vient d’une autre époque, d’une autre ère (et même d’un autre langage), donc ma première réaction a été : « Ce genre de mécanisme est probablement contre-productif, autant laisser le compilateur optimiser lui-même les boucles. »

Mais je voulais une preuve tangible que cette approche était la bonne. Quoi de mieux que quelques benchmarks pour obtenir une telle preuve ?

Benchmarks

Pour faire ce benchmark, j’ai utilisé le code suivant : Quick C++ Benchmarks – Duff’s device (quick-bench.com).

Voici les résultats1 :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og7.5657e+47.2965e+4– 3.6%
Clang 12.0-O17.0786e+47.3221e+4+ 3.4%
Clang 12.0-O21.2452e-11.2423e-1– 0.23%
Clang 12.0-O31.2620e-11.2296e-1– 2.6%
GCC 10.3-Og4.7117e+44.7933e+4+ 1.7%
GCC 10.3-O17.0789e+47.2404e+4+ 2.3%
GCC 10.3-O24.1516e-64.1224e-6– 0.70%
GCC 10.3-O34.0523e-64.0654e-6+ 0.32%

Dans cette situation, on peut voir que la différence est insignifiante (3.5% sur un benchmark c’est peu, dans un code intégré cette différence serait diluée dans le reste du code). De plus, le côté duquel penche la balance varie d’un compilateur et d’une option à l’autre.

Après cela, j’ai utilisé une version plus simple du computation(), plus facile à optimiser pour le compilateur.

Cela donne ce résultat :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og5.9463e+45.9547e+4+ 0.14%
Clang 12.0-O15.9182e+45.9235e+4+ 0.09%
Clang 12.0-O24.0450e-61.2233e-1+ 3 000 000%
Clang 12.0-O34.0398e-61.2502e-1+ 3 000 000%
GCC 10.3-Og4.2780e+44.0090e+4– 6.3%
GCC 10.3-O11.1299e+45.9238e+4+ 420%
GCC 10.3-O23.8900e-63.8850e-6– 0.13%
GCC 10.3-O35.3264e-64.1162e-6– 23%

C’est intéressant, car on observe que Clang peut, de lui-même, grandement optimiser la boucle simple sans arriver à optimiser de la même manière l’appareil de Duff (avec les options -O2 et -O3 la boucle simple est 30 000 fois plus rapide ; c’est parce que la boucle est entièrement optimisée en une simple addition, mais considère que l’appareil de Duff est trop complexe pour être optimisé de la sorte).

D’un autre côté, GCC n’arrive pas à réduire la boucle simple plus qu’il ne réduit l’appareil de Duff. Même si à -O1 la boucle simple est plus de cinq fois plus rapide, à -O3 c’est l’appareil de Duff qui est 23% meilleur (ce qui est significatif)2.

Lisibilité et sémantique

Au premier coup d’œil, l’appareil de Duff est une congruence très difficile à appréhender. Cependant, c’est aujourd’hui un mécanisme assez connu (surtout parmi les plus vieux développeur·se·s C et C++). De plus, il a un déjà un nom et qu’il possède une page Wikipedia qui explique son fonctionnement.

Tant que vous l’identifiez comme tel dans les commentaires de votre code, je pense qu’il n’est pas malsain de l’utiliser, mais si vos confrères et consœurs ne le connaissent pas (au pire, vous pouvez mettre un lien vers sa page Wikipedia directement dans les commentaires !).

Chercher un cas plus spécifique

Principe

Le déroulage de boucle cherche spécifiquement à réduire le nombre d’évaluation de la structure de contrôle de votre boucle. Du coup, j’ai construit un cas spécifique où ce contrôle est particulièrement lourd à évaluer, pour voir si ça permet à l’appareil de Duff d’avoir un impact significatif.

Du coup, à la place d’utiliser un entier comme index de boucle, j’ai utilisé cette classe :

struct MyIndex
{
  int index;
   
  MyIndex(int base_index): index(base_index) {}
   
  MyIndex& operator++() 
  {  
    if (index%2 == 0)
      index+=3;
    else
      index-=1;
    return *this;
  }
 
  bool operator<(const MyIndex& rhs)
  {
    if (index%3 == 0)
      return index < rhs.index;
    else if (index%3 == 1)
      return index < rhs.index+2;
    else
      return index < rhs.index+6;
  }
};

À chaque fois qu’on incrémente ou compare MyIndex, on évalue un modulo (qui est une opération arithmétique assez lente).

Et j’ai fait des benchmarks dessus.

Benchmarks

J’ai donc utilisé le code suivant : Quick C++ Benchmarks – Duff’s device with strange index (quick-bench.com).

Cela m’a donné les résultats suivants :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og2.0694e+55.9710e+4– 71%
Clang 12.0-O11.8356e+55.8805e+4– 68%
Clang 12.0-O21.2318e-11.2582e-1+ 2.1%
Clang 12.0-O31.2955e-11.2553e-4– 3.1%
GCC 10.3-Og6.2676e+44.0014e+4– 36%
GCC 10.3-O17.0324e+46.0959e+4– 13%
GCC 10.3-O26.5143e+44.0898e-6– 100%
GCC 10.3-O34.1155e-64.0917e-6– 0.58%

Ici, on peut voir que l’appareil de Duff est meilleur que la boucle simple dans les plus basses couches d’optimisation, mais jamais significativement à -O3. Cela signifie que le compilateur réussit à optimiser la boucle simple aussi bien que l’appareil de Duff l’est. C’est significativement différent des résultats précédents.

Pourquoi les résultats sont aussi inconsistants ?

Les benchmarks montrent des résultats très peu consistants : par exemple, comment cela se fait-il que dans d’une computation() simple, avec GCC et -O1, la boucle simple est plus de cinq fois plus rapide que l’appareil de Duff, alors qu’avec -O3, c’est l’appareil de Duff qui est 23% meilleur ? Comment se fait-il que pour le même code, Clang montre des résultats totalement différents de GCC et montre que la boucle simple est trente mille fois plus rapide avec -O2 et -O3 ?

C’est parce que chaque compilateur a ses propres manières d’optimiser ces genres de boucles à différents niveaux d’optimisation.

Si vous voulez regarder cela de plus près, vous pouvez comparer le code assembleur généré par chaque compilateur, comme dans cet exemple : Compiler Explorer (godbolt.org) où les versions Clang et GCC (à -O3) sont mises côte-à-côte.

J’aurais beaucoup aimé détailler tout cela ici, mais il faudrait largement plus d’un article dédié pour tous les couvrir. Si vous, lecteur·rice, voulez prendre le temps de le faire et effectuer ces analyses vous-même, je serai plus que contente de publier vos résultats sur ce blog (vous pouvez me contacter ici).

Conclusion

Voici un résumé des résultats, qui indique pour chaque implémentation quel appareil est meilleur :

CompilateurOption d’optimisationcomputation() complexecomputation() trivialContrôle de boucle lourd
Clang 12.0-OgAucunAucunAppareil de Duff
Clang 12.0-O1AucunAucunAppareil de Duff
Clang 12.0-O2AucunBoucle simpleAucun
Clang 12.0-O3AucunBoucle simpleAucun
GCC 10.3-OgAucunAucunAppareil de Duff
GCC 10.3-O1AucunBoucle simpleAppareil de Duff
GCC 10.3-O2AucunAucunAppareil de Duff
GCC 10.3-O3AucunAppareil de DuffAucun

Comment interpréter ces résultats ?

Premièrement, quand on a une computation complexe et une structure de contrôle triviale, il n’y a pas de différence significative entre les deux.

Deuxièmement, quand la computation est triviale, c’est plus souvent la boucle simple qui l’emporte.

Troisièmement, conformément à nos éventuelles attentes, l’appareil de Duff est meilleur dans le cas d’une computation triviale mais d’un contrôle de boucle complexe.

Et pour finir, les résultats vont presque toujours dépendre de votre implémentation. En faisant mes recherches pour cet article, j’ai testé différentes implémentations de l’appareil de Duff et je me suis souvent rendue compte que la plus petite modification dans le code pouvait renverser les résultats des benchmarks.

Ce que je veux dire, c’est que l’appareil de Duff vaut, encore aujourd’hui, la peine d’être pris en considération3, mais vous devrez faire vos propres benchmarks pour vous assurer que, dans votre cas spécifique, il est effectivement plus efficace.

Merci de votre attention et à la semaine prochaine !

ticle original : Duff’s device in 2021 | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Le « cpu_time » indiqué est une unité abstraite de mesure, affichée par Quick-bench. Elle n’a par elle-même pas de sens, elle sert juste à être comparée à elle-même dans les différentes implémentations benchmarkées.
  2. Ces résultats dépendent aussi de l’implémentation de chaque compute_*(). Par exemple, si vous évaluez (loop_size+3/4) à chaque passage de boucle au lieu de la mettre dans une constante, les résultats sous GCC vont être très différent et l’appareil de Duff ne sera plus significativement meilleur avec -O3.
  3. J’écris cette note juste pour vous rappeler une règle triviale : l’optimisation de temps d’exécution n’a de sens que si votre code est, en terme de temps d’exécution, particulièrement sensible. Si votre code n’est pas un goulot d’étranglement, vous ne devriez même pas considérer utiliser l’appareil de Duff. Restez simples et n’oubliez pas la règles des 80-20.

Pragma: une once de problèmes

Article original : Pragma: once or twice? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Contexte

Les header guards

Les header guards (littéralement : protections de header) sont une méthode très répandue pour protéger les fichiers header des inclusions multiples, et ainsi de ne pas avoir plusieurs fois la même définition de variable, fonction ou classe.

Normalement, tous les développeurs C++ se sont vus enseigner cette méthode.

En voici un exemple :

#ifndef HEADER_FOOBAR
#define HEADER_FOOBAR

class FooBar
{
    // ...
};

#endif // HEADER_FOOBAR

Pour ceux qui ne sont pas familiers avec son fonctionnement, voici ce qui se passe : la première fois que le fichier est inclus, la macro HEADER_FOOBAR n est pas définie. Nous entrons donc dans la directive de contrôle #ifndef. Dedans, on définit la macro HEADER_FOOBAR et la classe FooBar. Plus tard, si on inclut de nouveau ce fichier, puisque la macro HEADER_FOOBAR est désormais définie, on ne rentre pas dans le #ifndef, du coup la classe FooBar n’est pas redéfinie.

#pragma once

#pragma est une directive de précompilation qui prodigue des informations additionnelles au compilateur, au-delà de ce que fournit déjà le langage lui-même.

Tout compilateur est libre d’interpréter les directives #pragma comme il l’entend. Cependant, au fil des années, certaines directives ont acquis plus de popularité que les autres et sont maintenant presque des standards (comme #pragma once, qui est le sujet de cet article, ou encore #pragma pack).

#pragma once est une directive qui indique au compilateur d’inclure le fichier où elle est présente une seule fois. C’est au compilateur de gérer comment il fait ça.

Du coup, instinctivement, on pourrait se dire que #pragma once fait le même travail qu’un header guard, mais en mieux puisqu’il fait ça en une ligne au lieu de trois, et sans avoir à réfléchir à un nom de macro.

Aujourd’hui ?

Autrefois, #pragma once n’était pas implémentée sur tous les compilateurs, elle était donc moins portable que les header guards.

Mais aujourd’hui, en C++, il n’y a (à ma connaissance) aucun compilateur qui ne l’implémente pas.

Donc pourquoi continuer à utiliser les header guards ? Réponse : à cause du problème que je vais détailler dans la section suivante.

Un problème bizarre avec #pragma once

La problématique que je m’apprête à décrire ne peut pas arriver avec des header guards, ce qui rend #pragma once particulièrement problématique.

Disons, par exemple, que votre fichier header est dupliqué pour une raison qui vous échappe. Cela peut être parce que:

  • Vous avez raté un merge, et votre gestionnaire de version a gardé une version de chaque fichier.
  • Votre gestionnaire de version a mal géré le déplacement d’un fichier.
  • Votre système de fichier possède plusieurs points de montage sur le même disque, ce qui fait que chaque fichier peut-être accéder par deux chemins différents, ce que le compilateur comprend comme étant deux fichiers différents.
  • Quelqu’un a copié-collé un des fichiers du projet pour son usage personnel à un autre endroit du projet, sans renommer quoique ce soit (c’est très malpoli, mais ça peut arriver).

(notez que j’ai déjà rencontré chacun de ces problèmes sur des projets réels.)

Quand ce genre de cas de figure survient, les #pragma once et les header guards ne se comportent pas de manière identique:

  • Comme les macros qui protègent les headers dupliqués ont le même nom, les header guards fonctionnent parfaitement bien et un seul des fichiers dupliqués est inclus.
  • Comme le FS indique qu’il s’agit de fichiers différents, le #pragma once protège chaque duplicata indépendamment, ce qui mène fatalement à une collision de nom.

Des problèmes avec les header guards ?

Les header guards on des problèmes qui leur sont spécifiques également. Par exemple, s’il y a une typographie dans la macro de protection, alors le header guard ne fonctionnera pas. De plus, si les conventions de nom sont mal formulées, certains header guards peuvent avoir le même nom (alors qu’ils protègent des fichiers différents).

Cependant, éviter ces deux problèmes est trivial (les typos sont faciles à voir et si vous avez une convention de nommage correcte, tout ira bien).

Avec un gros avantage !

Il existe aussi un avantage non négligeable aux header guards dans le cadre d une stratégie de test.

Disons que vous voulez tester la classe Foo (dans Foo.h) qui utilise la classe Bar (dans Bar.h). Mais, pour des raisons de test, vous voulez bouchonner Bar.

Une possibilité que vous permet les header guards est de créer votre propre bouchon de Bar (dans le fichier BarMock.h). Si le bouchon utilise les mêmes header guards que l’original, alors vous pouvez inclure BarMock.h puis Foo.h sans que le header Bar.h ne soit inclus (puisque les protections ont déjà été levée dans BarMock.h).

Du coup, dois-je utiliser #pragma once ou des header guards?

Cette question dont il est un peu complexe de répondre. Voici les possibilités qui s’offrent à vous :

  • #pragma once est non standard et cause des problèmes majeurs quand vous tombez dans un environnement dégradé.
  • Les header guards peuvent causer des problèmes si elles ne sont pas utilisées correctement.

D’après moi, les directives #pragma sont à éviter dès que possible. Si, en pratique, elles fonctionnent, elles ne sont pas formellement standards.

Cher C++20, quid des Modules ?

Les Modules, une des « big four » fonctionnalités du C++20, change notre approche du processus de build des projets. À la place d’avoir des fichiers source et header, on peut maintenant avoir des fichiers module. Ils dépassent complètement les restrictions des headers, augmentent la vitesse de compilation, réduisent les violations de la règle de One-Time-Definition et, surtout, permettent de se dispenser de directives de préprocesseur.

Grâce aux modules, on peut dire que les dilemmes de #pragma once et des header guards sont de l’histoire ancienne.

Pour en apprendre plus sur les module, allez-voir les articles suivants :

En conclusion

Cet article, parlant surtout des directives #pragma et des header guards concerne les projets qui sont sur une version antérieure au C++20. Si vous hésitez encore entre les #pragma once et les header guards, peut-être devriez-vous passer au C++20 ?

Si vous ne pouvez pas migrer aussi facilement que ça (ce qui est le cas de la plupart des projets industriels), alors choisissez méticuleusement entre #pragma once et les header guards,

Merci de votre attention et à la prochaine!

Article original : Pragma: once or twice? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Sources

[Histoire du C++] La genèse du cast

Article original : [History of C++] The genesis of casting. | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Les casts C

Afin de comprendre le processus de création des casts pour le C++, je pense qu’il est important de faire un rappel de comment fonctionnent les cast dit style-C, en C et en C++.

En C1

En C, il y a deux manières de faire des casts :

  • Une conversion arithmétique pour convertir une valeur numérique en une autre. Il peut y avoir des pertes de données si le type cible est plus étroit que le type de départ (par exemple, si vous convertissez un float en int ou un int en short).
  • Une conversion de pointeur, qui convertit un pointeur d’un type en pointeur d’un autre type. Cela peut fonctionner correctement, comme dans cet exemple, mais cela peut rapidement provoquer des erreurs, comme dans cet exemple, où les types ne sont pas exactement les mêmes, ou encore cet exemple, où la structure cible est plus grande que la structure origine. Vous pouvez aussi défausser un const avec cette conversion, comme ceci.

Même si, en tant que fonctionnalité C, elle a ses avantages et ses inconvénients, ce n’est pas un comportement adéquat pour le C++.

En C++

En C++, le cast style-C ne fonctionne pas de la même manière qu’en C (même si le résultat est assez similaire).

Quand vous faites un cast C en C++, le compiler tente d’appliquer les conversions suivantes, dans l’ordre, jusqu’à en trouver une qui marche :

  1. const_cast
  2. static_cast
  3. static_cast suivi d’un const_cast
  4. reinterpret_cast
  5. reinterpret_cast suivi d’un const_cast

C’est un processus qui est très peu apprécié par les développeurs C++ (ce qui est un euphémisme) car la conversion effective n’est pas explicite et ne capte pas d’éventuelle erreur au moment de la compilation. Plutôt que de donner une intention derrière la conversion, on demande au compilateur de chercher par tous les moyens possibles une manière de convertir, ce qui mène tôt ou tard à des conversions indésirables.

L’information de type à l’exécution

Idée originelle et controverses

L’informatiion de type à l’exécution (appelée Run-Time Type Information dans sa forme originale, que j’abrégerai en RTTI dans l’article) est un mécanisme qui permet de connaître le type d’un objet en cours d’exécution du programme.

C’est utilisé dans le polymorphisme, quand on manipule des objets via l’interface de leur classe-mère (et de fait sans savoir quelle dérivée de cette classe mère on manipule à la compilation).

Une première idée pour la RTTI en C++ a été imaginée dès le début, mais son développement et son implémentation ont été retardés dans l’espoir qu’elle ne soit pas nécessaire.

Certaines personnes, à l’époque, ont levé leur voix à l’encontre de cette fonctionnalité, clamant que cela nécessiterait beaucoup trop de support, que c’était trop lourd à implémenter, trop coûteux en performance, trop compliqué et déroutant, « intrinsèquement mauvais » à l’encontre de l’esprit du langage), ou étant vu comme le début d’une avalanche de nouvelles fonctionnalités. De plus, les casts style-C étaient aussi très critiqués à l’époque (les casts C++ n’étaient pas encore implémentés, bien sûr, et les conversions se faisaient comme en C).

Cependant, Bjarne Stroustrup a finalement décidé que cela valait le coup de l’implémenter. Il avait trois raisons à cela : c’était une fonctionnalité importante pour certaines personnes, cela n’impacterait pas ceux qui ne l’utiliseraient pas, et de toute manière les librairies allaient implémenter leur propre RTTI si on ne le faisait pas.

Au final, la RTTI a été implémentée en trois parties :

  • L’opérateur dynamic_cast, permettant d’avoir un pointeur d’une classe dérivée à partir du pointeur d’une classe mère — mais seulement si le pointeur correspond effectivement à un objet de cette classe dérivée.
  • L’opérateur typeid, permettant d’identifier le type exact d’un objet à partir d’un objet parent.
  • La structure type_info, donnant des informations additionnelles sur le type, à l’exécution.

Tôt dans le processus, Stroustrup a détecté un nombre conséquent de mauvaises pratiques, et certaines personnes l’ont même qualifiée de « fonctionnalité dangereuse ».

Mais la principale différence entre une fonctionnalité qui peut être mal utilisée et une qui va être mal utilisée se tient dans l’éducation des développeurs et les tests de design. Mais cela a toujours un coup, et la question a été : est-ce que les bénéfices d’une telle fonctionnalité vaut le coup de faire tous les efforts nécessaires pour rendre les mauvais usages anecdotiques ?

La décision finale a été « oui ». Un « oui » controversé, mais un « oui » quand même.

Syntaxe

Comme les casts ne pouvaient pas être rendu sécurisés, Stroustrup voulait fournir une syntaxe qui était à la fois explicite (dans le fait qu’on utilise une fonctionnalité dangereuse) et qui décourage son utilisation quand il existe des alternatives.

Plusieurs propositions ont été faites. Par exemple, Checked<T*>(p); pour les conversions vérifiées à l’exécution et Unchecked<T*>(p); pour les autres. Ou encore utiliser la syntaxe (virtual T*)p pour les conversions dynamiques, en association avec l’ancienne syntaxe pour les conversions statiques.

Mais en considérant les contraintes nommées précédemment et le fait que les conversions dynamiques et standardes sont deux opérations très différentes, il a été décidé de complètement changer la vieille syntaxe en faveur d’une syntaxe plus verbeuse, sous forme d’opérateurs unaires. Ce sont les opérateurs qu’on connait aujourd’hui, à savoir dynamic_cast<T*>(p) et static_cast<T*>(p) (qui seront suivis plus tard par les autres opérateurs de conversion).

tipeid() et type_info

La première intention d’implémentation de la RTTI n’envisageait que le dynamic_cast. Cependant, rapidement les développeurs ont manifesté le besoin d’avoir plus d’information sur les types manipulés (à savoir leur nom). Cela mena à la création de cette opération et cette structure.

La méthode typeid() peut être appelée sur tout objet polymorphique. Elle retourne une référence à un type_info qui contient toutes les informations nécessaires.

L’utilisation d’une référence plutôt qu’un pointeur a été préféré pour éviter qu’on fasse de l’arithmétique de pointeur dessus.

La structure type_info est une structure non-copiable, polymorphique, comparable, triable (pour qu’on puisse s’en servir dans les hashmap et autres) et contient le nom du type que l’objet associé implémente.

Bonnes et mauvaises pratiques

Maintenant, nous avons deux catégories d’objets disponibles : ceux qui ont une information de type à l’exécution et les autres. Il a été décidé d’accorder la RTTI uniquement aux classes polymorphiques, c’est-à-dire les classes qui peuvent être manipulées via leur classe mère (via la virtualisation).

Au début, les gens avaient peur de ne pas être toujours capable de distinguer facilement les objets qui avaient la RTTI et les autres. Mais au final ce n’est pas un problème majeur, puisque le compilateur est capable de lever une erreur quand on essaye d’utiliser un opérateur RTTI sur une classe qui n’est pas polymorphique.

Le plus gros problème qui a été (à juste titre) anticipé par Stroustrup était la sur-utilisation de la RTTI dans les programmes. Par exemple, on pourrait s’attendre à voir le code suivant :

void rotate(const Shape& r)
{
    if (typeid(r) == typeid(Circle)) 
    {
        // do nothing
    }
    else if (typeid(r) == typeid(Triangle)) 
    {
        // rotate triangle
    }
    else if (typeid(r) == typeid(Square)) 
    {
        // rotate square
    }
}

Cependant, c’est une mauvaise utilisation de la RTTI. En effet, ce code ne supporte pas correctement les classes dérivées de celles qui sont mentionnées. Utiliser la virtualisation pour le faire serait préférable.

Une autre mauvaise pratique serait la vérification de type non-nécessaire, comme dans l’exemple suivant :

Crate* foobar(Crate* crate, MyContainer* cont)
{
    cont->put(crate);
 
    // do things...
 
    Object* obj = cont->get();
    Crate* cr = dynamic_cast<Crate*>(obj)
    if (cr)
        return cr;
    // else, handle error
}

Ici, on vérifie manuellement le type de l’objet dans MyContainer, alors qu’il serait plus simple, efficace et sécurisé d’utiliser une version templatée du conteneur :

Crate* foobar(Crate* crate, MyContainer<Crate>* cont)
{
    cont->put(crate);
 
    // do things...
 
    return cont->get();
}

Ici, pas besoin de vérifier d’éventuelles erreurs et, surtout, pas besoin de RTTI.

Ces deux mauvaises pratiques sont souvent utilisées par des développeurs issus de langages où elles sont admises, utiles voire même encouragées (comme le C, le Pascal, etc.). Mais cela ne correspond pas au C++.

Fonctionnalités abandonnées

Voici une liste de fonctionnalités autour de la RTTI qui ont été abandonnées :

  • Les meta-objets : Cela aurait remplacé le type_info. Il se serait agi d’un mécanisme (le meta-objet) qui peut accepter (à l’exécution) n’importe quelle requête qui peut être effectuée sur n’importe quel objet du langage. Cependant cela aurait obligé le langage à embarquer un interpréteur, ce qui est une menace indicible pour les performances.
  • La requête de type : Cela aurait été une alternative au dynamic_cast, un opérateur permettant de savoir si un objet est une dérivée de telle classe ou non. Grâce à cela, on aurait (après avoir tester) convertir le pointeur avec un cast style-C pour l’utiliser en tant que tel. Cependant, vu qu’il y a une différence fondamentale entre dynamic_cast et static_cast il reste important de faire la distinction (l’application des deux opérateurs sur le même objet peut mener à des résultats différents). De plus, cela décorrelle la vérification et la conversion, ce qui peut mener à des erreurs (et que dynamic_cast ne fait pas).
  • Les relations entre types : Une suggestion pour rendre les types comparables (avec < et <= par exemple) pour dire si une classe est dérivée d’une autre a été étudiée, mais cela ne correspond pas à une réalité mathématique, donc est assez arbitraire. De plus, comme la proposition précédente, cela décorelle le test de la conversion.
  • Les multi-méthodes : cela fait référence à l’éventuelle capacité de choisir une fonction virtuelle basée sur plusieurs objets. Une telle mécanique serait notamment pratique pour ceux qui développent des opérateurs binaires. Cependant, à cette époque, Stroustrup n’était pas familier avec le concept et a décider d’attendre pour voir si c’était une réelle nécessité et se donner le temps d’en apprendre plus à ce sujet.
  • Les appels de méthode non-contraints : Cela aurait été pour autoriser le développeur à appeler n’importe quelle méthode d’une classe fille à partir de la classe mère, pour vérifier à l’exécution si cela était valide. Cependant, grâce à dynamic_cast, on peut faire la vérification nous-mêmes, ce qui est plus efficace et sécurisé.
  • L’initialisation vérifiée : Cela aurait été la capacité à initialiser un objet d’une classe dérivée à partir de sa classe mère, en vérifiant à l’exécution si cela est valide. Cependant, cela menait à des complexifications syntaxiques, des incertitudes dans la gestion des erreurs et cela peut être trivialement émulé avec un dynamic_cast.

Les casts style-C++

Problèmes et conséquences

Pour citer Bjarne Stroustrup, les casts style-C sont des « grosses massues ». Quand vous écrivez (B)expr, vous dites au compilateur « Transforme-moi expr en B, peu importe la manière. ». Ça peut devenir très embêtant quand cela implique des const ou des volatile.

En plus de cela, cette syntaxe est simpliste. Elle est dur à voir, dure à détecter automatiquement et elle provoque une avalanche de parenthèses quand on veut l’utiliser dans un constexte plymorphique2.

De ce fait, il a éte décider de séparer les différentes manières de résoudre un cast style-C en plusieurs opérateurs C++. Ainsi, quand vous écrivez un conversion, vous écrivez comment vous voulez convertir. De plus, cela ajoute de la verbosité à l’opération, ce qui rend la détection plus simple et permet d’avertir les développeurs qu’une opération risquée est effectuée.

Comme il y a des comportement à absolument éviter (du point de vue C++) avec les casts style-C, certains opérateurs de casts sont faits pour ne pas être utilisés (pour les isoler des « bons » opérateur de conversion). Ces comportements ne sont pas rendus obsolètes par le langage car il existe des situation où ils sont nécessaires, mais il y a un besoin de les séparer des autres pour être sûr qu’ils ne seront pas utilisé par accident.

Les différents opérateurs de conversion

dynamic_cast

Je ne parlerai pas beaucoup de cet opérateurs qui est au centre de la première partie de cet article. Il permet concrètement d’implémenter la RTTI pour le C++.

dynamic_cast effectue une conversion qui est vérifée à l’exécution. Si vous voulez une conversion statique, préferez l’opérateur suivant : static_cast.

static_cast

Le static_cast peut être décrit comme l’opération inverse de la conversion implicite. Si A peut être implicitement converti en B, alors B peut être static_casté en A. Cet opérateur peut également faire toutes les conversions implicites.

Ceci couvre la grande majorité des conversion qui ne nécessite pas une vérification dynamique du type.

Le static_cast respecte la constness2, ce qui le rend plus sécurisé que le casts style-C, et est statique, ce qui fait que les erreurs seront détectées à la compilation.

À chaque fois que vous faites un static_cast sur un type créé par un utilisateur, le compilateur va chercher soit un constructeur avec un unique paramètre qui correspond à la conversion (il va chercher le constructeur Bar(Foo) si vous essayez de convertir un Foo en Bar), soit l’opérateur de conversion associé. Plus d’informations concernant ce mécanisme sur la page suivante : user-defined conversion function – cppreference.com.

Également, on ne peut pas faire de static_cast sur ou vers un pointeur qui pointe vers un type incomplet (mais ça peut être fait avec le cast suivant : reinterpret_cast).

reinterpret_cast

Le reinterpret_cast représente la partie « non-sécurisée » de la conversion style-C. Avec, vous pouvez convertir des valeurs d’un type à un autre, sans qu’il y ait de lien entre ces deux types.

Cette conversion réinterprète simplement les arguments qu’on lui donne. Vous pouvez aussi convertir un pointeur en une fonction et un pointeur en un membre.

Il est intrinsèquement non-sécurisé et doit êtr utilise avec beaucoup de précaution. Dès que vous voyez une reinterpret_cast, vous devez être très précautionneu·x·se. Utiliser reinterpret_cast est presque aussi peu sécurisé qu’utiliser un cast style-C.

Un reinterpret_cast peut très aisément mener à un comportement indéfini. Vous avez les règles précises d’utilisation sur la page suivante: reinterpret_cast conversion – cppreference.com.

Par exemple, si vous utilisez reinterpret_cast [pir convertir un pointer vers un type en pointeur vers un autre type, puis que vous déréférencez ce pointeur pour accéder à ses membres, c’est un comportement indéfini.

const_cast

Le but de cet opérateur est de faire en sorte que les qualifieurs const et volatile ne sont jamais implicitement retirés à travers un cast.

Pour faire un const_cast, il faut que le type source et le type cible soient exactement les mêmes, à un qualifieur const/volatile près.

C’est une opération très dangereuse et elle aussi doit être utilisée avec beaucoup de précautions. Souvenez-vous toujours que retirer le const d’un objet qui a été originellement définit constant est un comportement indéfini.

bit_cast

Cet opérateur n’est pas a proprement parlé historique (il a été introduit en C++20), mais il a été introduit pour remplacer les conversions « manuelles » bit-à-bit qu’on faisait auparavant avec std::memcpy().

Son comportement peut être indéfini s’il n’y a pas de valeur dans le type destination correspondant à la valeur de représentation produite (un peu comme avec un memcpy standard).

Conclusion générale

Historiquement, la manière dont l’opérateur de cast C a été divisé en quatre opérateurs C++ suit des règles simples :

  • Si vous avez besoin de vérifier dynamiquement le type, il vous faut un dynamic_cast.
  • Si vous pouvez vérifier les types de manière statique, il vous faut un static_cast.
  • Dans tous les cas spécifiquement particuliers où les autre type ne fonctionnent pas, c’est un reinterpret_cast ou un const_cast qui marchera, mais c’est toujours un risque conséquent de les utiliser.

J’ajouterai à ça qu’il ne faut, dans toutes le ssituations, jamais faire un reinterpret_cast ou un const_cast sauf si vous svez ce que vous faites. Ne faites jamais, au grand jamais ces conversions juste parce que le static_cast ne compile pas.

La RTTI dans son ensemble est utile — bien qu’optionnelle. Mais elle n’est pas simple à maîtriser.

En C++ moderne, nous voulons faire le plus de vérification possibles à la compilation (pour des raisons de sécurité), donc dès qu’on le peut, on utilisers des fonctionnalités statiques plutôt que dynamique.

Bien entendu, il ne faut pas se forcer à faire absolument du code statique là ou le code dynamique serait objectivement meilleur, mais il faut toujours penser à une solution statique avant une solution dynamique.

Merci de votre attention et à la semaine prochaine !

Article original : [History of C++] The genesis of casting. | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Sources

Par ordre d’apparition :

Notes

1 – Pour autant que je me considère experte en C++, mes connaissances du langage C sont assez limitées. Il pourrait y avoir des erreurs dans cette sous-section, ainsi j’invoque votre indulgence et votre participation pour indiquer en commentaire les éventuelles inexactitudes.

2 – Par exemple, si px est un pointeur sur un objet de type X (implémenté en tant que B) et B une classe dérivée de X, qui possède une méthode g. Pour appeler g à partir de px vous devez écrire (en style-C) ((B*)px)->g(). On peut tout à fait envisager des syntaxes plus simples, comme px-> B::g().

[Histoire du C++] Les templates : des macros C aux concepts

Article original : [History of C++] Templates: from C-style macros to concepts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Introduction : les types paramétrés

Template est, en C++, le nom donné à la fonctionnalité (ou plutôt au groupe de fonctionnalités, puisque le mot est utilisé dans plusieurs contextes) qui implémente les types paramétrés.

La notion de types paramétrés (ou parametrized types dans sa dénomination originale) est très importante dans la programmation moderne. Elle consiste à utiliser un type en tant que paramètre d’une fonctionnalité, de manière à ce que cette fonctionnalité puisse être utilisée avec plusieurs types différents, et ce de la même manière qu’on utilise une fonctionnalité avec différentes valeurs.

L’exemple le plus simple est avec std::vector. Quand vous déclarez un vecteur comme ceci : std::vector<int> foo;, le type int est paramétré. Vous auriez tout aussi bien pu mettre un autre type, comme doublevoid* ou une classe définie par vous.

C’est une manière de faire de la métaprogrammation, c’est-à-dire écrire un programme dont le but est de modifier les données d’un autre programme (ou d’une autre partie de son programme).

Pour le reste de l’article (y compris la partie 2), j’utiliserai le mot « template » pour renvoyer à la fois au concept de types paramétrés et à son implémentation en C++ (sauf dans les cas où je voudrais faire explicitement la distinction).

Avant les templates

Avant la création des templates, au début du C++, on devait écrire des macros de style C pour les émuler.

Une manière de faire était la suivante :

foobar.h

void foobar(FOOBAR_TYPE my_val);

foobar.cpp

void foobar(FOOBAR_TYPE my_val)
{
    // do stuff
}

main.cpp

#define FOOBAR_TYPE int
#include "foobar.h"
#include "foobar.cpp" // Only do this in a source file
#undef FOOBAR_TYPE
 
#define FOOBAR_TYPE double
#include "foobar.h"
#include "foobar.cpp" // Only do this in a source file
#undef FOOBAR_TYPE
 
int main()
{
    int toto = 42;
    double tata = 84;
    foobar(toto);
    foobar(tata);
}

Mais ne faites pas ça chez vous ! Ce n’est pas quelque chose qu’on veut faire de nos jours (surtout la partie #include <foobar.cpp>). Aussi, notez que ce code utilise la fonctionnalité des surcharges de fonctions, propre au C++, et que donc ce code ne compile pas en C.

De notre point de vue moderne, cela peut sembler très limité et sujet à erreurs. Mais la chose intéressante à retenir est que puisque les templates étaient utilisés avec des macros avant même le C++, elles pouvaient être utilisées au début du C++ et donc permettre à l’équipe de design du langage de gagner de l’expérience avant son implémentation réelle.

Timing

Les templates ont été introduits avec la version 3.0 du langage, en octobre 1991. Dans The Design and Evolution of C++, Stroustrup révèle que c’était une erreur de les introduire aussi tard, et qu’en rétrospective il aurait été préférable de l’introduire dans la version 2.0 (juin 1989) quitte à mettre de côté d’autres fonctionnalités moins importantes, comme l’héritage multiple :

Aussi, ajouter l’héritage multiple dans la Version 2.0 était une erreur. L’héritage multiple a sa place en C++, mais est beaucoup moins importante que les types paramétrés — et pour certaines personnes, les types paramétrés le sont encore moins que les exceptions.
— Bjarne Stroustrup, The Design and Evolution of C++, chapitre 12: Multiple Inheritance, §1 – Introduction

Avec le recul d’aujourd’hui, il est clair que Stroustrup avait raison et que les template ont impacté le paysage du C++ beaucoup, beaucoup plus que l’héritage multiple.

Cet ajout est arrivé tard car il était très chronophage pour les concepteurs d’explorer les designs et les soucis potentiels d’implémentation.

Besoins et objectifs

Le besoin originel pour les templates était de paramétrer les classes de conteneurs. Pour ce travail, les macros étaient trop limitées. Elles ne respectaient pas les périmètres de nommage et interagissaient très mal avec les outils (en particulier les debuggers). Avant les templates C++, il était très difficile de maintenir du code qui utilisait des type paramétrés, nécessitait de code dans un niveau d’abstraction très bas et on devait ajouter chaque instance de type paramétré à la main.

Les premières inquiétudes concernant les templates étaient les suivantes : est-ce que les templates seraient aussi faciles à utiliser que les objets codés à la main ? Est-ce que les temps de compilation et de link seraient significativement impactés ? Et est-ce que ce serait facilement portable ?

Le processus de développement des templates

La dernière fois nous avions vu l’origine des templates, l’idée de départ et la note d’intention. Cette semaine nous allons voir comment les template ont été construit et comment ils évoluent encore aujourd’hui.

Syntaxe

Les chevrons

Concevoir la syntaxe d’une fonctionnalité n’est pas aisé et nécessite beaucoup de questionnement.

Le choix des chevrons <...> pour les paramètres de template a été fait parce que même si les parenthèses auraient été plus pratique pour les analyseurs, elles sont très utilisées en C++, et les chevrons sont plus plaisants à lire dans ce contexte.

Ceci dit, cela pose des problèmes pour les chevrons imbriqués, comme ceci :

List<List<int>> a;

Dans ce petit bout de code, au début du C++, vous auriez eu une erreur de compilation car les chevrons fermant >> auraient été perçu comme l’opérateur de flux operator>>() et non comme deux chevrons fermants.

Depuis le C++141, une motion lexicale a été ajoutée pour prendre cela en compte et ne plus le considérer comme une erreur de compilation.

L’argument de template

Au départ, l’argument de template aurait été placé juste après l’objet qu’on template :

class Foo<class T>
{
    // ...
};

Mais cela posait deux problèmes :

  • C’est assez dur à lire pour les analyseurs automatiques et pour les humains. Comme l’indication qu’il s’agit d’un template est imbriqué dans la définition de l’objet, c’est un peu difficile à détecter.
  • Dans le cas des fonctions templatées, le type templaté peut être utilisé avant d’être déclaré, comme ceci: T at<class T>(const std::vector<T>& v, size_t index) { return v[index]; }. Comme T est le type de retour il est vu (par les analyseurs automatiques) avant même qu’on sache qu’il s’agit d’un type templaté.

Ces deux problèmes sont réglés si on déclare le template avant l’objet, comme ceci :

template<class T> class Foo
{
    // ...
};
 
template<class T> T at(const std::vector<T>& v, size_t index) { return v[index]; }

Et c’est ce qui a été retenu.

Les contraintes sur les paramètres de template

En C++, les contraintes sur les paramètres des templates sont implicites2

Le dilemme suivant est apparu à la création des templates : est-ce qu’on doit rendre les contraintes explicites ou pas ?

Un exemple de contrainte explicite a été proposé comme ceci :

template < class T {
        int operator==(const T&, const T&); 
        T& operator=(const T&);
        bool operator<(const T&, int);
    };
>
class Foo {
    // ...
};

Mais cela a été jugé trop verbeux et il aurait fallu écrire plus de templates pour le même nombre de features au final. De plus, cela limite un peu trop fort les possibilités des classes qu’on implémente, excluant des implémentations qui auraient été tout à fait correcte sans elles3.

Cependant, l’idée d’avoir des contraintes explicites n’a pas été abandonnée, c’est juste que les exprimer ainsi n’était pas la bonne manière de faire.

Une autre manière de faire (qui a été envisagée) a été via des classes dérivées. En spécifiant que tel template doit dériver de telle classe, on obtient un moyen explicite d’ajouter des contraintes :

template <class T>
class TBase {
    int operator==(const T&, const T&); 
    T& operator=(const T&);
    bool operator<(const T&, int);
};
 
template <class T : TBase>
class Foo {
    // ...
};

Cependant cette méthode créé plus de problèmes que cela n’en règle. Les développeurs sont, avec cette méthode, encouragés à exprimer les contraintes en tant que classes, menant à une sur-utilisation de l’héritage. Il y a une perte d’expressivité et de sens sémantique, parce que « T doit être comparable à une int » devient « T doit hériter de TBase ». De plus, vous ne pouvez pas exprimer des contraintes sur des types qui ne peuvent pas avoir de classe mère, comme int ou double.

Ce sont les raisons pour lesquelles nous n’avons pas eu de moyen d’exprimer des contraintes sur les paramètres de template pendant très longtemps4.

Mais tout vient à point à qui sait attendre, et le débat a été ranimé à la fin des années 2010, ce qui a mené à la création des Concepts en C++20 (c.f. Évolutions modernes – Concepts plus bas).

La génération d’objets templatés

La manière dont les templates sont compilés est assez simple : pour chaque jeu de paramètres de templates (pour un objet templaté donné), le compilateur va généré autant d’implémentations de cet objet en utilisant explicitement ce jeu de paramètres.

Donc, écrire ceci :

template <class T> class Foo { /* ... do things with T ... */ };
template <class T, class U> class Bar { /* ... do things with T  and U... */ };
 
Foo<int> foo1;
Foo<double> foo2;
Bar<int, int> bar1;
Bar<int, double> bar2;
Bar<double, double> bar3;
Bar< Foo<int>, Foo<long> > bar4;

Est la même chose qu’écrire cela :

class Foo_int { /* ... do things with int ... */ };
class Foo_double { /* ... do things with double ... */ };
class Foo_long { /* ... do things with long ... */ };
class Bar_int_int { /* ... do things with int  and int... */ };
class Bar_int_double { /* ... do things with int  and double... */ };
class Bar_double_double { /* ... do things with double  and double... */ };
class Bar_Foo_int_Foo_long { /* ... do things with Foo_int  and Foo_long... */ };
 
Foo_int foo1;
Foo_double foo2;
Bar_int_int bar1;
Bar_int_double bar2;
Bar_double_double bar3;
Bar_Foo_int_Foo_long bar4;

… sauf que c’est plus verbeux et moins générique.

Classes templatées

À la base, les templates ont été imaginés pour les classes, en particulier pour l’implémentation de conteneurs standards. Ils ont été pensés pour être aussi simples à utiliser que les classes standardes et aussi performantes que les macros. Ces deux faits ont été décidés pour que les tableaux bas niveaux puissent être abandonnés quand ils n’étaient pas spécifiquement utiles (comme en programmation très bas niveau) et que les conteneurs templatés soient préférés pour les plus hauts niveaux.

En plus des paramètres typés, les templates peuvent avoir des paramètres non-typés, comme suit :

template <class T, int Size>
class MyContainer {
    T m_collection[Size];
    int m_size;
public:
    MyContainer(): m_size(Size) {}
    // ...
};

Cela a été conçu pour permettre d’utiliser des conteneurs de taille statique. Avoir la taille directement dans le type du conteneur permet d’avoir une implémentation plus performante.

class Foo;
 
int main()
{
    Foo[700] fooTable; // low-level container
    MyContainer<Foo, 700> fooCnt; // high-level container, as efficient as the previous one
}

Fonctions templatées

L’idée des fonctions templatées est venue directement du besoin d’avoir des méthodes de classe templatées et de l’idée selon laquelle les fonctions templatées sont dans la continuité logique des classes templatées.

Aujourd’hui, l’exemple le plus classique de fonction templaté auquel on peut penser sont les algorithmes de la STL (std::find()std::find_first_of()std::merge(), etc.). Même à sa création, les algorithmes de la STL n’existaient pas, c’était ce genre de fonctions qui a inspiré les fonctions templatées (la plus symbolique étant sort()).

La principale problématique a été de savoir comment déduire les paramètres du template à partir des arguments et du type de retour sans avoir à les spécifier à chaque appel.

Dans ce contexte, il a été décidé que les arguments de templates pouvaient à la fois être déduits (quand cela est possible) et spécifiés (quand c’est nécessaire). C’est extrêmement utile pour spécifier une valeur de retour, car celle-ci ne peut pas toujours être déduite, comme dans l’exemple suivant :

template <class TTo, class TFrom>
TTo convert(TFrom val)
{
    return val;
}
 
int main()
{
    int val = 4;
    convert(val); // Error: TTo is ambiguous
    convert<double, int>(val) // Correct: TTo is double; TFrom is int
    convert<double>(val) // Correct: TTo is double; TFrom is int; 
}

Comme vous pouvez le voir ligne 12, les arguments de template peuvent être en partie (ou en intégralité) ignorés, en partant du dernier.

La manière dont les templates sont générés (confère à la section La génération d’objets templatés ci-avant) fait qu’ils fonctionnent parfaitement bien avec la surcharge de fonctions. La seule subtilité intervient quand on a à la fois des surcharges templatées et des surcharges non-templatées. Dans ce cas, les surcharges non-templatées sont prioritaires sur celles qui sont templatées, s’il y a une correspondance parfaite. Sinon, on prend la version templatée s’il est possible d’avoir une correspondance parfaite. Sinon, on résout la surcharge comme une surcharge ordinaire.

Instanciation de templates

Au tout début, l’instanciation explicite de templates n’était pas vraiment envisagée. C’était parce que cela pouvait créer des problèmes complexes à résoudre dans certaines circonstances spécifiques. Par exemple : si deux parties (sans lien) d’un code indiquent chacune qu’elles veulent la même instanciation d’un objet templaté, ce qui aurait besoin d’être fait sans réplication de code et sans gêner le link dynamique. C’est pourquoi au début, il semblait préférable de n’avoir que des instanciations implicites.

La première instanciation automatique de template s’est déroulée ainsi : quand le linkeur est lancé, il cherche les instanciations de template manquantes. Il rappelle alors le compilateur pour qu’il les génère. On relance alors le linkeur, qui cherche alors de nouveau les instanciations manquantes, et ainsi de suite jusqu’à ce qu’on ait toutes les instanciations de template dont on a besoin.

Cependant, cette manière de faire avait plusieurs soucis, notamment celui d’être très peu performante (puisqu’on fait beaucoup de va-et-viens entre le compilateur et le linkeur).

C’est pour mitiger ce problème que les instanciations de template explicite ont finalement été introduites.

Le développement des instanciations de templates a eu beaucoup d’autres soucis et écueils, comme le point d’instanciation (aussi appelé « le problème des noms », c’est-à-dire localiser précisément à quelle déclaration les noms invoqués dans un template font référence), les problèmes de dépendance, la résolution d’ambiguïtés, etc. Les évoquer tous dans le détail requerrait un article dédié.

Évolutions modernes

Les templates sont une fonctionnalité qui a continué d’évoluer alors même qu’on entrait dans l’ère moderne du C++ (qui commença avec le C++11).

Templates variadiques

Les templates variadiques sont des templates qui ont au moins un paquet de paramètres. Un paquet de paramètre (parameter pack en VO) est une manière d’indiquer qu’une fonction ou un template a un nombre variable de paramètres.

Par exemple, la fonction suivante utiliser un paquet de paramètres :

void foobar(int args...);

Et peut être appelée avec n’importe quel nombre d’argument (mais toujours au moins 1) :

foobar(1);
foobar(42, 666);
foobar(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16);

Les templates variadiques permettent la même chose, mais avec des types différents pour chaque paramètre.

Avec cela, on peut écrire des fonctions beaucoup plus génériques. Par exemple :

#include <iostream>
 
struct MyLogger 
{
    static int log_counter;
 
    template<typename THead>
    static void log(const THead& h)
    {
        std::cout << "[" << (log_counter++) << "] " << h << std::endl;
    }
 
    template<typename THead, typename ...TTail>
    static void log(const THead& h, const TTail& ...t)
    {
        log(h);
        log(t...);
    }
};
 
int MyLogger::log_counter = 0;
 
int main()
{
    MyLogger::log(1,2,3,"FOO");
    MyLogger::log('f', 4.2);
}

Ce code génère la sortie suivante :

[0] 1
[1] 2
[2] 3
[3] FOO
[4] f
[5] 4.2

On peut aisément supposer que la motivation derrière les templates variadiques (et les paquets de paramètres) est de pouvoir implémenter des fonctions encore plus genériques, au détriment parfois du volume de code généré (en l’occurrence, dans l’exemple précédent, la classe MyLogger a 8 surcharges de la fonction log5).

Vous trouverez plus de détails sur cette page : Parameter pack (since C++11) – cppreference.com.

Concepts

Les concepts sont une fonctionnalité introduite en C++20 qui vise à donner au développeur une manière de déclarer des contraintes sur les paramètres d’un template. Cela mène à du code plus clair (avec un plus haut niveau d’abstraction) et des messages d’erreur moins abscons.

Par exemple, voici une déclaration de concept :

template<typename T_>
concept Addable = requires(T_ a, T_ b)
{
    a + b;
};

Et un exemple de son utilisation :

template<typename T_>
requires Addable<T_>
T_ foo(T_ a, T_ b);
 
template<typename T_>
T_ bar(T_ a, T_ b) requires Addable<T_>;
 
auto l = []<typename T_> requires Addable<T_> (T_ a, T_ b) {};

Avant cela, les erreurs de compilations liées à des templates était à peine lisible. Les concepts sont devenus (au cours des années précédant le C++20) une fonctionnalité très attendue.

Un bon aperçu des concept est trouvable sur le blog d’Oleksandr Koval : All C++20 core language features with examples | Oleksandr Koval’s blog (oleksandrkvl.github.io).

Guides de déduction

Les guides de déduction de templates sont une fonctionnalité du C++17 et sont des motifs qui, associés à un objet templaté qui indiquent au compilateur comment interpréter les paramètres (et leur type).

Par exemple :

template<typename T_>
struct Foo
{
  T_ t;
};
  
Foo(const char *) -> Foo<std::string>;
  
Foo foo{"A String"};

Dans ce code, l’objet foo est un Foo<std::string> et non un Foo<const char*> comme on pourrait éventuellement le penser. De ce fait, foo.t est une std::string. C’est grâce à l’indication qu’on laisse sous la forme d’un guide de déduction, indiquant au compilateur que quand on utilise le type const char* on veut utiliser l’instanciation std::string du template.

C’est particulièrement utile pour des objets comme les vecteurs, qui peuvent ainsi avoir ce genre de constructeur :

template<typename Iterator> vector(Iterator b, Iterator e) -> vector<typename std::iterator_traits<Iterator>::value_type>;

De ce fait, si on appelle le constructeur du vecteur avec un itérateur, le compilateur comprendra qu’on veut non pas un vecteur d’itérateurs, mais un vecteur contenant des objets de même type que celui pointe par l’itérateur.

Substitution Failure Is Not An Error

L’échec de substitution n’est pas une erreur (Substitution Failure Is Not An Error, ou SFINAE parce que c’est un nom beaucoup trop long) est une règle qui s’applique pendant la résolution d’une fonction templatée qui possède plusieurs surcharges.

Elle signifie que si la résolution du type (déduit ou spécifié) du template d’un paramètre échoue, alors la spécialisation est mise de côté sans générer d’erreur (et on continue d’essayer de résoudre le template normalement).

Par exemple, prenons le code suivant :

struct Foo {};
struct Bar { Bar(Foo){} }; // Bar can be created from Foo
  
template <class T>
auto f(T a, T b) -> decltype(a+b); // 1st overload
  
Foo f(Bar, Bar);  // 2nd overload
  
Foo a, b;
Foo x3 = f(a, b);

Instinctivement, on pourrait se dire que c’est la première surcharge qui est appelée ligne 10 (parce que l’instanciation qui utilise Foo en tant que T est une surcharge plus adéquate que l’autre, qui nécessite une conversion).

Cependant, l’expression (a+b) n’est pas solvable pour le type Foo. Mais à la place de générer une erreur de compilation (du type « pas d’opérateur ‘+’ n’a été trouvé pour Foo ») cette surcharge est mise de côté. Il ne reste plus que l’autre surcharge, qui fonctionne parce qu’on peut convertir implicitement un Foo en Bar.

Ce genre de substitution se produit pour tous les types utilisés dans les types de fonction et pour tous les types utilisés dans les déclarations de paramètres templatés. Depuis le C++11, cela se produit également pour toutes les expressions utilisées dans le type d’une fonction et toutes les expressions utilisées dans les déclarations de paramètres templatés. Depuis le C++20, cela se produit aussi pour toutes les expressions utilisées dans les spécifieurs explicites.

La documentation complète du SFINAE se trouve là : SFINAE – cppreference.com.

Autres features en C++20

Les templates continuent toujours d’évoluer aujourd’hui. Voici une petite liste des fonctionnalités concernant les templates qui sont apparue en C++20 et auxquelles je n’ai pas pu faire une place dans cet article :

  • Les listes de paramètres templatés pour les lambdas génériques. Parfois les lambdas génériques sont trop génériques. Le C++20 permet d’utiliser la syntaxe familière de fonctions templatées pour introduire directement des noms de type.
  • La déduction d’argument de template de classe pour les agrégats. En C++17 on avait besoin d’expliciter des guides de déduction pour la déduction d’argument de template avec les agrégats. Plus maintenant.
  • Les classes dans des paramètres de template non-typés. Les paramètres de template non-typés peuvent maintenant être des classes littérales.
  • Les paramètres des template non-typé généralisés. Les paramètres des template non-typé sont généralisés aux soi-disants type structuraux.

Exceptions et templates : les deux revers d’une même médaille

Je n’ai pas parlé d’exceptions dans cet article, mais pour Stroustrup, les exceptions et les templates sont des fonctionnalités complémentaires :

Dans ma tête, les templates et les exceptions sont deux revers d’une même médaille : les templates permettent de réduire le nombre d’erreurs d’exécution en étendant le panel de problèmes que la vérification statique de type peut résoudre ; les exceptions fournissent un mécanisme pour traiter les erreurs d’exécution restantes. Les templates font que le traitement des exceptions est faisable en réduisant le besoin de gérer des erreurs à l’exécution, ne laissant que les cas essentiels. Les exceptions font que les librairies générales basées sur des templates sont gérables en donnant à ces librairies une manière de remonter des erreurs.
— Bjarne Stroustrup, The Design And Evolution Of C++, Chapitre 15 : Templates, §1 – Introduction

Donc, par construction, les templates et les exceptions sont liées, en plus d’élever le niveau d’abstraction du code où elles sont employées.

Cependant, les exceptions et les templates (surtout les templates) ont évolué depuis, donc je pense que cela n’est plus trop vrai aujourd’hui.

Conclusion

D’après moi, les templates sont le plus gros poisson dans le lac métaphorique qu’est le C++. On n’en parlera jamais assez, et je pense qu’ils continueront d’évoluer pour des décénnies encore.

Il en est ainsi car, dans le C++ moderne, une des idées-clés est qu’on souhaite écrire des intentions plutôt que des actions. Nous voulons un niveau d’abstraction toujours plus élevé et plus de métaprogrammation. Ainsi, il est normal que les templates soient au coeur des évolutions actuelles.

Merci de votre attention et à la semaine prochaine !

Article original : [History of C++] Templates: from C-style macros to concepts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Notes


1 – J’ai réussi à isoler le changement dans le compilateur de GCC à la version 6 ([https://godbolt.org/z/vndGdd7Wh][11]), suggérant que ça a effectivement été pris en compte lors du passage au C++14. J’ai réussi à observer la même chose auprès de clang, à la version 6 ([https://godbolt.org/z/ssfxvb4cM][12]), ce qui confirme cette hypothèse.

2 – On appelle ça le duck-typing (le typage-canard). Si ça ressemble à un canard, que ça nage comme un canard et que ça cancane comme un canard, alors c’est probablement un canard.

3 – Je n’ai cependant pas d’exemple concret a présenter pour illustrer cette assertion et je ne fais que plus ou moins paraphraser Stroustrup sur le sujet. Cependant, l’idée d’avoir des contraintes implémentées par l’utilisateur ferme des portes dont vous ne saviez pas qu’elles existaient et qui pourraient tout à fait être exploitées.

4 – Il y a eu d’autres essais pour exprimer des contraintes, mais sans succès. Vous pouvez avoir plus de détails sur ces essais au paragraphe §15.4 de Stroustrup: The Design and Evolution of C++.

5 – Ces instanciations sont (d’après le code assembleur – Compiler Explorer (godbolt.org)):
– log(int);
– log(char[4]);
– log(char);
– log(double);
– log(int,int,int,char[4]);
– log(int,int,char[4]);
– log(int,char[4]);
– log(char,double);

Sources

Par ordre d’apparition :

[Histoire du C++] Pourquoi le mot-clé `class` n’a plus de raison d’exister 

Article original : [History of C++] Explanation on why the keyword `class` has no more reason to exist | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Introduction de ce nouveau concept : l’Histoire du C++

Il y a quelques mois (au tout début de cette série) je m’intéressais à ce qu’on pouvait faire d’intéressant en C++, quand j’ai réalisé une chose : le mot-clé class n’a, aujourd’hui, pas de raison valable d’exister !

Cela peut sembler un peu brut comme formulation, mais je vais tout détailler au cours de l’article.

Toujours est-il qu’en cherchant la raison pour laquelle le mot-clé class a été créé à la base, j’ai été amenée à me plonger dans le passé du langage. C’était une expérience intéressante et enrichissante.

Ainsi j’ai décidé d’écrire une mini-série qui relatera de l’histoire du C++ et visant à expliquer des concepts qui sont aujourd’hui dans le langage mais qui, en C++20, peuvent sembler obsolètes, étranges ou sujet à débat, en s’appuyant sur leurs origines.

Sources

Pour cette petite série, j’ai trois sources principales:

Notez que Sibling Rivalry: C and C++ et History of C++: 1979-1991 sont tous les deux librement disponibles sur le site de Stroustrup.

Je suis assez contrite du fait de n’avoir trouvé de source intéressante que de la part d’un seul auteur. Certes, personne n’est mieux placé que le créateur du langage pour parler de son histoire, mais j’aurais aimé avoir aussi le recul d’autres auteurs (si vous en connaissez, n’hésitez pas à me l’indiquer !).

Pourquoi le mot-clé class est totalement dispensable en C++20 ?

Aujourd’hui, en C++20, nous avons deux mot-clés qui fonctionnent presque exactement de la même façon : class et struct.

La seule différence entre les deux est que si vous ne spécifiez pas la visibilité (publicprivate ou protected) des membres, ils seront publics pour une struct et privé pour une class. La visibilité par défaut ne se limite pas aux membres, mais s’étend également à l’héritage (par défaut, les struct héritent en public et les class en privé).

Il y a trois raisons pour lesquelles cette petite différence ne mérite pas un mot-clé à part entière1 :

  • En pratique, les modifieurs d’accès par défaut ne sont presque jamais utilisés, d’expérience. La plupart des développeurs préfèrent spécifier les modifieurs d’accès à chaque fois.
  • En 2021, un bon est un code clair. Expliciter le modifieur d’accès est, dans cette optique, bien meilleur que laisser celui par défaut. Ce fait est débattable sur des petits projets, mais quand on commence à développer en grand nombre, il est toujours mieux d’écrire une poignée de caractères en plus pour être sûr que le code soit clair pour tout le monde.
  • Avoir deux mots-clés est plus ambigu qu’un seul. J’ai très souvent discuté avec des développeurs qui pensaient qu’il y avait plus de différences entre les classes et les structures, parfois en me faisant la liste de ces soi-disant différences. S’il n’y avait qu’un seul mot-clé, il n’y aurait pas ce genre de confusion.

D’autre part, j’ai déjà rencontré des gens qui avaient des arguments contraires. Parmis les plus courants :

  • C’est du sucre syntaxique2.
  • Ils utilisent effectivement les modifieurs d’accès implicites3.
  • Il existe une sémantique derrière chacun des mots-clés qui va au-delà des considérations techniques4.

En tout et pour tout, ce que j’essaye de dire est que le C++ serait en pratique identique si on n’avait pas le mot-clé class. Dans l’état d’esprit du C++20, on peut alors se demander « Quel est l’intérêt d’ajouter un mot-clé qui n’est ni nécessaire, ni utile ? ».

Je sais cependant une chose : class est un des plus vieux mots-clés spécifiques au C++. Plongeons-nous dans l’histoire du langage pour mieux comprendre son existence.

Histoire du mot-clé class

Naissance

La première apparition officielle du mot-clé class se trouve dans Classes: An Abstract Data Type Facility for the C language (Bjarne Stroustrup, 1980), qui ne parle pas vraiment de C++, mais du langage qu’on appelle C with Classes (le C avec des classes).

Qu’est-ce que le C with Classes ? Je ne m’étendrai pas en profondeur dessus ici (je compte y dédier un article complet). Il s’agit du prédécesseur direct du C++ et il a vu le jour en 1979. Le but originel de ce langage était d’ajouter de la modularité au langage C en s’inspirant des classes qu’on trouve en Simula5. Au début, ce n’était pas bien plus qu’un outil, nais il a rapidement évolué pour devenir un langage à part entière.

Comme le C with Classes a connu un succès mitigé et qu’il avait besoin d’un support à temps plein, Bjarne Stroustrup décida d’abandonner ce langage pour en créer un nouveau, visant à être populaire, en utilisant l’expérience qu’il avait acquise en créant le C with Classes. Il appela ce nouveau langage C++.

Le choix du mot-clé class vient directement du Simula et du fait que Stroustrup n’aime pas inventer de nouvelle termiologie.

Vous pouvez en apprendre plus sur le C with Classes dans le livre The Design and Evolution Of C++ (Bjarne Stroustrup), où une section entière lui est dédiée.

Donc le mot-clé class est en réalité né du prédécesseur du C++. En terme de design, il s’agit du plus vieux concept du langage et même de la raison de sa création.

Difference originelle entre struct et class

En C with Classesstruct et class étaient très différents.

Les structures fonctionnaient comme en C, structures de données simples, alors que c’est au sein des classes que sont nés les concepts de méthodes et d’attributs.

Donc à l’époque, la différence entre les deux était bien réelle, d’où l’utilité d’une telle distinction6.

Vers le C++

Les deux plus grandes features du début du C++ étaient les fonctions virtuelles et la surcharge de fonction.

En plus de ça, des règles pour les espaces de nom ont été introduits en C++. Parmi elles :

  • Les noms (au sein d’un périmètre) sont privés sauf s’ils sont explicitement déclarés publics.
  • Une classe est un périmètre (impliquant que les périmètres de classe s’emboîtent correctement).
  • Les structures C ne s’emboîtent pas (même si elles s’emboîtent lexicalement).

Ces règles font que les structures et les classes se comportent différemment en terme de périmètre et de nom.

Par exemple, ceci était légal à cette époque :

struct outer {
    struct inner {
        int i;
    };
};
 
struct inner a = { 1 };

Mais si vous changiez les struct en class, vous aviez une erreur de compilation (il eût fallu écrire outer::inner au lieu de struct inner).

« Fusion » avec le mot-clé struct

Il est assez difficile de donner une date précise pour le moment où les deux mots-clés ont « fusionné » pour devenir plus ou moins équivalents, parce qu’il n’y a aucune source qui le dit explicitement. Mais nous pouvons mener l’enquête.

D’après The C++ Programming Language – Reference Manual (Bjarne Stroustrup, 1984), la première version publiée du langage C++ :

Les classes contiennent une séquence d’objets de types variés, un ensemble de fonctions pour manipuler ces objets et un ensemble de restriction sur l’accès à ces objets et fonctions.
Les structures sont des classes sans restriction d’accès.
—Bjarne Stroustrup, The C++ Promgramming Language – Reference Manual, §4.4 Derived Types

De plus, si on regarde la retrospective de Stroustrup donne sur les fonctions virtuelles et le modèle d’agencement des objets (concepts introduits en 1986) :

À ce moment, le modèle d’objet devient réel dans le sens qu’un objet est bien plus qu’une simple agrégation de données […] Alors pourquoi n’ai-je pas, à ce moment, choisi de faire en sorte que les structures et les classes soient des notions différentes ?
Mon intention était d’avoir un concept unique : un unique ensemble de règles d’agencement, un unique ensemble de règles de correspondance, un unique ensemble de règles de résolution, etc. […]
—Bjarne Stroustrup, The Design and Evolution of C++, §3.5.1 The Object Layout Model

Même s’il semble que les structures ne pouvaient pas avoir de membres privés à l’époque (le mot-clé private n’existait pas encore !), on peut confortablement avancer que c’est à cette époque que les struct et les class ont été « fusionnés ».

Mais quand est-ce qu’ils sont réellement devenus identiques

Techniquement, les structures ne pouvaient toujours pas émuler des classes au moment de la création du C++. Il faut donc chercher l’invention du mot-clé private pour avoir cette correspondance.

Il semblerait que ce soit arrivé en même temps que la création du mot-clé protected, qui a été introduit en 1987, pour la version 1.2 du langage.

Depuis lors, jusqu’à maintenant

Malgré tout ce qu’on a pu voir à ce sujet, et le fait qu’aujourd’hui, class est techniquement inutile, il y a plus à cela que les considérations techniques.

Car le mot-clé class a acquis de la sémantique.

En effet, écrire le mot class sert à indiquer qu’on est en train d’implémenter une classe qui n’est pas juste un sac de données, tandis que le mot-clé est principalement réservé à cet usage. L’usage des mots-clés diffèrent de leur technicité. Au cours de leur histoire, ils ont chacun acquis un sens.

L’article de Jonathan Boccara sur le sujet est très pertinent : The real difference between struct and class – Fluent C++ (fluentcpp.com). Cet article s’inspire des Core Guidelines du C++.

Le fait que class a un vécu de plus de quarante ans le rend très différent que le class de 1980 et du class qu’on aurait hypothétiquement introduit dans le C++20 en partant de rien.

Mais la question qui me vient à l’esprit est la suivante : est-ce qu’on doit continuer d’utiliser class ainsi ? Est-ce qu’on devrait maintenir la sémantique acquise ou devrait-on chercher à faire évoluer son sens vers plus de modernité ?

La réponse est plutôt simple : ça dépend de chacun de nous. Nous, développeurs C++, sommes ceux qui faisons évoluer le langage, chaque jour, pour chaque ligne qu’on écrit.

Les Core Guidelines nous disent comment on devrait utiliser chaque fonctionnalité du C++ aujourd’hui, mais peut-être que demain, quelqu’un (vous ?) trouvera une meilleure manière de coder, plus claire et plus sécurisée. Comprendre ce que sont les structures et les classes dans le présent et ce qu’elles ont été dans le passé est le premier pas pour définir ce qu’elle seront demain.

En conclusion

La meilleure façon de résumer cet article est la suivante : « Les structures du C et les classes du Simula ont fusionné à la création du C++ », mais on peut aussi que, grâce à cela, malgré le fait qu’elles représentent la même fonctionnalité, elles ont un sens différent.

Cet article n’est pas un pamphlet contre class et je ne conclurai pas cet article par un argument mi-éclairé mi-autoritaire comme j’ai l’habitude de faire7. À la place, je vous dirai simplement que j’ai réalise à quel il était important de toujours contextualiser les articles comme celui-ci avec la version de C++ dans laquelle ils ont été pensés.

Je pense qu’il est important de comprendre l’histoire et d’être capable de juger les pratiques qu’on emploie aujourd’hui. Est-ce qu’on fait telle ou telle chose par habitude, où y a-t-il de réels avantages à cela ? On doit se poser cette question tous les jours, à défaut de quoi on finira fatalement par écrire du code obsolète, dans un mode de pensée obsolète.

La manière dont les développeurs C++ pensent8 évolue de décennie en décennie. À chaque ère, les développeurs ont un état d’esprit différent, des buts différents, des problématiques différentes, une éducation différentes, etc. Je ne reproche pas leur manière de coder au développeurs du passé, mais je blâmerai toujours les gens d’aujourd’hui pour ce qu’ils codent avec des us dépassés. Et dans le futur, j’espère que mes pairs sauront me pointer du doigt quand je m’abaisserai moi-même à écrire du « vieux C++ ».

Merci de votre attention et à la semaine prochaine !

Article original : [History of C++] Explanation on why the keyword `class` has no more reason to exist | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Pour aller un peu plus loin

Dans cette sous-section se trouvent deux petites idées qui sont un peu hors-sujet.

Les espaces de nom en C

Quelque chose qui n’a pas été hérité du C en C++ est l’espace de nom des structures.

En C, l’espace qui contient les noms de structure n’est pas le même que l’espace global. En C, struct foo et foo ne font pas référence au même objet. Ce n’est pas le cas en C++. Pour peu que foo soit une structure, struct foo et foo sont le même nom.

Il y a une manière, en C, de faire le lien entre les deux espaces de nom. Il suffit pour ça d’utiliser typedef. Pour plus d’information, suivez le lien suivant : How to use the typedef struct in C (educative.io).

Le mot-clé class dans les templates

Peut-être avez-vous déjà vu cette syntaxe :

template <class C_>
void foo(C_ arg)
{
    // ...
}

Que veut dire le mot-clé class dans ce contexte ?

Cela ne veut rien de plus que « un type, n’importe lequel ».

C’est un peu maladroit, puisque l’utilisation du mot-clé peut laisser penser que C_ doit être une classe, alors que ça peut très bien être un type primitif. Plus tard, le mot-clé typename a été introduit pour un peu plus de genéricité, mais le mot-cle class peut encore être utilisé

Annotations

1. Comme `struct` et `class` sont si similaire, je vais considérer arbitrairement que `class` est le mot-clé « en trop », tout simplement parce que `struct` existait déjà en C.

2. Cet argument est incorrect. Par définition, le sucre syntaxique est censé rendre le code plus simple à lire ou écrire. Comme struct/class n’est qu’une permutation de mot-clé, il n’y a pas de gain de clarté quel qu’il soit. La seule raison pour laquelle le code peut sembler plus simple à lire en les utilisant est lié à leur sémantique, pas à la syntaxe.

3. Oui, je sais, il existe des développeurs qui utilisent les modifieurs d’accès implicites. J’en fais moi-même partie. Mais ce qu’on a tendance à oublier avec ce genre d’argument c’est que la majorité de l’industrie du logiciel ne code pas comme nous (et cette phrase est vraie pour la plupart des développeurs). Les faits que je soutiens ici sont empiriques. Une pratique individuelle, aussi saine soit-elle, ne peut pas être un argument contre l’établissement de ce fait.

4. C’est techniquement vrai, mais le raisonnement est à l’envers. C’est parce que leur duplicité est historique qu’ils ont acquis de la sémantique, par l’inverse. Si ces deux mots-clés étaient créés aujourd’hui, à partir de rien, ils n’auraient pas spécialement de sémantique et sembleraient redondant pour la majorité des gens. Je traite ce sujet plus en profondeur vers la fin de l’article.

5. Le nom « Simula » désigne deux vieux langages de programmation, le Simula I et le Simula 67, développés dans les années soixante au *Norvegian Computing Center* à Oslo. Il est considéré comme le premier langage de programmation orienté-objet. Bien qu’il soit très méconnu au sein de la communauté de développeurs, il est l’influence de beaucoup de langages de programmation modernes, certains largement utilisés aujourd’hui, comme l’Objective Pascal, le Java, le C# et, bien entendu, le C++.

6. À cette époque, on pouvait émuler n’importe quelle structure avec une classe, mais il était quand même intéressant, en particulier avec l’état d’esprit de l’époque, de faire la distinction.

7. J’ai tendance à toujours être d’accord avec les [C++ Core Guidelines (isocpp.github.io)][9], même si j’essaye toujours de garder un esprit critique. Mais gardez en tête que les *guidelines* d’aujourd’hui ne sont pas forcément celles de demain.

8. Je pense que cette assertion est vraie pour tous les langages, mais est particulièrement flagrante pour le C++, puisqu’il s’agit d’un des plus vieux langages parmi les plus populaires sur le marché, aujourd’hui en 2021.

Sources

Par ordre d’apparition :

Une raison de plus pour ne pas utiliser printf (ou écrire du code C en général)

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cet article est né du tweet suivant, par Joe Groff @jckarter:

De manière assez évidente, le tweet est une blague, mais discutons un peu de ce qui se passe dans ce code.

Et donc, que se passe-t-il ?

Juste pour être 100% claire, l’expression double(2101253) ne calcule pas le double de la valeur 2101253, c’est une conversion (de style C) d’un entier vers un double.

En l’écrivant différemment, on obtient :

#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d\n", double(42));
}

En compilant sous x86_64 gcc 11.2, on a le résultat suivant :

666
4202506

On peut donc voir que la valeur 4202506 n’a rien à voir avec le 666 ou le 42.

D’ailleurs, si on lance le même code sous x86_64 clang 12.0.1, on obtient un résultat différent :

666
4202514

Vous pouvez voir les résultats exécutés ici : [https://godbolt.org/z/c6Me7a5ee].

Vous l’avez peut-être déjà deviné, mais cela vient de la ligne 5, où on affiche un double comme s’il s’agissait d’un int. Mais ce n’est pas à proprement parler une erreur de conversion (votre machine sait très bien convertir un flottant en entier, si ce n’était que ça il n’y aurait pas de soucis), mais d’un tout autre problème.

La vérité

Si on veut comprendre comment tout cela fonctionne, il faut se plonger dans le code assembleur correspondant au code (https://godbolt.org/z/5YKEdj73r) :

.LC0:
        .string "%d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

(allez-voir le Godbolt pour une correspondance plus visuelle : https://godbolt.org/z/5YKEdj73r).

En jaune dans le code assembleur (lignes 6 à 9, équivalent de printf("%d\n", 666);), on peut voir que tout va bien, la valeur 666 est positionnée dans le registre esi et ensuite la fonction printf est appelée. On peut donc confortablement supposer que quand la fonction ptintf lit un %d dans la chaîne qui lui est transmise, elle va afficher ce qu’elle a dans ce registre esi.

Or, quand on regarde le code en bleu (lignes 10 à 14, l’équivalent de printf("%d\n", double(42));), la valeur 42 est positionnée dans un autre registre, qui est xmm0 (du fait que c’est un double). Comme on passe à la fonction printf la même chaîne qu’avant, elle va regarder dans le même registre qu’avant (à savoir esi) et afficher quoique ce soit qui s’y trouve, d’où une valeur incohérente.

On peut prouver cela assez simplement :

\#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d %d\n", double(42), 24);
}

Il s’agit du même code qu’avant, sauf qu’on a ajouté l’affichage d’un autre entier dans le second printf.

Quand on regarde l’assembleur (https://godbolt.org/z/jjeca8qd7) :

.LC0:
        .string "%d %d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        mov     esi, 24
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

Le double(42) est toujours positionné dans le registre xmm0 et l’entier 24, en toute logique, tombe dans le registre esi. À cause de cela, on obtient la sortie suivante :

666
24 0

Pourquoi ? Et bien comme printf comprend qu’il faut afficher deux entiers, elle va regarder dans le registre esi (et donc afficher 24) puis dans le registre d’entier suivant, (edx) et afficher ce qui s’y trouve (0, de manière fortuite).

Au final, ce comportement survient à cause de la manière dont l’architecture x86_64 est faite. Si vous voulez vous documenter à ce propos, voici deux liens :

Que dit la doc à ce sujet ?

Le point chaud de tout cela est, selon la référence (printf, fprintf, sprintf, snprintf, printf_s, fprintf_s, sprintf_s, snprintf_s – cppreference.com), le prédicat suivant :

If a conversion specification is invalid, the behavior is undefined.

Si une spécification de conversion est invalide, alors le ocmportement est indéfini.

Cette même référence est équivoque quant à la spécification %d :

converts a signed integer into decimal representation [-]dddd.
Precision specifies the minimum number of digits to appear. The default precision is 1.
If both the converted value and the precision are ​0​ the conversion results in no characters.

Convertit un entier signé en sa représentation décimale [-]dddd.
[…]

De fait, transmettre un double à un printf alors que, d’après la chaîne de formattage, il s’attend à un entier est un comportement indéfini. Ce comportement est donc de notre propre faute.

D’ailleurs, ce code déclenche toujours un warning sous clang. Sous gcc, il faut activer -Wall pour le voir.

En résumé

Le langage C est un très, très vieux langage. Il est plus vieux que le C++ (évidemment) qui est lui-même très vieux. Pour rappel, le K&R a été publié en 1978. C’était treize ans avant ma propre naissance. Et (contrairement à nous autres développeur·se·s), les langages de programmation vieillissent mal.

J’aurais pu résumer cet article par un bon vieux « N’écrivez pas de comportements indéfinis » mais je pense que c’est un peu à côté de la plaque dans cette situation. Du coup je vais le dire franchement : n’utilisez pas printf du tout.

Le problème n’est pas avec printf lui-même, c’est d’utiliser une fonctionnalité qui est issue d’un autre langage1 dont la publication originale est vieille de quarante-trois ans. En un mot : n’écrivez pas du code C en C++.

Merci de votre attention et à la semaine prochaine !

(Merci tout particulièrement à Guillaume Delacourt, qui nous a partagé le tweet qui a servi de base à cet article)

1. Oui, que ça vous plaise ou non, le C et le C++ sont bien deux langages disctincts. Ils sont distincts dans leurs intentions, leurs pratiques et leur méta. C’est pourquoi je refuses systématiquement les offres d’emploi pour des postes de type « C/C++ », parce que je ne travaille pas pour des gens qui ne savent pas quel langage ils utilisent.

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

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

À propos des tailles

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

Si j’étais venue ce matin avec une interro surprise sur les tailles des types fondamentaux en C++, beaucoup d’entre nous l’aurait sans doute échoué (moi y compris). La raison à cela : les tailles en C++ sont compliquées.

La taille de chaque type fondamental n’est pas fixée, elle dépend toujours de l’architecture (elle est « implementation-defined« ).

Pourtant, le standard impose des contraintes à ces tailles. Ces contraintes peuvent prendre deux formes différentes :

  • Une comparaison des sizeof des types
  • Le nombre minimum de bits qui composent un type

Qu’est-ce que sizeof() ?

Une des idées reçues les plus largement propagées (même si inoffensive) est celle selon laquelle un octet est composé de 8 bits.

Même si la plupart du temps c’est vrai en pratique, c’est techniquement faux.

Un byte (anglais de octet1) est en réalité défini par la taille d’un char. Bien qu’un char fasse toujours au moins 8 bits, il peut être plus grand. Dans tous les cas, on part de la définition qu’un byte est la taille d’un char.

En C++, toutes les tailles sont des multiples de la taille d’un char. Ainsi, la fonction sizeof(N) renvoie le nombre de bytes qui composent le type N.

De ce fait, si sizeof(int) vaut quatre dans une architecture donnée, cela veut dire qu’il vaut la taille de 4 char, donc au moins 32 bits. Il peut cependant être plus grand. Si un char mesure 32 bits, alors un int fera, dans ce contexte, 128 bits.

La véritable taille d’un byte est enregistrée dans la constante CHAR_BIT.

1. À partir de là et jusqu’à la fin de l’article, j’utiliserai le terme anglais byte à la place de l’équivalent français octet, car ce dernier est, de fait, étymologiquement inexact.

Résumé des tailles en C++

Voici l’intégralité des contraintes de taille sur les types fondamentaux en C++ :

  • 1 ≡ sizeof(char) ≤ sizeof(short) ≤ sizeof(int) ≤ sizeof(long) ≤ sizeof(long long)
  • 1 ≤ sizeof(bool) ≤ sizeof(long)
  • sizeof(char) ≤ sizeof(wchar_t) ≤ sizeof(long)
  • sizeof(float) ≤ sizeof(double) ≤ sizeof(long double)
  • sizeof(N) ≡ sizeof(unsigned N) ≡ sizeof(signed N)
  • Un char fait au moins 8 bits
  • Un short fait au moins 16 bits
  • Un long fait au moins 32 bits

… et c’est tout.

Fait amusant : selon cette définition, il est techniquement valide d’avoir une architecture où tous les types fondamentaux font 32 bits.

Deux mots de sagesse

Puisque les tailles des types fondamentaux dépendent entièrement de l’architecture, il peut être parfois complexe d’écrire du code fiable.

#include <limits>

L’include limits de la librairie standard contient les bornes supérieures et inférieures de tous les types fondamentaux. Il vous permet en plus de savoir si un type est signé ou pas.

Exemple :

#include <limits>
#include <iostream>
 
int main()
{
    std::cout << "largest double == " << std::numeric_limits<double>::max() << std::endl;
    std::cout << "char is signed == " << std::numeric_limits<char>::is_signed << std::endl;
}

Plus d’informations ici : std::numeric_limits – cppreference.com.

Rappel : l’overflow d’entier signé est un comportement indéfini. Utiliser les limites vous permettra d’éviter cela.

#include <cstdint>

Parfois on veut utiliser directement des types de taille définie. Quand on écrit une classe de sérialisation, quand on travaille sur des systèmes à mémoire très limitée ou quand on veut que le code soit compatible cross-plateforme, on veut pouvoir utiliser des types qui ont une longueur (en terme de bits) prédéfinie.

C’est possible avec la librairie cstdint qui contient des types de taille fixe.

En voici quelques-uns:

int8_t
int16_t
int32_t
int64_t
Signed integer type with width of exactly 8, 16, 32 and 64 bits respectively
with no padding bits and using 2’s complement for negative values
(provided only if the implementation directly supports the type)
int_least8_t
int_least16_t
int_least32_t
int_least64_t
Smallest signed integer type with width of at least 8, 16, 32 and 64 bits respectively
intmax_tMaximum-width signed integer type
uint8_t
uint16_t
uint32_t
uint64_t
Unsigned integer type with width of exactly 8, 16, 32 and 64 bits respectively
(provided only if the implementation directly supports the type)
uint_least8_t
uint_least16_t
uint_least32_t
uint_least64_t
smallest unsigned integer type with width of at least 8, 16, 32 and 64 bits respectively
uintmax_tmaximum-width unsigned integer type

Plus d’informations ici : Fixed width integer types (since C++11) – cppreference.com.

En conclusion

Si vous voulez en lire plus à propos des tailles de types, je vous renvoie à la section §6.2.8 de l’ouvrage The C++ Langage (de Bjarne Stroustrup). Plus largement, vous pouvez vous documenter à propos des types et déclaration dans toute la section §6 du livre.

Vous pouvez aussi aller voir Fundamental types – cppreference.com si vous préférez la documentation en ligne.

Merci de votre attention et à la semaine prochaine !

Article original : About sizes | 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