web-dev-qa-db-fra.com

Implémentation de classes et interfaces abstraites pures

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?

27
Clinton

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:

  • Wikipédia: mise en cache en ligne . Discute des approches de mise en cache qui peuvent être utilisées pour éviter une recherche de méthode coûteuse. Généralement non nécessaire pour la répartition basée sur vtable, mais très souhaitable pour les mécanismes de répartition plus coûteux comme les stratégies de répartition d'interface ci-dessus.
  • OpenJDK Wiki (2013): Appels d'interface . Discute des objets utilisables.
  • Pobar, Neward (2009): SSCLI 2.0 Internals. Le chapitre 5 du livre traite en détail des cartes des emplacements. N'a jamais été publié mais mis à disposition par les auteurs sur leurs blogs . Le lien PDF a depuis déménagé. Ce livre ne reflète probablement plus l'état actuel du CLR.
  • CoreCLR (2006): Virtual Stub Dispatch . Dans: Book Of The Runtime. Discute des cartes d'emplacements et de la mise en cache pour éviter les recherches coûteuses.
  • Kennedy, Syme (2001): Conception et implémentation de génériques pour le Common Language Runtime .NET . ( lien PDF ). Discute de diverses approches pour implémenter des génériques. Les génériques interagissent avec la répartition des méthodes car les méthodes peuvent être spécialisées et les vtables doivent donc être réécrites.
35
amon

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);
}
2
Richard Hodges