Bien que cela ne soit pas obligatoire dans la norme C++, il semble que la manière 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?
Dans 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 la classe contient alors la table de contrôle pour la hiérarchie à héritage unique . Mais l'appel de méthodes d'interface présente également tous les problèmes d'héritage multiple. Cela est généralement résolu en plaçant des tables vtables supplémentaires pour toutes les interfaces implémentées dans la structure de classe. compliqué - qui peut être partiellement compensé par la mise en cache.
Par exemple. dans la machine virtuelle Java 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:
// Dispatch SomeInterface.method
Method const* resolve_method(
Object const* instance, Klass const* interface, uint itable_slot) {
Klass const* klass = instance->klass;
for (Itable const* itable : klass->itables()) {
if (itable->klass() == interface)
return itable[itable_slot];
}
throw ...; // class does not implement required interface
}
(Comparez le vrai code dans le HotSpot OpenJDK interprète ou 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 avec 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:
Method const* resolve_method(
Object const* instance, Klass const* interface, uint interface_slot) {
Klass const* klass = instance->klass;
// Walk all base classes to find slot map
for (Klass const* base = klass; base != nullptr; base = base->base()) {
// I think the CLR actually uses hash tables instead of a linear search
for (SlotMap const* slot_map : base->slot_maps()) {
if (slot_map->klass() == interface) {
uint vtable_slot = slot_map[interface_slot];
return klass->vtable[vtable_slot];
}
}
}
throw ...; // class does not implement required interface
}
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 un (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 traits Rust et les classes de types Haskell.
Références et lectures complémentaires:
Naturellement, cela gonfle la taille de chaque instance de cette classe par un pointeur pour chaque classe parente qu'elle possède.
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 Assembly pour la fonction principale ici avec des annotations:
main:
Push rbx
# allocate space for a C on the stack
sub rsp, 16
# initialise c's vtable (note: only one)
mov QWORD PTR [rsp+8], OFFSET FLAT:vtable for C+16
# use c
lea rdi, [rsp+8]
call do_something(C&)
# destruction sequence through virtual destructor
mov QWORD PTR [rsp+8], OFFSET FLAT:vtable for B+16
lea rdi, [rsp+8]
call A::~A() [base object destructor]
add rsp, 16
xor eax, eax
pop rbx
ret
mov rbx, rax
jmp .L10
Source complète pour référence:
struct A
{
virtual void foo() = 0;
virtual ~A();
};
struct B : A {};
struct C : B {
virtual void extrafoo()
{
}
void foo() override {
extrafoo();
}
};
int main()
{
extern void do_something(C&);
auto c = C();
do_something(c);
}