Pourquoi le «polymorphisme pur» est-il préférable à l'utilisation du RTTI?

106

Presque toutes les ressources C ++ que j'ai vues qui traitent de ce genre de chose me disent que je devrais préférer les approches polymorphes à l'utilisation de RTTI (identification de type à l'exécution). En général, je prends ce genre de conseil au sérieux et j'essaierai de comprendre la raison d'être - après tout, C ++ est une bête puissante et difficile à comprendre dans toute sa profondeur. Cependant, pour cette question particulière, je dessine un blanc et j'aimerais voir quel genre de conseils Internet peut offrir. Tout d'abord, permettez-moi de résumer ce que j'ai appris jusqu'à présent, en énumérant les raisons courantes citées pour lesquelles le RTTI est "considéré comme dangereux":

Certains compilateurs ne l'utilisent pas / RTTI n'est pas toujours activé

Je n'achète vraiment pas cet argument. C'est comme dire que je ne devrais pas utiliser les fonctionnalités C ++ 14, car il existe des compilateurs qui ne le prennent pas en charge. Et pourtant, personne ne me découragerait d'utiliser les fonctionnalités de C ++ 14. La majorité des projets auront une influence sur le compilateur qu'ils utilisent et sur la façon dont il est configuré. Même en citant la page de manuel gcc:

-fno-rtti

Désactivez la génération d'informations sur chaque classe avec des fonctions virtuelles à utiliser par les fonctionnalités d'identification de type d'exécution C ++ (dynamic_cast et typeid). Si vous n'utilisez pas ces parties de la langue, vous pouvez économiser de l'espace en utilisant cet indicateur. Notez que la gestion des exceptions utilise les mêmes informations, mais G ++ les génère selon les besoins. L'opérateur dynamic_cast peut toujours être utilisé pour les transtypages qui ne nécessitent pas d'informations de type à l'exécution, c'est-à-dire les transtypages en "void *" ou en classes de base non ambiguës.

Ce que cela me dit, c'est que si je n'utilise pas RTTI, je peux le désactiver. C'est comme dire, si vous n'utilisez pas Boost, vous n'êtes pas obligé de créer un lien vers celui-ci. Je n'ai pas à planifier le cas où quelqu'un compile avec -fno-rtti. De plus, le compilateur échouera haut et fort dans ce cas.

Cela coûte de la mémoire supplémentaire / peut être lent

Chaque fois que je suis tenté d'utiliser RTTI, cela signifie que je dois accéder à une sorte d'information de type ou de trait de ma classe. Si j'implémente une solution qui n'utilise pas RTTI, cela signifie généralement que je devrai ajouter des champs à mes classes pour stocker ces informations, donc l'argument de mémoire est un peu vide (je vais en donner un exemple plus bas).

Un dynamic_cast peut être lent, en effet. Il existe généralement des moyens d'éviter de l'utiliser dans des situations critiques en termes de vitesse. Et je ne vois pas tout à fait l'alternative. Cette réponse SO suggère d'utiliser une énumération, définie dans la classe de base, pour stocker le type. Cela ne fonctionne que si vous connaissez toutes vos classes dérivées a-priori. C'est assez gros "si"!

De cette réponse, il semble également que le coût du RTTI n'est pas clair non plus. Différentes personnes mesurent des choses différentes.

Des conceptions polymorphes élégantes rendront le RTTI inutile

C'est le genre de conseil que je prends au sérieux. Dans ce cas, je ne peux tout simplement pas trouver de bonnes solutions non-RTTI qui couvrent mon cas d'utilisation RTTI. Laissez-moi vous donner un exemple:

Disons que j'écris une bibliothèque pour gérer les graphiques de certains types d'objets. Je souhaite permettre aux utilisateurs de générer leurs propres types lors de l'utilisation de ma bibliothèque (la méthode enum n'est donc pas disponible). J'ai une classe de base pour mon nœud:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();
};

Maintenant, mes nœuds peuvent être de différents types. Que penses-tu de ceux-ci:

class red_node : virtual public node_base
{
  public:
    red_node();
    virtual ~red_node();

    void get_redness();
};

class yellow_node : virtual public node_base
{
  public:
    yellow_node();
    virtual ~yellow_node();

    void set_yellowness(int);
};

Bon sang, pourquoi pas même l'un de ceux-ci:

class orange_node : public red_node, public yellow_node
{
  public:
    orange_node();
    virtual ~orange_node();

    void poke();
    void poke_adjacent_oranges();
};

La dernière fonction est intéressante. Voici une façon de l'écrire:

void orange_node::poke_adjacent_oranges()
{
    auto adj_nodes = get_adjacent_nodes();
    foreach(auto node, adj_nodes) {
        // In this case, typeid() and static_cast might be faster
        std::shared_ptr<orange_node> o_node = dynamic_cast<orange_node>(node);
        if (o_node) {
             o_node->poke();
        }
    }
}

Tout cela semble clair et net. Je n'ai pas à définir des attributs ou des méthodes là où je n'en ai pas besoin, la classe de nœud de base peut rester légère et moyenne. Sans RTTI, par où commencer? Peut-être que je peux ajouter un attribut node_type à la classe de base:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();

  private:
    std::string my_type;
};

Est-ce que std :: string est une bonne idée pour un type? Peut-être pas, mais que puis-je utiliser d'autre? Inventez un numéro et espérez que personne d'autre ne l'utilise encore? Aussi, dans le cas de mon orange_node, que faire si je veux utiliser les méthodes de red_node et yellow_node? Dois-je stocker plusieurs types par nœud? Cela semble compliqué.

Conclusion

Ces exemples ne semblent pas trop complexes ou inhabituels (je travaille sur quelque chose de similaire dans mon travail quotidien, où les nœuds représentent du matériel réel qui est contrôlé par le logiciel, et qui font des choses très différentes en fonction de ce qu'ils sont). Pourtant, je ne saurais pas comment faire cela avec des modèles ou d'autres méthodes. Veuillez noter que j'essaie de comprendre le problème, pas de défendre mon exemple. Ma lecture de pages telles que la réponse SO que j'ai liée ci-dessus et cette page sur Wikibooks semblent suggérer que j'utilise mal RTTI, mais j'aimerais savoir pourquoi.

Donc, revenons à ma question initiale: pourquoi le «polymorphisme pur» est-il préférable à l'utilisation de RTTI?

mbr0wn
la source
9
Ce qui vous "manque" (en tant que fonctionnalité de langage) pour résoudre votre exemple de poke oranges serait l'envoi multiple ("multimethods"). Ainsi, la recherche de moyens d'imiter cela pourrait être une alternative. Habituellement, le modèle de visiteur est donc utilisé.
Daniel Jour
1
Utiliser une chaîne comme type n'est pas très utile. Utiliser un pointeur vers une instance d'une classe "type" rendrait cela plus rapide. Mais alors vous faites essentiellement manuellement ce que ferait RTTI.
Daniel Jour
4
@MargaretBloom Non, ce ne sera pas le cas, RTTI signifie Runtime Type Information tandis que CRTP ne concerne que les modèles - les types statiques, donc.
edmz
2
@ mbr0wn: tous les processus d'ingénierie sont liés par certaines règles; la programmation n'est pas une exception. Les règles peuvent être divisées en deux groupes: les règles souples (DEVRAIENT) et les règles strictes (DOIVENT). (Il y a aussi un bucket de conseils / options (POURRAIT), pour ainsi dire.) Lisez comment le standard C / C ++ (ou tout autre standard d'ingénierie, pour le fait) les définit. Je suppose que votre problème vient du fait que vous avez tort « ne pas utiliser RTTI » comme disque règle ( « vous utilisez RTTI ne dois pas »). C'est en fait une règle souple ("vous NE DEVEZ PAS utiliser RTTI"), ce qui signifie que vous devez l' éviter autant que possible - et l'utiliser uniquement lorsque vous ne pouvez pas éviter de le faire
3
Je note que beaucoup de réponses ne notent pas l'idée que votre exemple suggère de faire node_basepartie d'une bibliothèque et que les utilisateurs créeront leurs propres types de nœuds. Ensuite, ils ne peuvent pas modifier node_basepour autoriser une autre solution, alors peut-être que RTTI devient alors leur meilleure option. D'un autre côté, il existe d'autres façons de concevoir une telle bibliothèque afin que les nouveaux types de nœuds puissent s'intégrer beaucoup plus élégamment sans avoir besoin d'utiliser RTTI (et d'autres façons de concevoir les nouveaux types de nœuds, aussi).
Matthew Walton

Réponses:

69

Une interface décrit ce qu'il faut savoir pour interagir dans une situation donnée dans le code. Une fois que vous étendez l'interface avec "toute votre hiérarchie de types", votre "surface" d'interface devient énorme, ce qui rend le raisonnement plus difficile .

Par exemple, votre "piquer des oranges adjacentes" signifie que moi, en tant que tiers, je ne peux pas émuler le fait d'être une orange! Vous avez déclaré en privé un type orange, puis utilisez RTTI pour que votre code se comporte de manière spéciale lors de l'interaction avec ce type. Si je veux "être orange", je dois être dans votre jardin privé.

Désormais, tous ceux qui se marient avec "orangeness" se marient avec tout votre type orange, et implicitement avec tout votre jardin privé, plutôt qu'avec une interface définie.

Alors qu'à première vue, cela semble être un excellent moyen d'étendre l'interface limitée sans avoir à changer tous les clients (ajout am_I_orange), ce qui a tendance à se produire à la place, c'est que cela ossifie la base de code et empêche toute extension supplémentaire. L'orangé particulier devient inhérent au fonctionnement du système, et vous empêche de créer un remplaçant "mandarine" pour l'orange qui est implémenté différemment et qui supprime peut-être une dépendance ou résout un autre problème avec élégance.

Cela signifie que votre interface doit être suffisante pour résoudre votre problème. De ce point de vue, pourquoi avez-vous besoin de piquer uniquement des oranges, et si oui, pourquoi l'orange n'était-elle pas disponible dans l'interface? Si vous avez besoin d'un ensemble de balises floues pouvant être ajoutées ad hoc, vous pouvez l'ajouter à votre type:

class node_base {
  public:
    bool has_tag(tag_name);

Cela fournit un élargissement massif similaire de votre interface, d'une spécification étroite à une large base de balises. Sauf au lieu de le faire via RTTI et les détails d'implémentation (aka, "comment êtes-vous implémenté? Avec le type orange? Ok vous passez."), Il le fait avec quelque chose de facilement émulé à travers une implémentation complètement différente.

Cela peut même être étendu aux méthodes dynamiques, si vous en avez besoin. "Est-ce que vous êtes d'accord pour être Foo'd avec des arguments Baz, Tom et Alice? Ok, Fooing you" Dans un sens large, c'est moins intrusif qu'une distribution dynamique pour comprendre que l'autre objet est un type que vous connaissez.

Désormais, les objets mandarine peuvent avoir l'étiquette orange et jouer le jeu, tout en étant découplés de l'implémentation.

Cela peut toujours conduire à un énorme désordre, mais c'est au moins un désordre de messages et de données, pas de hiérarchies d'implémentation.

L'abstraction est un jeu de découplage et de masquage des non-pertinence. Cela rend le code plus facile à raisonner localement. RTTI perce un trou directement dans l'abstraction dans les détails de mise en œuvre. Cela peut faciliter la résolution d'un problème, mais cela a le coût de vous enfermer dans une implémentation spécifique très facilement.

Yakk - Adam Nevraumont
la source
14
+1 pour le tout dernier paragraphe; non seulement parce que je suis d'accord avec vous, mais parce que c'est le marteau-sur-le-clou ici.
7
Comment obtenir une fonctionnalité particulière une fois que l'on sait qu'un objet est étiqueté comme prenant en charge cette fonctionnalité? Soit cela implique un casting, soit il y a une classe Dieu avec toutes les fonctions membres possibles. La première possibilité est soit un casting non vérifié, auquel cas le balisage est juste son propre schéma de vérification de type dynamique très faillible, soit il est vérifié dynamic_cast(RTTI), auquel cas les balises sont redondantes. La deuxième possibilité, une classe de Dieu, est odieuse. En résumé, cette réponse contient de nombreux mots qui, à mon avis, semblent agréables aux programmeurs Java, mais le contenu réel n'a pas de sens.
Acclamations et hth. - Alf
2
@Falco: C'est (une variante de) la première possibilité que j'ai mentionnée, un casting non vérifié basé sur le tag. Ici, le marquage est son propre système de vérification de type dynamique très fragile et très faillible. Tout petit mauvais comportement du code client, et en C ++, un est désactivé dans UB-land. Vous n'obtenez pas d'exceptions, comme vous pourriez l'obtenir en Java, mais un comportement indéfini, comme des plantages et / ou des résultats incorrects. En plus d'être extrêmement peu fiable et dangereux, il est également extrêmement inefficace, comparé à un code C ++ plus sain. IOW., C'est très très mauvais; extrêmement.
Acclamations et hth. - Alf
1
Uhm. :) Types d'arguments?
Acclamations et hth. - Alf
2
@JojOatXGME: Parce que "polymorphisme" signifie pouvoir travailler avec une variété de types. Si vous devez vérifier s'il s'agit d'un type particulier , au-delà de la vérification de type déjà existante que vous avez utilisée pour obtenir le pointeur / référence au départ, vous regardez derrière le polymorphisme. Vous ne travaillez pas avec une variété de types; vous travaillez avec un type spécifique . Oui, il existe des "(gros) projets en Java" qui font cela. Mais c'est Java ; le langage n'autorise que le polymorphisme dynamique. C ++ a également un polymorphisme statique. De plus, ce n'est pas une bonne idée que quelqu'un de "grand" le fasse.
Nicol Bolas
31

La plupart des pressions morales contre telle ou telle caractéristique sont la typicité provenant de l'observation qu'il y a une multitude d' utilisations erronées de cette caractéristique.

Là où les moralistes échouent, c'est qu'ils présument que TOUS les usages sont mal conçus, alors qu'en fait les caractéristiques existent pour une raison.

Ils ont ce que je l' habitude d'appeler le « plombier complexe »: ils pensent que tous les robinets sont fonctionne mal parce que tous les robinets , ils sont appelés à réparer sont. La réalité est que la plupart des robinets fonctionnent bien: vous n'appelez tout simplement pas un plombier pour eux!

Une chose folle qui peut arriver, c'est quand, pour éviter d'utiliser une fonctionnalité donnée, les programmeurs écrivent beaucoup de code standard en réimplémentant en privé exactement cette fonctionnalité. (Avez-vous déjà rencontré des classes qui n'utilisent ni RTTI ni appels virtuels, mais qui ont une valeur pour suivre de quel type dérivé réel s'agit-il? Ce n'est rien de plus que la réinvention de RTTI déguisée.)

Il y a une façon générale de penser polymorphisme: IF(selection) CALL(something) WITH(parameters). (Désolé, mais la programmation, en ne tenant pas compte de l'abstraction, c'est tout ça)

L'utilisation du polymorphisme au moment de la conception (concepts) à la compilation (basé sur la déduction de modèle), à ​​l'exécution (héritage et fonction virtuelle) ou basé sur les données (RTTI et commutation) dépend de la quantité de décisions connues. à chacune des étapes de la production et à quel point elles sont variables à chaque contexte.

L'idée est que:

plus vous pouvez anticiper, meilleures sont les chances de détecter les erreurs et d'éviter les bogues affectant l'utilisateur final.

Si tout est constant (y compris les données), vous pouvez tout faire avec la méta-programmation modèle. Une fois la compilation effectuée sur des constantes actualisées, le programme entier se résume à une simple instruction return qui crache le résultat .

S'il y a un certain nombre de cas qui sont tous connus au moment de la compilation , mais que vous ne connaissez pas les données réelles sur lesquelles ils doivent agir, alors le polymorphisme à la compilation (principalement CRTP ou similaire) peut être une solution.

Si la sélection des cas dépend des données (pas des valeurs connues au moment de la compilation) et que la commutation est mono-dimensionnelle (ce qu'il faut faire peut être réduit à une seule valeur), alors répartition basée sur la fonction virtuelle (ou en général "tables de pointeurs de fonction ") est nécessaire.

Si la commutation est multidimensionnelle, étant donné qu'aucune répartition native de plusieurs runtime n'existe en C ++, vous devez soit:

  • Réduire à une dimension par Goedelization : c'est là que se trouvent les bases virtuelles et l'héritage multiple, avec des losanges et des parallélogrammes empilés , mais cela nécessite que le nombre de combinaisons possibles soit connu et relativement petit.
  • Enchaînez les dimensions les unes dans les autres (comme dans le modèle de visiteurs composites, mais cela nécessite que toutes les classes soient conscientes de leurs autres frères et sœurs, il ne peut donc pas «évoluer» depuis l'endroit où il a été conçu)
  • Distribuez les appels en fonction de plusieurs valeurs. C'est exactement à cela que sert RTTI.

Si ce n'est pas seulement la commutation, mais même les actions ne sont pas connues au moment de la compilation, alors le script et l'analyse sont nécessaires: les données elles-mêmes doivent décrire l'action à entreprendre.

Maintenant, puisque chacun des cas que j'ai énumérés peut être vu comme un cas particulier de ce qui le suit, vous pouvez résoudre tous les problèmes en abusant de la solution la plus basse également pour les problèmes abordables avec les plus hauts.

C'est ce que la moralisation pousse en fait à éviter. Mais cela ne signifie pas que les problèmes qui vivent dans les domaines les plus bas n'existent pas!

Bashing RTTI juste pour le dénigrer, c'est comme dénigrer gotojuste pour le dénigrer. Des choses pour les perroquets, pas pour les programmeurs.

Emilio Garavaglia
la source
Un bon compte rendu des niveaux auxquels chaque approche est applicable. Cependant, je n'ai pas entendu parler de "Goedelization" - est-il également connu sous un autre nom? Pourriez-vous peut-être ajouter un lien ou plus d'explications? Merci :)
j_random_hacker
1
@j_random_hacker: Je suis moi aussi curieux de connaître cette utilisation de Godelization. On pense normalement à la Godelisation comme étant la première, mappant d'une chaîne à un entier, et deuxièmement, en utilisant cette technique pour produire des déclarations auto-référentielles dans des langages formels. Je ne connais pas ce terme dans le contexte de l'envoi virtuel et j'aimerais en savoir plus.
Eric Lippert
1
En fait , je suis abuse du terme: selon Goedle, puisque chaque correspondent entier à un entier n-ple (les pouvoirs des principaux facteurs de it) et tous correspondent n-ple à un entier, chaque problème d'indexation de dimension n discrète peut être réduit à un mono-dimensionnel . Cela ne veut pas dire que c'est la seule et unique façon de le faire: c'est juste une façon de dire "c'est possible". Tout ce dont vous avez besoin est un mécanisme «diviser pour vaincre». les fonctions virtuelles sont la "division" et l'héritage multiple est la "conquête".
Emilio Garavaglia
... Quand tout ce qui se passe à l'intérieur d'un corps fini (une plage), les combinaisons linéaires sont plus efficaces (le classique i = r * C + c obtient l'indice dans un tableau de la cellule d'une matrice). Dans ce cas, la division identifie le "visiteur" et le vainqueur est le "composite". Puisque l'algèbre linéaire est impliquée, la technique dans ce cas correspond à la "diagonalisation"
Emilio Garavaglia
Ne pensez pas à tout cela comme des techniques. Ce ne sont que des analogies
Emilio Garavaglia
23

Cela a l'air plutôt soigné dans un petit exemple, mais dans la vraie vie, vous vous retrouverez bientôt avec un long ensemble de types qui peuvent se pousser les uns les autres, certains d'entre eux peut-être dans une seule direction.

Qu'en est-il dark_orange_node, ou black_and_orange_striped_node, ou dotted_node? Peut-il avoir des points de couleurs différentes? Et si la plupart des points sont orange, peut-on alors les piquer?

Et chaque fois que vous devez ajouter une nouvelle règle, vous devrez revoir toutes les poke_adjacentfonctions et ajouter d'autres instructions if.


Comme toujours, il est difficile de créer des exemples génériques, je vais vous donner cela.

Mais si je devais faire cet exemple spécifique , j'ajouterais un poke()membre à toutes les classes et laisserais certains d'entre eux ignorer l'appel ( void poke() {}) s'ils ne sont pas intéressés.

Ce serait sûrement encore moins cher que de comparer le par typeid.

Bo Persson
la source
3
Vous dites «sûrement», mais qu'est-ce qui vous rend si sûr? C'est vraiment ce que j'essaie de comprendre. Disons que je renomme orange_node en pokable_node, et ce sont les seuls sur lesquels je peux appeler poke (). Cela signifie que mon interface devra implémenter une méthode poke () qui, disons, lève une exception ("ce nœud n'est pas pokable"). Cela semble plus cher.
mbr0wn
2
Pourquoi aurait-il besoin de lever une exception? Si vous vous souciez de savoir si l'interface est "poke-able" ou non, ajoutez simplement une fonction "isPokeable" et appelez-la avant d'appeler la fonction poke. Ou faites simplement ce qu'il dit et «ne faites rien, dans des classes non pokeable».
Brandon
1
@ mbr0wn: La meilleure question est de savoir pourquoi vous voulez que les nœuds pokables et nonpokables partagent la même classe de base.
Nicol Bolas
2
@NicolBolas Pourquoi voudriez-vous que des monstres amicaux et hostiles partagent la même classe de base, ou des éléments d'interface utilisateur focalisables et non focalisables, ou des claviers avec un pavé numérique et des claviers sans pavé numérique?
user253751
1
@ mbr0wn Cela ressemble à un modèle de comportement. L'interface de base a deux méthodes supportsBehaviouret invokeBehaviouret chaque classe peut avoir une liste de comportements. Un comportement serait Poke et pourrait être ajouté à la liste des Behaviors pris en charge par toutes les classes qui veulent être pokeable.
Falco
20

Certains compilateurs ne l'utilisent pas / RTTI n'est pas toujours activé

Je pense que vous avez mal compris ces arguments.

Il existe un certain nombre d'endroits de codage C ++ où RTTI ne doit pas être utilisé. Où les commutateurs du compilateur sont utilisés pour désactiver de force RTTI. Si vous codez dans un tel paradigme ... alors vous avez presque certainement déjà été informé de cette restriction.

Le problème vient donc des bibliothèques . Autrement dit, si vous écrivez une bibliothèque qui dépend de RTTI, votre bibliothèque ne peut pas être utilisée par les utilisateurs qui désactivent RTTI. Si vous voulez que votre bibliothèque soit utilisée par ces personnes, alors elle ne peut pas utiliser RTTI, même si votre bibliothèque est également utilisée par des personnes qui peuvent utiliser RTTI. Tout aussi important, si vous ne pouvez pas utiliser RTTI, vous devez magasiner un peu plus pour les bibliothèques, car l'utilisation de RTTI est un facteur décisif pour vous.

Cela coûte de la mémoire supplémentaire / peut être lent

Il y a beaucoup de choses que vous ne faites pas dans les boucles chaudes. Vous n'allouez pas de mémoire. Vous ne parcourez pas de listes liées. Et ainsi de suite. RTTI peut certainement être une autre de ces choses "ne faites pas ceci ici".

Cependant, considérez tous vos exemples RTTI. Dans tous les cas, vous avez un ou plusieurs objets de type indéterminé et vous souhaitez effectuer sur eux une opération qui peut ne pas être possible pour certains d'entre eux.

C'est quelque chose que vous devez contourner au niveau de la conception . Vous pouvez écrire des conteneurs qui n'allouent pas de mémoire et qui s'inscrivent dans le paradigme "STL". Vous pouvez éviter les structures de données de listes liées ou limiter leur utilisation. Vous pouvez réorganiser des tableaux de structures en structures de tableaux ou autre. Cela change certaines choses, mais vous pouvez le garder compartimenté.

Changer une opération RTTI complexe en un appel de fonction virtuelle normal? C'est un problème de conception. Si vous devez changer cela, alors c'est quelque chose qui nécessite des changements dans chaque classe dérivée. Cela change la façon dont beaucoup de code interagit avec différentes classes. La portée d'un tel changement s'étend bien au-delà des sections de code critiques pour les performances.

Alors ... pourquoi l'avez-vous écrit de la mauvaise façon au départ?

Je n'ai pas à définir des attributs ou des méthodes là où je n'en ai pas besoin, la classe de nœud de base peut rester légère et moyenne.

À quelle fin?

Vous dites que la classe de base est «maigre et méchant». Mais vraiment ... c'est inexistant . Cela ne fait rien .

Il suffit de regarder votre exemple: node_base. Qu'Est-ce que c'est? Cela semble être une chose qui a d'autres choses adjacentes. Il s'agit d'une interface Java (Java pré-générique en plus): une classe qui existe uniquement pour être quelque chose que les utilisateurs peuvent convertir en type réel . Peut-être que vous ajoutez une fonctionnalité de base comme la contiguïté (ajout de Java ToString), mais c'est tout.

Il y a une différence entre «maigre et moyen» et «transparent».

Comme l'a dit Yakk, ces styles de programmation se limitent à l'interopérabilité, car si toutes les fonctionnalités sont dans une classe dérivée, alors les utilisateurs en dehors de ce système, sans accès à cette classe dérivée, ne peuvent pas interagir avec le système. Ils ne peuvent pas remplacer les fonctions virtuelles et ajouter de nouveaux comportements. Ils ne peuvent même pas appeler ces fonctions.

Mais ce qu'ils font aussi, c'est rendre difficile de faire de nouvelles choses, même au sein du système. Considérez votre poke_adjacent_orangesfonction. Que se passe-t-il si quelqu'un veut un lime_nodetype qui peut être poussé comme un orange_nodes? Eh bien, nous ne pouvons pas dériver lime_nodede orange_node; ça n'a aucun sens.

Au lieu de cela, nous devons ajouter un nouveau lime_nodedérivé de node_base. Puis changez le nom de poke_adjacent_orangesen poke_adjacent_pokables. Et puis, essayez de lancer vers orange_nodeet lime_node; quelle que soit l'œuvre du casting, celle que nous piquons.

Cependant, lime_nodeil a besoin de son propre poke_adjacent_pokables . Et cette fonction doit faire les mêmes vérifications de casting.

Et si nous ajoutons un troisième type, nous devons non seulement ajouter sa propre fonction, mais nous devons changer les fonctions des deux autres classes.

De toute évidence, maintenant vous créez poke_adjacent_pokablesune fonction gratuite, de sorte que cela fonctionne pour tous. Mais que supposez-vous qu'il se passe si quelqu'un ajoute un quatrième type et oublie de l'ajouter à cette fonction?

Bonjour, casse silencieuse . Le programme semble fonctionner plus ou moins bien, mais ce n'est pas le cas. Avait pokeété une fonction virtuelle réelle , le compilateur aurait échoué si vous n'aviez pas remplacé la fonction virtuelle pure de node_base.

Avec votre chemin, vous n'avez pas de telles vérifications du compilateur. Bien sûr, le compilateur ne vérifiera pas les virtuels non purs, mais au moins vous avez une protection dans les cas où la protection est possible (c'est-à-dire: il n'y a pas d'opération par défaut).

L'utilisation de classes de base transparentes avec RTTI conduit à un cauchemar de maintenance. En effet, la plupart des utilisations du RTTI entraînent des maux de tête de maintenance. Cela ne veut pas dire que RTTI n'est pas utile (c'est vital pour faire du boost::anytravail, par exemple). Mais c'est un outil très spécialisé pour des besoins très spécialisés.

De cette façon, il est «nuisible» de la même manière que goto. C'est un outil utile qui ne devrait pas être supprimé. Mais son utilisation devrait être rare dans votre code.


Donc, si vous ne pouvez pas utiliser de classes de base transparentes et de casting dynamique, comment éviter les interfaces lourdes? Comment éviter que toutes les fonctions que vous souhaitez appeler sur un type ne bouillonnent jusqu'à la classe de base?

La réponse dépend de ce à quoi sert la classe de base.

Les classes de base transparentes comme node_basen'utilisent que le mauvais outil pour le problème. Les listes liées sont mieux gérées par des modèles. Le type de nœud et la contiguïté seraient fournis par un type de modèle. Si vous souhaitez mettre un type polymorphe dans la liste, vous pouvez. Utilisez simplement BaseClass*comme Tdans l'argument modèle. Ou votre pointeur intelligent préféré.

Mais il existe d'autres scénarios. L'un est un type qui fait beaucoup de choses, mais qui comporte des parties optionnelles. Une instance particulière pourrait implémenter certaines fonctions, tandis qu'une autre ne le ferait pas. Cependant, la conception de ces types offre généralement une réponse appropriée.

La classe «entité» en est un parfait exemple. Cette classe est depuis longtemps en proie aux développeurs de jeux. Conceptuellement, il a une interface gigantesque, vivant à l'intersection de près d'une douzaine de systèmes entièrement disparates. Et différentes entités ont des propriétés différentes. Certaines entités n'ont aucune représentation visuelle, donc leurs fonctions de rendu ne font rien. Et tout cela est déterminé au moment de l'exécution.

La solution moderne pour cela est un système de type composant. Entityest simplement un conteneur d'un ensemble de composants, avec un peu de colle entre eux. Certains composants sont facultatifs; une entité qui n'a pas de représentation visuelle n'a pas le composant "graphique". Une entité sans IA n'a pas de composant «contrôleur». Et ainsi de suite.

Les entités d'un tel système ne sont que des pointeurs vers des composants, la plupart de leur interface étant fournie en accédant directement aux composants.

Le développement d'un tel système de composants nécessite de reconnaître, au stade de la conception, que certaines fonctions sont conceptuellement regroupées, de sorte que tous les types qui en implémentent une les implémentent toutes. Cela vous permet d'extraire la classe de la classe de base prospective et d'en faire un composant distinct.

Cela permet également de suivre le principe de responsabilité unique. Une telle classe de composants n'a que la responsabilité d'être détenteur de composants.


De Matthew Walton:

Je note que beaucoup de réponses ne notent pas l'idée que votre exemple suggère que node_base fait partie d'une bibliothèque et que les utilisateurs créeront leurs propres types de nœuds. Ensuite, ils ne peuvent pas modifier node_base pour autoriser une autre solution, alors peut-être que RTTI devient alors leur meilleure option.

OK, explorons cela.

Pour que cela ait un sens, il vous faudrait une situation dans laquelle une bibliothèque L fournit un conteneur ou un autre support structuré de données. L'utilisateur peut ajouter des données à ce conteneur, parcourir son contenu, etc. Cependant, la bibliothèque ne fait vraiment rien avec ces données; il gère simplement son existence.

Mais il ne gère même pas tant son existence que sa destruction . La raison en est que, si l'on s'attend à ce que vous utilisiez RTTI à de telles fins, vous créez des classes que L ignore. Cela signifie que votre code alloue l'objet et le confie à L pour la gestion.

Maintenant, il y a des cas où quelque chose comme ça est une conception légitime. Signalisation d'événement / passage de message, files d'attente de travail thread-safe, etc. .

En C, ce modèle est orthographié void*et son utilisation nécessite beaucoup de soin pour éviter d'être cassé. En C ++, ce modèle est épelé std::experimental::any(bientôt épelé std::any).

La façon dont cela devrait fonctionner est que L fournit une node_baseclasse qui prend un anyqui représente vos données réelles. Lorsque vous recevez le message, l'élément de travail de la file d'attente de threads ou quoi que vous fassiez, vous le convertissez ensuite en anyson type approprié, que l'expéditeur et le destinataire connaissent.

Ainsi , au lieu de tirer orange_nodede node_data, vous vous en tenez simplement un orangeintérieur node_datade » anychamp membre. L'utilisateur final l'extrait et l'utilise any_castpour le convertir en orange. Si le casting échoue, ce n'était pas le cas orange.

Maintenant, si vous êtes familier avec l'implémentation de any, vous direz probablement, "hé, attendez une minute: utilise RTTI en any interne pour faire any_castfonctionner." A quoi je réponds, "... oui".

C'est le but d'une abstraction . Au fond des détails, quelqu'un utilise RTTI. Mais au niveau auquel vous devriez opérer, le RTTI direct n'est pas quelque chose que vous devriez faire.

Vous devez utiliser des types qui vous fournissent les fonctionnalités souhaitées. Après tout, vous ne voulez pas vraiment de RTTI. Ce que vous voulez, c'est une structure de données qui peut stocker une valeur d'un type donné, la masquer à tout le monde sauf la destination souhaitée, puis être reconvertie dans ce type, avec vérification que la valeur stockée est réellement de ce type.

Cela s'appelle any. Il utilise RTTI, mais l'utilisation anyest bien supérieure à l'utilisation directe de RTTI, car elle correspond plus correctement à la sémantique souhaitée.

Nicol Bolas
la source
10

Si vous appelez une fonction, en règle générale, vous ne vous souciez pas vraiment des étapes précises qu'elle prendra, mais seulement qu'un objectif de plus haut niveau sera atteint dans certaines contraintes (et comment la fonction le rend possible est vraiment son propre problème).

Lorsque vous utilisez RTTI pour faire une présélection d'objets spéciaux qui peuvent faire un certain travail, alors que d'autres dans le même ensemble ne le peuvent pas, vous rompez cette vision confortable du monde. Tout à coup, l'appelant est censé savoir qui peut faire quoi, au lieu de simplement dire à ses serviteurs de continuer. Certaines personnes sont dérangées par cela, et je suppose que c'est en grande partie la raison pour laquelle RTTI est considéré comme un peu sale.

Y a-t-il un problème de performances? Peut-être, mais je ne l'ai jamais expérimenté, et c'est peut-être la sagesse d'il y a vingt ans, ou de personnes qui croient honnêtement que l'utilisation de trois instructions de montage au lieu de deux est un gonflement inacceptable.

Alors, comment y faire face ... En fonction de votre situation, il peut être judicieux de regrouper les propriétés spécifiques aux nœuds dans des objets séparés (c'est-à-dire que toute l'API «orange» pourrait être un objet distinct). L'objet racine pourrait alors avoir une fonction virtuelle pour renvoyer l'API 'orange', renvoyant nullptr par défaut pour les objets non orange.

Bien que cela puisse être excessif en fonction de votre situation, cela vous permettrait de demander au niveau racine si un nœud spécifique prend en charge une API spécifique et, si c'est le cas, d'exécuter des fonctions spécifiques à cette API.

H. Guijt
la source
6
Re: le coût des performances - J'ai mesuré dynamic_cast <> comme coûtant environ 2µs dans notre application sur un processeur 3GHz, ce qui est environ 1000 fois plus lent que la vérification d'une énumération. (Notre application a une date limite de 11,1 ms pour la boucle principale, nous nous soucions donc beaucoup des microsecondes.)
Crashworks
6
Les performances diffèrent beaucoup entre les implémentations. GCC utilise une comparaison de pointeurs de typeinfo qui est rapide. MSVC utilise des comparaisons de chaînes qui ne sont pas rapides. Cependant, la méthode de MSVC fonctionnera avec du code lié à différentes versions de bibliothèques, statiques ou DLL, où la méthode pointeur de GCC croit qu'une classe dans une bibliothèque statique est différente d'une classe dans une bibliothèque partagée.
Zan Lynx
1
@Crashworks Juste pour avoir un enregistrement complet ici: quel compilateur (et quelle version) était-ce?
H. Guijt
@Crashworks secondant la demande d'informations sur quel compilateur a produit vos résultats observés; Merci.
underscore_d
@underscore_d: MSVC.
Crashworks
9

C ++ est construit sur l'idée de la vérification de type statique.

[1] RTTI, c'est-à-dire dynamic_castet type_id, est une vérification de type dynamique.

Donc, essentiellement, vous vous demandez pourquoi la vérification de type statique est préférable à la vérification de type dynamique. Et la réponse simple est de savoir si la vérification de type statique est préférable à la vérification de type dynamique, cela dépend . Sur beaucoup. Mais C ++ est l'un des langages de programmation conçus autour de l'idée de vérification de type statique. Et cela signifie que, par exemple, le processus de développement, en particulier les tests, est généralement adapté à la vérification de type statique, puis correspond le mieux à cela.


" Je ne saurais pas comment faire cela avec des modèles ou d'autres méthodes

vous pouvez faire ce processus-nœuds-hétérogènes-d'un-graphe avec une vérification de type statique et aucun cast que ce soit via le modèle de visiteur, par exemple comme ceci:

#include <iostream>
#include <set>
#include <initializer_list>

namespace graph {
    using std::set;

    class Red_thing;
    class Yellow_thing;
    class Orange_thing;

    struct Callback
    {
        virtual void handle( Red_thing& ) {}
        virtual void handle( Yellow_thing& ) {}
        virtual void handle( Orange_thing& ) {}
    };

    class Node
    {
    private:
        set<Node*> connected_;

    public:
        virtual void call( Callback& cb ) = 0;

        void connect_to( Node* p_other )
        {
            connected_.insert( p_other );
        }

        void call_on_connected( Callback& cb )
        {
            for( auto const p : connected_ ) { p->call( cb ); }
        }

        virtual ~Node(){}
    };

    class Red_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        auto redness() -> int { return 255; }
    };

    class Yellow_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }
    };

    class Orange_thing
        : public Red_thing
        , public Yellow_thing
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        void poke() { std::cout << "Poked!\n"; }

        void poke_connected_orange_things()
        {
            struct Poker: Callback
            {
                void handle( Orange_thing& obj ) override
                {
                    obj.poke();
                }
            } poker;

            call_on_connected( poker );
        }
    };
}  // namespace graph

auto main() -> int
{
    using namespace graph;

    Red_thing   r;
    Yellow_thing    y1, y2;
    Orange_thing    o1, o2, o3;

    for( Node* p : std::initializer_list<Node*>{ &y1, &y2, &r, &o2, &o3 } )
    {
        o1.connect_to( p );
    }
    o1.poke_connected_orange_things();
}

Cela suppose que l'ensemble des types de nœuds est connu.

Quand ce n'est pas le cas, le modèle de visiteur (il en existe de nombreuses variantes) peut être exprimé avec quelques moulages centralisés, ou juste un seul.


Pour une approche basée sur des modèles, consultez la bibliothèque Boost Graph. C'est triste de dire que je ne suis pas familier avec cela, je ne l'ai pas utilisé. Je ne sais donc pas exactement ce qu'il fait et comment, et dans quelle mesure il utilise la vérification de type statique au lieu de RTTI, mais comme Boost est généralement basé sur un modèle avec la vérification de type statique comme idée centrale, je pense que vous le trouverez sa sous-bibliothèque Graph est également basée sur la vérification de type statique.


[1] Informations sur le type de durée d'exécution .

Bravo et hth. - Alf
la source
1
Une "chose amusante" à noter est que l'on peut réduire la quantité de code (changements lors de l'ajout de types) nécessaire pour le modèle de visiteur en utilisant RTTI pour "gravir" une hiérarchie. Je connais cela comme un "modèle de visiteur acyclique".
Daniel Jour
3

Bien sûr, il existe un scénario où le polymorphisme ne peut pas aider: les noms. typeidvous permet d'accéder au nom du type, bien que la manière dont ce nom est encodé soit définie par l'implémentation. Mais en général, ce n'est pas un problème puisque vous pouvez comparer deux typeid-s:

if ( typeid(5) == "int" )
    // may be false

if ( typeid(5) == typeid(int) )
   // always true

La même chose vaut pour les hachages.

[...] RTTI est "considéré comme dangereux"

nuisibles est certainement exagéré: RTTI a quelques inconvénients, mais il ne présente des avantages aussi.

Vous n'avez pas vraiment besoin d'utiliser RTTI. RTTI est un outil pour résoudre les problèmes de POO: si vous utilisez un autre paradigme, ceux-ci disparaîtraient probablement. C n'a pas de RTTI, mais fonctionne toujours. Au lieu de cela, C ++ prend entièrement en charge la POO et vous donne plusieurs outils pour résoudre certains problèmes qui peuvent nécessiter des informations d'exécution: l'un d'entre eux est en effet RTTI, qui a cependant un prix. Si vous ne pouvez pas vous le permettre, chose que vous feriez mieux de dire qu'après une analyse de performance sécurisée, il y a toujours la vieille école void*: c'est gratuit. Sans coût. Mais vous n'obtenez aucune sécurité de type. C'est donc une question de métiers.


  • Certains compilateurs n'utilisent pas / RTTI n'est pas toujours activé.
    Je n'achète vraiment pas cet argument. C'est comme dire que je ne devrais pas utiliser les fonctionnalités C ++ 14, car il existe des compilateurs qui ne le prennent pas en charge. Et pourtant, personne ne me découragerait d'utiliser les fonctionnalités de C ++ 14.

Si vous écrivez du code C ++ (éventuellement strictement) conforme, vous pouvez vous attendre au même comportement quelle que soit l'implémentation. Les implémentations conformes aux normes doivent prendre en charge les fonctionnalités C ++ standard.

Mais considérez que dans certains environnements définis par C ++ (ceux «autonomes»), RTTI n'a pas besoin d'être fourni et les exceptions non plus, virtualet ainsi de suite. RTTI a besoin d'une couche sous-jacente pour fonctionner correctement qui traite des détails de bas niveau tels que l'ABI et les informations de type réelles.


Je suis d'accord avec Yakk concernant RTTI dans ce cas. Oui, il pourrait être utilisé; mais est-ce logiquement correct? Le fait que la langue vous permette de contourner cette vérification ne signifie pas que cela doit être fait.

edmz
la source