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 :