[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().

Laisser un commentaire