Implementazione di classi e interfacce astratte pure


27

Sebbene questo non sia obbligatorio nello standard C ++, sembra che GCC, ad esempio, implementa le classi parent, comprese quelle pure astratte, includendo un puntatore alla tabella v per quella classe astratta in ogni istanza della classe in questione .

Naturalmente questo gonfia le dimensioni di ogni istanza di questa classe da un puntatore per ogni classe genitrice che ha.

Ma ho notato che molte classi e strutture C # hanno molte interfacce padre, che sono fondamentalmente classi astratte pure. Sarei sorpreso se ogni istanza di dire Decimal, fosse gonfiata con 6 puntatori a tutte le sue varie interfacce.

Quindi se C # fa interfacce in modo diverso, come le fa, almeno in un'implementazione tipica (capisco che lo standard stesso potrebbe non definire tale implementazione)? E le implementazioni C ++ hanno un modo per evitare il gonfiamento delle dimensioni degli oggetti quando si aggiungono genitori virtuali puri alle classi?


1
Gli oggetti C # di solito hanno molti metadati collegati, forse i vtables non sono così grandi rispetto a quelli
max630

potresti iniziare con l'esame del codice compilato con disassemblatore idl
max630

Il C ++ fa una frazione significativa delle sue "interfacce" staticamente. Confronta IComparerconCompare
Caleth,

4
GCC, ad esempio, utilizza un puntatore di tabella vtable (un puntatore a una tabella di vtables o un VTT) per oggetto per le classi con più classi di base. Quindi, ogni oggetto ha solo un singolo puntatore extra anziché la collezione che stai immaginando. Forse ciò significa in pratica che non è un problema anche quando il codice è mal progettato e c'è una massiccia gerarchia di classi coinvolta.
Stephen M. Webb,

1
@ StephenM.Webb Per quanto ho capito da questa risposta SO , i VTT vengono utilizzati solo per ordinare la costruzione / distruzione con eredità virtuale. Non partecipano all'invio di metodi e non finiscono per risparmiare spazio nell'oggetto stesso. Poiché gli aggiornamenti C ++ eseguono efficacemente lo slicing degli oggetti, non è possibile posizionare il puntatore vtable in nessun altro posto ma nell'oggetto (che per MI aggiunge i puntatori vtable al centro dell'oggetto). Ho verificato guardando l' g++-7 -fdump-class-hierarchyoutput.
Amon,

Risposte:


35

Nelle implementazioni C # e Java, gli oggetti in genere hanno un singolo puntatore alla sua classe. Ciò è possibile perché sono linguaggi a eredità singola. La struttura della classe contiene quindi la vtable per la gerarchia di ereditarietà singola. Ma chiamare i metodi di interfaccia ha anche tutti i problemi dell'ereditarietà multipla. Questo è in genere risolto inserendo vtables aggiuntivi per tutte le interfacce implementate nella struttura della classe. Ciò consente di risparmiare spazio rispetto alle tipiche implementazioni di ereditarietà virtuali in C ++, ma rende più complicata la distribuzione dei metodi di interfaccia, che può essere parzialmente compensata dalla memorizzazione nella cache.

Ad esempio nella JVM OpenJDK, ogni classe contiene un array di vtables per tutte le interfacce implementate (un'interfaccia vtable è chiamata itable ). Quando viene chiamato un metodo di interfaccia, questo array viene cercato linearmente per l'Itable di quell'interfaccia, quindi il metodo può essere inviato attraverso quell'Itable. La memorizzazione nella cache viene utilizzata in modo che ciascun sito di chiamata ricordi il risultato dell'invio del metodo, pertanto questa ricerca deve essere ripetuta solo quando il tipo di oggetto concreto cambia. Pseudocodice per l'invio del metodo:

// 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
}

(Confronta il codice reale nell'interprete HotSpot OpenJDK o nel compilatore x86 ).

C # (o più precisamente, il CLR) utilizza un approccio correlato. Tuttavia, qui gli itable non contengono puntatori ai metodi, ma sono mappe di slot: puntano a voci nella vtable principale della classe. Come per Java, la ricerca dell'Itable corretto è solo lo scenario peggiore e si prevede che la memorizzazione nella cache nel sito di chiamata possa evitare quasi sempre questa ricerca. Il CLR utilizza una tecnica chiamata Virtual Stub Dispatch per correggere il codice macchina compilato da JIT con diverse strategie di memorizzazione nella cache. pseudocodice:

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 differenza principale rispetto allo pseudocodice OpenJDK è che in OpenJDK ogni classe ha un array di tutte le interfacce implementate direttamente o indirettamente, mentre il CLR mantiene solo un array di mappe di slot per interfacce implementate direttamente in quella classe. Pertanto, dobbiamo spostare la gerarchia ereditaria verso l'alto fino a quando non viene trovata una mappa di slot. Per gerarchie ereditarie profonde ciò si traduce in un risparmio di spazio. Questi sono particolarmente rilevanti nel CLR a causa del modo in cui vengono implementati i generici: per una specializzazione generica, la struttura della classe viene copiata e i metodi nella tabella principale possono essere sostituiti da specializzazioni. Le mappe di slot continuano a puntare alle voci vtable corrette e possono quindi essere condivise tra tutte le specializzazioni generiche di una classe.

Come nota finale, ci sono più possibilità per implementare l'invio dell'interfaccia. Invece di posizionare il puntatore vtable / itable nell'oggetto o nella struttura della classe, possiamo usare puntatori grassi sull'oggetto, che sono fondamentalmente una (Object*, VTable*)coppia. Lo svantaggio è che questo raddoppia la dimensione dei puntatori e che gli upcasts (da un tipo concreto a un tipo di interfaccia) non sono gratuiti. Ma è più flessibile, ha meno riferimenti indiretti e significa anche che le interfacce possono essere implementate esternamente da una classe. Gli approcci correlati sono utilizzati dalle interfacce Go, i tratti Rust e le macchine da scrivere Haskell.

Riferimenti e ulteriori letture:

  • Wikipedia: cache in linea . Discute gli approcci di memorizzazione nella cache che possono essere utilizzati per evitare costose ricerche di metodi. In genere non è necessario per l'invio basato su vtable, ma è molto desiderabile per meccanismi di invio più costosi come le strategie di invio dell'interfaccia sopra riportate.
  • OpenJDK Wiki (2013): Interface Calls . Discute su itables.
  • Pobar, Neward (2009): Internals SSCLI 2.0. Il capitolo 5 del libro tratta in dettaglio le mappe delle slot. Non è mai stato pubblicato ma reso disponibile dagli autori sui loro blog . Da allora il link PDF è stato spostato. Questo libro probabilmente non riflette più lo stato attuale del CLR.
  • CoreCLR (2006): Virtual Stub Dispatch . In: Book Of The Runtime. Discute le mappe delle slot e la memorizzazione nella cache per evitare ricerche costose.
  • Kennedy, Syme (2001): Progettazione e implementazione di Generics per .NET Common Language Runtime . ( Collegamento PDF ). Discute vari approcci per implementare generici. I generici interagiscono con l'invio dei metodi perché i metodi potrebbero essere specializzati, quindi potrebbe essere necessario riscrivere i vtables.

Grazie @amon ottima risposta in attesa di ulteriori dettagli su come Java e CLR ottengano questo risultato!
Clinton,

@Clinton Ho aggiornato il post con alcuni riferimenti. Puoi anche leggere il codice sorgente delle macchine virtuali, ma ho trovato difficile seguirlo. I miei riferimenti sono un po 'vecchi, se trovi qualcosa di nuovo sarei piuttosto interessato. Questa risposta è fondamentalmente un estratto di note che avevo in giro per un post sul blog, ma non sono mai andato in giro a pubblicarlo: /
amon

1
callvirtAKA CEE_CALLVIRTin CoreCLR è l'istruzione CIL che gestisce i metodi di interfaccia di chiamata, se qualcuno vuole leggere di più su come il runtime gestisce questa configurazione.
jrh

Si noti che il callcodice operativo viene utilizzato per i staticmetodi, interessante callvirtanche se viene utilizzata la classe sealed.
jrh

1
Ri, "Gli oggetti [C #] in genere hanno un singolo puntatore alla sua classe ... perché [C # è un] linguaggio a eredità singola". Anche in C ++, con tutto il suo potenziale per reti complesse di tipi ereditati in modo multiplo, è ancora possibile specificare un tipo nel punto in cui il programma crea una nuova istanza. Dovrebbe essere possibile, in teoria, progettare un compilatore C ++ e una libreria di supporto runtime in modo tale che nessuna istanza di classe porti mai più di un puntatore di RTTI.
Solomon Slow

2

Naturalmente questo gonfia le dimensioni di ogni istanza di questa classe da un puntatore per ogni classe genitrice che ha.

Se per "classe genitore" intendi "classe base", questo non è il caso di gcc (né mi aspetto in nessun altro compilatore).

Nel caso di C deriva da B deriva da A dove A è una classe polimorfica, l'istanza C avrà esattamente una tabella.

Il compilatore ha tutte le informazioni necessarie per unire i dati nella tabella di A in B e B in C.

Ecco un esempio: https://godbolt.org/g/sfdtNh

Vedrai che c'è solo un'inizializzazione di una vtable.

Ho copiato l'output dell'assembly per la funzione principale qui con le annotazioni:

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

Fonte completa per riferimento:

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);
}

Se prendiamo un esempio in cui la sottoclasse eredita direttamente da due classi base come class Derived : public FirstBase, public SecondBaseallora ci possono essere due vtabili. Puoi correre g++ -fdump-class-hierarchyper vedere il layout della classe (mostrato anche nel mio post sul blog collegato). Godbolt mostra quindi un ulteriore incremento del puntatore prima della chiamata per selezionare la 2a vtable.
amon,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.