Bien que cela ne soit pas obligatoire dans la norme C ++, il semble que la façon dont GCC implémente les classes parentes, y compris les classes abstraites pures, consiste à inclure un pointeur vers la table v pour cette classe abstraite à chaque instanciation de la classe en question .
Naturellement, cela gonfle la taille de chaque instance de cette classe par un pointeur pour chaque classe parente qu'elle possède.
Mais j'ai remarqué que de nombreuses classes et structures C # ont beaucoup d'interfaces parent, qui sont essentiellement des classes abstraites pures. Je serais surpris si chaque instance de say Decimal
, était gonflée de 6 pointeurs vers toutes ses différentes interfaces.
Donc, si C # fait les interfaces différemment, comment les fait-il, au moins dans une implémentation typique (je comprends que la norme elle-même ne définit pas une telle implémentation)? Et les implémentations C ++ ont-elles un moyen d'éviter le gonflement de la taille de l'objet lors de l'ajout de parents virtuels purs aux classes?
la source
IComparer
àCompare
g++-7 -fdump-class-hierarchy
sortie.Réponses:
Dans les implémentations C # et Java, les objets ont généralement un seul pointeur vers sa classe. Cela est possible car ce sont des langages à héritage unique. La structure de classe contient alors la table virtuelle pour la hiérarchie à héritage unique. Mais appeler des méthodes d'interface a également tous les problèmes d'héritage multiple. Ceci est généralement résolu en plaçant des vtables supplémentaires pour toutes les interfaces implémentées dans la structure de classe. Cela économise de l'espace par rapport aux implémentations d'héritage virtuel typiques en C ++, mais rend la répartition des méthodes d'interface plus compliquée - qui peut être partiellement compensée par la mise en cache.
Par exemple, dans la JVM OpenJDK, chaque classe contient un tableau de vtables pour toutes les interfaces implémentées (une interface vtable est appelée un itable ). Lorsqu'une méthode d'interface est appelée, ce tableau recherche linéairement l'itable de cette interface, puis la méthode peut être distribuée via cet itable. La mise en cache est utilisée pour que chaque site d'appel se souvienne du résultat de l'envoi de la méthode, de sorte que cette recherche ne doit être répétée que lorsque le type d'objet concret change. Pseudocode pour l'envoi de méthode:
(Comparez le vrai code dans l' interpréteur OpenJDK HotSpot ou le compilateur x86 .)
C # (ou plus précisément, le CLR) utilise une approche connexe. Cependant, ici les itables ne contiennent pas de pointeurs vers les méthodes, mais sont des mappages de slots: ils pointent vers des entrées dans la table principale de la classe. Comme pour Java, la recherche de l'itable correct n'est que le pire des cas, et il est prévu que la mise en cache sur le site d'appel puisse éviter cette recherche presque toujours. Le CLR utilise une technique appelée Virtual Stub Dispatch afin de patcher le code machine compilé JIT avec différentes stratégies de mise en cache. Pseudocode:
La principale différence avec le pseudocode OpenJDK est que, dans OpenJDK, chaque classe possède un tableau de toutes les interfaces implémentées directement ou indirectement, tandis que le CLR ne conserve qu'un tableau de mappages d'emplacements pour les interfaces qui ont été directement implémentées dans cette classe. Nous devons donc remonter la hiérarchie d'héritage jusqu'à ce qu'une carte de slot soit trouvée. Pour les hiérarchies d'héritage profondes, cela se traduit par des économies d'espace. Celles-ci sont particulièrement pertinentes dans CLR en raison de la façon dont les génériques sont implémentés: pour une spécialisation générique, la structure de classe est copiée et les méthodes de la table principale peuvent être remplacées par des spécialisations. Les mappages d'emplacements continuent de pointer vers les entrées de table appropriées et peuvent donc être partagés entre toutes les spécialisations génériques d'une classe.
Pour finir, il existe plus de possibilités pour implémenter la répartition d'interface. Au lieu de placer le pointeur vtable / itable dans l'objet ou dans la structure de classe, nous pouvons utiliser de gros pointeurs vers l'objet, qui sont essentiellement une
(Object*, VTable*)
paire. L'inconvénient est que cela double la taille des pointeurs et que les upcasts (d'un type concret à un type d'interface) ne sont pas gratuits. Mais il est plus flexible, a moins d'indirection, et signifie également que les interfaces peuvent être implémentées en externe à partir d'une classe. Les approches associées sont utilisées par les interfaces Go, les caractères Rust et les classes de types Haskell.Références et lectures complémentaires:
la source
callvirt
AKACEE_CALLVIRT
dans CoreCLR est l'instruction CIL qui gère les méthodes d'interface d'appel, si quelqu'un veut en savoir plus sur la façon dont le runtime gère cette configuration.call
opcode est utilisé pour lesstatic
méthodes, ilcallvirt
est intéressant de l' utiliser même si la classe l'estsealed
.Si par «classe parent» vous voulez dire «classe de base», ce n'est pas le cas dans gcc (et je ne m'attends à aucun autre compilateur).
Dans le cas de C dérive de B dérive de A où A est une classe polymorphe, l'instance C aura exactement une vtable.
Le compilateur dispose de toutes les informations dont il a besoin pour fusionner les données de la table V de A en B et de B en C.
Voici un exemple: https://godbolt.org/g/sfdtNh
Vous verrez qu'il n'y a qu'une seule initialisation d'une table virtuelle.
J'ai copié la sortie de l'assembly pour la fonction principale ici avec des annotations:
Source complète pour référence:
la source
class Derived : public FirstBase, public SecondBase
alors il peut y avoir deux vtables. Vous pouvez exécuterg++ -fdump-class-hierarchy
pour voir la disposition de la classe (également affichée dans mon article de blog lié). Godbolt affiche alors un incrément de pointeur supplémentaire avant l'appel afin de sélectionner le 2ème vtable.