c++ - generation - ¿Cuál es el VTT para una clase?
generation z (6)
Recientemente me encontré con un error de enlazador de C ++ que era nuevo para mí.
libfoo.so: undefined reference to `VTT for Foo''
libfoo.so: undefined reference to `vtable for Foo''
Reconocí el error y solucioné mi problema, pero todavía tengo una pregunta persistente: ¿qué es exactamente un VTT?
Aparte: para los interesados, el problema ocurre cuando olvida definir la primera función virtual declarada en una clase. El vtable entra en la unidad de compilación de la primera función virtual de la clase. Si olvida definir esa función, se obtiene un error del enlazador que no puede encontrar la tabla de contenido, en lugar de que la herramienta mucho más amigable para el desarrollador.
La página "Notas sobre la herencia múltiple en el compilador de G ++ C ++ v4.0.1" ahora está fuera de línea, y http://web.archive.org no la archivó . Entonces, encontré una copia del texto en tinydrblog que está archivado en el archivo web .
Hay un texto completo de las Notas originales, publicadas en línea como parte del " Seminario de Lenguaje de Programación Doctoral: Internals GCC " (otoño de 2005) por el graduado Morgan Deters "en el Laboratorio de Computación de Objetos Distribuidos en el departamento de Informática de la Universidad de Washington en San Luis. "
Su página de inicio (archivada) :
THIS IS THE TEXT by Morgan Deters and NOT CC-licensed.
Páginas web de Morgan Deters:
- http://web.archive.org/web/20060908050947/http://www.cse.wustl.edu/~mdeters/ ,
- Su biografía http://web.archive.org/web/20060910122623/http://www.cse.wustl.edu/~mdeters/bio/ ,
- Perfil de Google Scholar: https://scholar.google.com/citations?user=DsD8SDYAAAAJ&hl=en&oi=sra
- Página web más reciente https://cs.nyu.edu/~mdeters/
- Obituario de 2015: http://www.sent-trib.com/obituaries/dr-morgan-g-deters/article_70a9b22a-a307-11e4-ba08-476415c7fb1c.html " Dr. Morgan G. Deters, PhD, 35, anteriormente de Bowling Green y más recientemente de Brooklyn, NY, murió el sábado 17 de enero de 2015 en Tobago, Trinidad " .
- http://cvc4.cs.stanford.edu/web/in-memoriam-morgan-deters/
PARTE 1:
Lo básico: Herencia única
Como comentamos en clase, la herencia única conduce a un diseño de objeto con datos de clase base establecidos antes de los datos de clase derivados. Entonces, si las clases
A
yB
se definen de la siguiente manera:
class A { public: int a;
};
class B : public A { public: int b; };
luego los objetos de tipo
B
se disponen de esta manera (donde "b" es un puntero a dicho objeto):
b --> +-----------+ | a | +-----------+ | b | +-----------+
Si tienes métodos virtuales:
class A { public: int a; virtual void v(); }; class B : public A { public: int b; };
entonces también tendrás un puntero vtable:
+-----------------------+ | 0 (top_offset) | +-----------------------+ b --> +----------+ | ptr to typeinfo for B | | vtable |-------> +-----------------------+ +----------+ | A::v() | | a | +-----------------------+ +----------+ | b | +----------+
es decir,
top_offset
y el puntero typeinfo viven sobre la ubicación a la que apunta el puntero vtable.Herencia múltiple simple
Ahora considere la herencia múltiple:
class A { public: int a; virtual void v(); }; class B { public: int b; virtual void w(); }; class C : public A, public B { public: int c; };
En este caso, los objetos de tipo C se disponen de esta manera:
+-----------------------+ | 0 (top_offset) | +-----------------------+ c --> +----------+ | ptr to typeinfo for C | | vtable |-------> +-----------------------+ +----------+ | A::v() | | a | +-----------------------+ +----------+ | -8 (top_offset) | | vtable |---+ +-----------------------+ +----------+ | | ptr to typeinfo for C | | b | +---> +-----------------------+ +----------+ | B::w() | | c | +-----------------------+ +----------+
...¿pero por qué? ¿Por qué dos vtables en uno? Bueno, piense en la sustitución de tipo. Si tengo un puntero-a-C, puedo pasarlo a una función que espera un puntero-a-A o una función que espera un puntero-a-B. Si una función espera un puntero-a-A y quiero pasarle el valor de mi variable c (de tipo puntero-a-C), ya estoy configurado. Las llamadas a
A::v()
se pueden realizar a través del (primer) vtable, y la función llamada puede acceder al miembro a a través del puntero que paso de la misma forma que lo hace a través de cualquier puntero-a-A.Sin embargo, si paso el valor de mi variable puntero
c
a una función que espera un puntero-a-B, también necesitamos un subobjeto de tipo B en nuestra C para referirlo. Es por eso que tenemos el segundo puntero vtable. Podemos pasar el valor del puntero (c + 8 bytes) a la función que espera un puntero-a-B, y está todo listo: puede hacer llamadas aB::w()
través del (segundo) puntero vtable, y acceder el miembro b a través del puntero lo pasamos de la misma forma que lo hace a través de cualquier puntero-a-B.Tenga en cuenta que esta "corrección del puntero" también debe ocurrir para los métodos llamados. La clase
C
heredaB::w()
en este caso. Cuando se llama aw()
a través de un puntero a C, se necesita ajustar el puntero (que se convierte en este puntero dentro dew()
. A menudo se lo denomina ajuste de puntero.En algunos casos, el compilador generará un procesador para arreglar la dirección. Considere el mismo código que el anterior, pero esta vez
C
anula la función miembroB
w()
B
:
class A { public: int a; virtual void v(); }; class B { public: int b; virtual void w(); }; class C : public A, public B { public: int c; void w(); };
El diseño de objetos de
C
y vtable ahora se ven así:
+-----------------------+ | 0 (top_offset) | +-----------------------+ c --> +----------+ | ptr to typeinfo for C | | vtable |-------> +-----------------------+ +----------+ | A::v() | | a | +-----------------------+ +----------+ | C::w() | | vtable |---+ +-----------------------+ +----------+ | | -8 (top_offset) | | b | | +-----------------------+ +----------+ | | ptr to typeinfo for C | | c | +---> +-----------------------+ +----------+ | thunk to C::w() | +-----------------------+
Ahora, cuando se llama a
w()
en una instancia deC
través de un puntero a B, se invoca el procesador. ¿Qué hace el thunk? Vamos a desmontarlo (aquí, congdb
):
0x0804860c <_ZThn8_N1C1wEv+0>: addl $0xfffffff8,0x4(%esp) 0x08048611 <_ZThn8_N1C1wEv+5>: jmp 0x804853c <_ZN1C1wEv>
Por lo tanto, simplemente ajusta el puntero y salta a
C::w()
. Todo está bien.Pero, ¿no significa que
B
''s vtable siempre apunta a esteC::w()
thunk? Quiero decir, si tenemos un puntero a B que es legítimamente unaB
(no unaC
), no queremos invocar el procesador, ¿verdad?Derecha. El vtable incrustado anterior para
B
enC
es especial para el caso B-in-C. La ventana regular de B es normal y apunta aB::w()
directamente.The Diamond: múltiples copias de las clases base (herencia no virtual)
Bueno. Ahora para abordar las cosas realmente difíciles. Recuerde el problema habitual de las copias múltiples de las clases base cuando se forma un diamante de herencia:
class A { public: int a; virtual void v(); }; class B : public A { public: int b; virtual void w(); }; class C : public A { public: int c; virtual void x(); }; class D : public B, public C { public: int d; virtual void y(); };
Tenga en cuenta que
D
hereda deB
yC
, yB
yC
ambos heredan deA
Esto significa queD
tiene dos copias deA
en él. El diseño del objeto y la incrustación de vtable es lo que esperaríamos de las secciones anteriores:
+-----------------------+ | 0 (top_offset) | +-----------------------+ d --> +----------+ | ptr to typeinfo for D | | vtable |-------> +-----------------------+ +----------+ | A::v() | | a | +-----------------------+ +----------+ | B::w() | | b | +-----------------------+ +----------+ | D::y() | | vtable |---+ +-----------------------+ +----------+ | | -12 (top_offset) | | a | | +-----------------------+ +----------+ | | ptr to typeinfo for D | | c | +---> +-----------------------+ +----------+ | A::v() | | d | +-----------------------+ +----------+ | C::x() | +-----------------------+
Por supuesto, esperamos que los datos de A (el miembro
a
) existan dos veces en el diseño de objetos deD
(y lo sean), y esperamos que las funciones de miembros virtuales de A se representen dos veces en el vtable (yA::v()
está realmente allí). De acuerdo, nada nuevo aquí.The Diamond: copias únicas de bases virtuales
Pero, ¿y si aplicamos herencia virtual? La herencia virtual de C ++ nos permite especificar una jerarquía de diamantes, pero se garantiza una sola copia de las bases virtualmente heredadas. Entonces, escribamos nuestro código de esta manera:
class A { public: int a; virtual void v(); }; class B : public virtual A { public: int b; virtual void w(); }; class C : public virtual A { public: int c; virtual void x(); }; class D : public B, public C { public: int d; virtual void y(); };
De repente las cosas se vuelven mucho más complicadas. Si solo podemos tener una copia de
A
en nuestra representación deD
, entonces ya no podemos seguir con nuestro "truco" de incrustación de unaC
en unaD
(e incrustando un vtable para la parteC
deD
enD
vtable) ) Pero, ¿cómo podemos manejar la sustitución de tipo habitual si no podemos hacer esto?Tratemos de diagramar el diseño:
+-----------------------+ | 20 (vbase_offset) | +-----------------------+ | 0 (top_offset) | +-----------------------+ | ptr to typeinfo for D | +----------> +-----------------------+ d --> +----------+ | | B::w() | | vtable |----+ +-----------------------+ +----------+ | D::y() | | b | +-----------------------+ +----------+ | 12 (vbase_offset) | | vtable |---------+ +-----------------------+ +----------+ | | -8 (top_offset) | | c | | +-----------------------+ +----------+ | | ptr to typeinfo for D | | d | +-----> +-----------------------+ +----------+ | C::x() | | vtable |----+ +-----------------------+ +----------+ | | 0 (vbase_offset) | | a | | +-----------------------+ +----------+ | | -20 (top_offset) | | +-----------------------+ | | ptr to typeinfo for D | +----------> +-----------------------+ | A::v() | +-----------------------+
Bueno. Entonces ves que
A
está ahora integrado enD
esencialmente de la misma manera que otras bases. Pero está integrado en D en lugar de en sus clases directamente derivadas.
La tabla de tabla virtual, abreviada VTT, es una tabla de tablas usadas en construcción donde se trata de herencia múltiple.
Más información aquí en este interesante artículo: http://www.cse.wustl.edu/~mdeters/seminar/fall2005/mi.html
Son la tabla virtual (función) y la tabla de tipos virtuales, que maneja herencia múltiple.
cf:
http://www.codesourcery.com/archives/cxx-abi-dev/msg00077.html http://www.cse.wustl.edu/~mdeters/seminar/fall2005/mi.html
Tabla de tabla virtual (VTT). Permite que los objetos se construyan / deconstruyan de forma segura cuando hay herencia múltiple.
para una explicación vea: http://www.cse.wustl.edu/~mdeters/seminar/fall2005/mi.html
VTT = Tabla de tabla virtual.
THIS IS THE TEXT by Morgan Deters and NOT CC-licensed.
Páginas web de Morgan Deters:
- http://web.archive.org/web/20060908050947/http://www.cse.wustl.edu/~mdeters/ ,
- Su biografía http://web.archive.org/web/20060910122623/http://www.cse.wustl.edu/~mdeters/bio/ ,
- Perfil de Google Scholar: https://scholar.google.com/citations?user=DsD8SDYAAAAJ&hl=en&oi=sra
- Página web más reciente https://cs.nyu.edu/~mdeters/
- Obituario de 2015: http://www.sent-trib.com/obituaries/dr-morgan-g-deters/article_70a9b22a-a307-11e4-ba08-476415c7fb1c.html " Dr. Morgan G. Deters, PhD, 35, anteriormente de Bowling Green y más recientemente de Brooklyn, NY, murió el sábado 17 de enero de 2015 en Tobago, Trinidad " .
- http://cvc4.cs.stanford.edu/web/in-memoriam-morgan-deters/
PARTE 2:
Construcción / destrucción en presencia de herencia múltiple
¿Cómo se construye el objeto anterior en la memoria cuando se construye el objeto en sí? ¿Y cómo nos aseguramos de que un objeto parcialmente construido (y su vtable) sea seguro para que los constructores operen?
Afortunadamente, todo se manejó con mucho cuidado para nosotros. Digamos que estamos construyendo un nuevo objeto de tipo
D
(a través de, por ejemplo,new D
). Primero, la memoria para el objeto se asigna en el montón y se devuelve un puntero. Se invoca el constructor deD
, pero antes de hacer cualquier construcción específica deD
, llama al constructor de A sobre el objeto (¡después de ajustarthis
puntero, por supuesto!).A
constructor de A rellena la parteA
del objetoD
como si fuera una instancia deA
d --> +----------+ | | +----------+ | | +----------+ | | +----------+ | | +-----------------------+ +----------+ | 0 (top_offset) | | | +-----------------------+ +----------+ | ptr to typeinfo for A | | vtable |-----> +-----------------------+ +----------+ | A::v() | | a | +-----------------------+ +----------+
El control se devuelve al constructor de
D
, que invoca el constructor deB
(El ajuste del puntero no es necesario aquí). Cuando el constructor deB
termina, el objeto se ve así:
B-in-D +-----------------------+ | 20 (vbase_offset) | +-----------------------+ | 0 (top_offset) | +-----------------------+ d --> +----------+ | ptr to typeinfo for B | | vtable |------> +-----------------------+ +----------+ | B::w() | | b | +-----------------------+ +----------+ | 0 (vbase_offset) | | | +-----------------------+ +----------+ | -20 (top_offset) | | | +-----------------------+ +----------+ | ptr to typeinfo for B | | | +--> +-----------------------+ +----------+ | | A::v() | | vtable |---+ +-----------------------+ +----------+ | a | +----------+
Pero espere ... ¡el constructor de
B
modificó la parteA
del objeto al cambiar su puntero vtable! ¿Cómo se sabe distinguir este tipo de B-en-D de un B-en-algo-otro (o unB
independiente para ese caso)? Sencillo. La tabla de la mesa virtual le dijo que hiciera esto. Esta estructura, abreviada VTT , es una tabla de tablas usadas en la construcción. En nuestro caso, el VTT paraD
ve así:
B-in-D +-----------------------+ | 20 (vbase_offset) | VTT for D +-----------------------+ +-------------------+ | 0 (top_offset) | | vtable for D |-------------+ +-----------------------+ +-------------------+ | | ptr to typeinfo for B | | vtable for B-in-D |-------------|----------> +-----------------------+ +-------------------+ | | B::w() | | vtable for B-in-D |-------------|--------+ +-----------------------+ +-------------------+ | | | 0 (vbase_offset) | | vtable for C-in-D |-------------|-----+ | +-----------------------+ +-------------------+ | | | | -20 (top_offset) | | vtable for C-in-D |-------------|--+ | | +-----------------------+ +-------------------+ | | | | | ptr to typeinfo for B | | vtable for D |----------+ | | | +-> +-----------------------+ +-------------------+ | | | | | A::v() | | vtable for D |-------+ | | | | +-----------------------+ +-------------------+ | | | | | | | | | | C-in-D | | | | | +-----------------------+ | | | | | | 12 (vbase_offset) | | | | | | +-----------------------+ | | | | | | 0 (top_offset) | | | | | | +-----------------------+ | | | | | | ptr to typeinfo for C | | | | | +----> +-----------------------+ | | | | | C::x() | | | | | +-----------------------+ | | | | | 0 (vbase_offset) | | | | | +-----------------------+ | | | | | -12 (top_offset) | | | | | +-----------------------+ | | | | | ptr to typeinfo for C | | | | +-------> +-----------------------+ | | | | A::v() | | | | +-----------------------+ | | | | | | D | | | +-----------------------+ | | | | 20 (vbase_offset) | | | | +-----------------------+ | | | | 0 (top_offset) | | | | +-----------------------+ | | | | ptr to typeinfo for D | | | +----------> +-----------------------+ | | | B::w() | | | +-----------------------+ | | | D::y() | | | +-----------------------+ | | | 12 (vbase_offset) | | | +-----------------------+ | | | -8 (top_offset) | | | +-----------------------+ | | | ptr to typeinfo for D | +----------------> +-----------------------+ | | C::x() | | +-----------------------+ | | 0 (vbase_offset) | | +-----------------------+ | | -20 (top_offset) | | +-----------------------+ | | ptr to typeinfo for D | +-------------> +-----------------------+ | A::v() | +-----------------------+
El constructor de D''s pasa un puntero en el VTT de D''a el constructor de B (en este caso, pasa en la dirección de la primera entrada B-en-D). Y, de hecho, el vtable que se usó para el diseño del objeto anterior es un vtable especial usado solo para la construcción de B-in-D.
El control se devuelve al constructor D y llama al constructor C (con un parámetro de dirección VTT apuntando a la entrada "C-en-D + 12"). Cuando el constructor de C termina con el objeto, se ve así:
B-in-D +-----------------------+ | 20 (vbase_offset) | +-----------------------+ | 0 (top_offset) | +-----------------------+ | ptr to typeinfo for B | +---------------------------------> +-----------------------+ | | B::w() | | +-----------------------+ | C-in-D | 0 (vbase_offset) | | +-----------------------+ +-----------------------+ d --> +----------+ | | 12 (vbase_offset) | | -20 (top_offset) | | vtable |--+ +-----------------------+ +-----------------------+ +----------+ | 0 (top_offset) | | ptr to typeinfo for B | | b | +-----------------------+ +-----------------------+ +----------+ | ptr to typeinfo for C | | A::v() | | vtable |--------> +-----------------------+ +-----------------------+ +----------+ | C::x() | | c | +-----------------------+ +----------+ | 0 (vbase_offset) | | | +-----------------------+ +----------+ | -12 (top_offset) | | vtable |--+ +-----------------------+ +----------+ | | ptr to typeinfo for C | | a | +-----> +-----------------------+ +----------+ | A::v() | +-----------------------+
Como puede ver, el constructor de C modificó nuevamente el puntero vtable de A incrustado. Los objetos incrustados C y A ahora están usando la construcción especial C-in-D vtable, y el objeto B incrustado está usando la construcción especial B-in-D vtable. Finalmente, el constructor de D termina el trabajo y terminamos con el mismo diagrama que antes:
+-----------------------+ | 20 (vbase_offset) | +-----------------------+ | 0 (top_offset) | +-----------------------+ | ptr to typeinfo for D | +----------> +-----------------------+ d --> +----------+ | | B::w() | | vtable |----+ +-----------------------+ +----------+ | D::y() | | b | +-----------------------+ +----------+ | 12 (vbase_offset) | | vtable |---------+ +-----------------------+ +----------+ | | -8 (top_offset) | | c | | +-----------------------+ +----------+ | | ptr to typeinfo for D | | d | +-----> +-----------------------+ +----------+ | C::x() | | vtable |----+ +-----------------------+ +----------+ | | 0 (vbase_offset) | | a | | +-----------------------+ +----------+ | | -20 (top_offset) | | +-----------------------+ | | ptr to typeinfo for D | +----------> +-----------------------+ | A::v() | +-----------------------+
La destrucción ocurre de la misma manera pero a la inversa. El destructor D''s es invocado. Después de que se ejecute el código de destrucción del usuario, el destructor llama al destructor de C y lo dirige a usar la porción relevante del VTT de D''s. El destructor de C manipula los punteros vtable de la misma manera que lo hizo durante la construcción; es decir, los punteros vtable relevantes apuntan ahora a la vtable de construcción C-in-D. Luego ejecuta el código de destrucción del usuario para C y devuelve el control al destructor de D''s, que luego invoca el destructor de B con una referencia en el VTT de D''s. El destructor de B configura las partes relevantes del objeto para referirse a la tabla de construcción B-in-D. Ejecuta el código de destrucción del usuario para B y devuelve el control al destructor de D''s, que finalmente invoca el destructor de A. El destructor de A cambia la variable vtable para que la parte A del objeto se refiera a la tabla V para A. Finalmente, el control regresa al destructor de D''s y se completa la destrucción del objeto. La memoria utilizada una vez por el objeto se devuelve al sistema.
Ahora, de hecho, la historia es algo más complicada. ¿Alguna vez ha visto esas especificaciones de constructor y destructor "a carga" y "sin cargo" en los mensajes de advertencia y error producidos por GCC o en los binarios producidos por GCC? Bueno, el hecho es que puede haber dos implementaciones de constructor y hasta tres implementaciones de destructor.
Un constructor "a cargo" (u objeto completo) es uno que construye bases virtuales, y un constructor "no a cargo" (o objeto base) es uno que no lo hace. Considera nuestro ejemplo anterior. Si se construye una B, su constructor necesita llamar al constructor de A para construirla. De forma similar, el constructor de C necesita construir A. Sin embargo, si B y C se construyen como parte de una construcción de una D, sus constructores no deberían construir A, porque A es una base virtual y el constructor de D''se encargará de construirla exactamente una vez para la instancia de D. Considere los casos:
Si haces una nueva A, el constructor "a cargo" de A se invoca para construir A. Cuando haces una nueva B, se invoca al constructor "a cargo" de B. Llamará al constructor "no a cargo" para A.
nueva C es similar a la nueva B.
Una nueva D invoca al constructor "a cargo" de D''s. Caminamos por este ejemplo. El constructor "a cargo" de D''llama a las versiones "no a cargo" de los constructores de A, B y C (en ese orden).
Un destructor "a carga" es el análogo de un constructor "a carga": se encarga de destruir las bases virtuales. De manera similar, se genera un destructor "no a cargo". Pero hay un tercero también. Un destructor de "eliminación de carga" es aquel que desasigna el almacenamiento y destruye el objeto. Entonces, ¿cuándo se llama a uno con preferencia al otro?
Bueno, hay dos tipos de objetos que pueden destruirse: los asignados en la pila y los asignados en el montón. Considere este código (dada nuestra jerarquía de diamantes con herencia virtual de antes):
D d; // allocates a D on the stack and constructs it D *pd = new D; // allocates a D in the heap and constructs it /* ... */ delete pd; // calls "in-charge deleting" destructor for D return; // calls "in-charge" destructor for stack-allocated D
Vemos que el operador de eliminación real no es invocado por el código que realiza la eliminación, sino por el destructor eliminador de carga para el objeto que se está eliminando. ¿Por qué hacerlo de esta manera? ¿Por qué no hacer que la persona que llama llame al destructor a cargo y luego eliminar el objeto? Entonces solo tendrías dos copias de implementaciones de destructor en lugar de tres ...
Bueno, el compilador podría hacer tal cosa, pero sería más complicado por otras razones. Considere este código (suponiendo un destructor virtual, que siempre usa, ¿no? ... ¿verdad?!?):
D *pd = new D; // allocates a D in the heap and constructs it C *pc = d; // we have a pointer-to-C that points to our heap-allocated D /* ... */ delete pc; // call destructor thunk through vtable, but what about delete?
Si no tenía una variedad de "eliminación de carga" del destructor D''s, entonces la operación de eliminación necesitaría ajustar el puntero al igual que lo hace el procesador destructor. Recuerde, el objeto C está incrustado en una D, por lo que nuestro puntero a C anterior se ajusta para apuntar al centro de nuestro objeto D. No podemos simplemente eliminar este puntero, ya que no es el puntero que fue devuelto por
malloc()
cuando lo construimos.Por lo tanto, si no tuviéramos un destructor eliminador de carga, tendríamos que tener acceso directo al operador de eliminación (y representarlo en nuestros tablas virtuales), o algo similar.
Thunks, virtuales y no virtuales
Esta sección aún no está escrita.
Herencia múltiple con métodos virtuales en un lado
Bueno. Un último ejercicio. ¿Qué ocurre si tenemos una jerarquía de herencia de diamantes con herencia virtual, como antes, pero solo tenemos métodos virtuales en un lado? Asi que:
class A { public: int a; }; class B : public virtual A { public: int b; virtual void w(); }; class C : public virtual A { public: int c; }; class D : public B, public C { public: int d; virtual void y(); };
En este caso, el diseño del objeto es el siguiente:
+-----------------------+ | 20 (vbase_offset) | +-----------------------+ | 0 (top_offset) | +-----------------------+ | ptr to typeinfo for D | +----------> +-----------------------+ d --> +----------+ | | B::w() | | vtable |----+ +-----------------------+ +----------+ | D::y() | | b | +-----------------------+ +----------+ | 12 (vbase_offset) | | vtable |---------+ +-----------------------+ +----------+ | | -8 (top_offset) | | c | | +-----------------------+ +----------+ | | ptr to typeinfo for D | | d | +-----> +-----------------------+ +----------+ | a | +----------+
Para que pueda ver el subobjeto C, que no tiene métodos virtuales, todavía tiene un vtable (aunque vacío). De hecho, todas las instancias de C tienen un vtable vacío.
Gracias, Morgan Deters !!