Tabelle virtuali e layout di memoria in più eredità virtuali

Considera la seguente gerarchia:

struct A { int a; A() { f(0); } A(int i) { f(i); } virtual void f(int i) { cout << i; } }; struct B1 : virtual A { int b1; B1(int i) : A(i) { f(i); } virtual void f(int i) { cout << i+10; } }; struct B2 : virtual A { int b2; B2(int i) : A(i) { f(i); } virtual void f(int i) { cout << i+20; } }; struct C : B1, virtual B2 { int c; C() : B1(6),B2(3),A(1){} virtual void f(int i) { cout << i+30; } }; 
  1. Qual è l’ esatto layout di memoria dell’istanza C ? Quanti vptrs contiene, dove sono posizionati esattamente ciascuno di essi? Quale delle tabelle virtuali sono condivise con la tabella virtuale di C? Cosa contiene esattamente ogni tabella virtuale?

    Ecco come ho capito il layout:

     ---------------------------------------------------------------- |vptr1 | AptrOfB1 | b1 | B2ptr | c | vptr2 | AptrOfB2 | b2 | a | ---------------------------------------------------------------- 

    dove AptrOfBx è il puntatore a un’istanza che Bx contiene (poiché l’ereditarietà è virtuale).
    È corretto? A quali funzioni vptr1 punta? A quali funzioni vptr2 punta?

  2. Dato il seguente codice

     C* c = new C(); dynamic_cast(c)->f(3); static_cast(c)->f(3); reinterpret_cast(c)->f(3); 

    Perché tutte le chiamate a f print 33 ?

Le basi virtuali sono molto diverse dalle basi ordinarie. Ricorda che “virtuale” significa “determinato in fase di esecuzione”, quindi l’intero sottobobbo di base deve essere determinato in fase di runtime.

Immagina di ottenere un riferimento B & x e hai il compito di trovare il membro A::a . Se l’ereditarietà era reale, allora B ha una superclass A , e quindi l’object B che stai visualizzando attraverso x ha un object- A in cui puoi trovare il tuo membro A::a . Se l’object più derivato di x ha basi multiple di tipo A , allora puoi vedere solo quella particolare copia che è il subobject di B

Ma se l’ereditarietà è virtuale, niente di tutto ciò ha senso. Non sappiamo quale A -sobobject ci serva – questa informazione semplicemente non esiste al momento della compilazione. Potremmo avere a che fare con un object B reale come in B y; B & x = y; B y; B & x = y; o con un object C come C z; B & x = z; C z; B & x = z; o qualcosa di completamente diverso che deriva virtualmente da A molte più volte. L’unico modo per sapere è trovare la base effettiva A in fase di esecuzione .

Questo può essere implementato con un ulteriore livello di riferimento a runtime. (Si noti come questo sia del tutto parallelo al modo in cui le funzioni virtuali vengono implementate con un livello aggiuntivo di runtime indiretto rispetto alle funzioni non virtuali.) Invece di avere un puntatore a un subo vtable o di base, una soluzione è quella di memorizzare un puntatore su un puntatore al vero e proprio subobject di base. Questo a volte viene chiamato “thunk” o “trampolino”.

Quindi l’object reale C z; può apparire come segue. L’attuale ordine in memoria dipende dal compilatore e non è importante, e ho soppresso i vtables.

 +-+------++-+------++-----++-----+ |T| B1 ||T| B2 || C || A | +-+------++-+------++-----++-----+ | | | VV ^ | | +-Thunk-+ | +--->>----+-->>---| ->>-+ +-------+ 

Quindi, non importa se hai un B1& o un B2& , per prima cosa cerca il thunk, e quello a sua volta ti dirà dove trovare il vero e proprio subobject di base. Questo spiega anche perché non è ansible eseguire un cast statico da un A& a nessuno dei tipi derivati: queste informazioni semplicemente non esistono al momento della compilazione.

Per una spiegazione più approfondita, dai un’occhiata a questo bell’articolo . (In questa descrizione, il thunk fa parte del vtable di C , e l’ereditarietà virtuale richiede sempre il mantenimento di vtables, anche se non ci sono funzioni virtuali da nessuna parte.)

Ho pimpato il tuo codice un po ‘come segue:

 #include  #include  struct A { int a; A() : a(32) { f(0); } A(int i) : a(32) { f(i); } virtual void f(int i) { printf("%d\n", i); } }; struct B1 : virtual A { int b1; B1(int i) : A(i), b1(33) { f(i); } virtual void f(int i) { printf("%d\n", i+10); } }; struct B2 : virtual A { int b2; B2(int i) : A(i), b2(34) { f(i); } virtual void f(int i) { printf("%d\n", i+20); } }; struct C : B1, virtual B2 { int c; C() : B1(6),B2(3),A(1), c(35) {} virtual void f(int i) { printf("%d\n", i+30); } }; int main() { C foo; intptr_t address = (intptr_t)&foo; printf("offset A = %ld, sizeof A = %ld\n", (intptr_t)(A*)&foo - address, sizeof(A)); printf("offset B1 = %ld, sizeof B1 = %ld\n", (intptr_t)(B1*)&foo - address, sizeof(B1)); printf("offset B2 = %ld, sizeof B2 = %ld\n", (intptr_t)(B2*)&foo - address, sizeof(B2)); printf("offset C = %ld, sizeof C = %ld\n", (intptr_t)(C*)&foo - address, sizeof(C)); unsigned char* data = (unsigned char*)address; for(int offset = 0; offset < sizeof(C); offset++) { if(!(offset & 7)) printf("| "); printf("%02x ", (int)data[offset]); } printf("\n"); } 

Come vedi, questo stampa un bel po 'di informazioni aggiuntive che ci permettono di dedurre il layout della memoria. L'output sulla mia macchina (un byte Linux a 64 bit, piccolo ordine byte endian) è questo:

 1 23 16 offset A = 16, sizeof A = 16 offset B1 = 0, sizeof B1 = 32 offset B2 = 32, sizeof B2 = 32 offset C = 0, sizeof C = 48 | 00 0d 40 00 00 00 00 00 | 21 00 00 00 23 00 00 00 | 20 0d 40 00 00 00 00 00 | 20 00 00 00 00 00 00 00 | 48 0d 40 00 00 00 00 00 | 22 00 00 00 00 00 00 00 

Quindi, possiamo descrivere il layout come segue:

 +--------+----+----+--------+----+----+--------+----+----+ | vptr | b1 | c | vptr | a | xx | vptr | b2 | xx | +--------+----+----+--------+----+----+--------+----+----+ 

Qui, xx indica padding. Nota come il compilatore ha posizionato la variabile c nel padding della sua base non virtuale. Si noti inoltre che tutti e tre i puntatori v sono diversi, questo consente al programma di dedurre le posizioni corrette di tutte le basi virtuali.