¿Qué es una mesa de mesa virtual?

Una vez en Slack, me encontré con un nuevo acrónimo para mi glosario de acrónimos C ++ : "VTT". Godbolt :

test.o: In function `MyClass': test.cc:3: undefined reference to `VTT for MyClass' 

"VTT" en este contexto significa "tabla de tabla virtual". Esta es una estructura de datos auxiliar utilizada (en Itanium C ++ ABI) al crear algunas clases base que se heredan de las clases base virtuales. Los VTT siguen las mismas reglas de diseño que las tablas virtuales (vtable) y escriben información (typeinfo), por lo que si obtiene el error anterior, puede sustituir mentalmente "vtable" por "VTT" y comenzar la depuración. (Lo más probable es que haya dejado la función clave de la clase sin definir). Para ver por qué es necesario VTT, o una estructura similar, comencemos con lo básico.

Orden de diseño para herencia no virtual


Cuando tenemos una jerarquía de herencia, las clases base se construyen a partir de las más básicas . Para construir Charlie, primero debemos construir sus clases para padres MrsBucket y MrBucket, recursivamente, para construir MrBucket, primero debemos construir sus clases para padres GrandmaJosephine y GrandpaJoe.

Así:

 struct A {}; struct B : A {}; struct C {}; struct D : C {}; struct E : B, D {}; //     // ABCDE 

Orden de diseño para clases base virtuales


¡Pero la herencia virtual confunde todas las cartas! Con la herencia virtual, podemos tener una jerarquía en forma de diamante en la que dos clases principales diferentes pueden compartir un ancestro común.

 struct G {}; struct M : virtual G {}; struct F : virtual G {}; struct E : M, F {}; //     // GMFE 

En la última sección, cada constructor fue responsable de llamar al constructor de su clase base. Pero ahora tenemos herencia virtual, y los constructores M y F deben saber de alguna manera que no es necesario construir G, porque es común. Si M y F fueran responsables de construir los objetos base en este caso, el objeto base común se construiría dos veces, lo que no es muy bueno.

Para trabajar con subobjetos de herencia virtual, Itanium C ++ ABI divide cada constructor en dos partes: el constructor de objetos base y el constructor de objetos completos. El constructor del objeto base es responsable de construir todos los subobjetos de herencia no virtuales (y sus subobjetos, e instalar su vptr en su vtable, y ejecutar el código entre llaves en código C ++). El constructor del objeto completo, que se llama cada vez que crea el objeto C ++ completo, es responsable de construir todos los subobjetos de la herencia virtual del objeto derivado y luego hace el resto.

Considere la diferencia entre nuestro ejemplo ABCDE de la sección anterior y el siguiente ejemplo:

 struct A {}; struct B : virtual A {}; struct C {}; struct D : virtual C {}; struct E : B, D {}; //     // ACBDE 

El constructor del objeto completo E primero llama a los constructores del objeto base de los subobjetos virtuales A y C; entonces se llama a los constructores del objeto de herencia no virtual base B y D. B y D ya no son responsables de construir A y C, respectivamente.

Diseñando tablas vtable


Supongamos que tenemos una clase con algunos métodos virtuales, por ejemplo, ( Godbolt ):

 struct Cat { Cat() { poke(); } virtual void poke() { puts("meow"); } }; struct Lion : Cat { std::string roar = "roar"; Lion() { poke(); } void poke() override { roar += '!'; puts(roar.c_str()); } }; 

Cuando construimos Lion, comenzamos construyendo el subobjeto básico de Cat. El constructor de Cat llama poke (). En este punto, solo tenemos un objeto Cat: aún no hemos inicializado los datos de miembros que se necesitan para hacer el objeto Lion. Si el constructor Cat llama a Lion :: poke (), puede intentar cambiar el miembro no inicializado de std :: string roar y obtenemos UB. Entonces, el estándar C ++ nos obliga a hacer esto en el constructor Cat, ¡una llamada al método virtual poke () debería llamar a Cat :: poke (), no a Lion :: poke ()!

No hay problema El compilador simplemente hace que Cat :: Cat () (tanto la versión para el objeto base como la versión para el objeto completo) comience configurando el vptr del objeto en la vtable del objeto Cat. Lion :: Lion () llamará a Cat :: Cat (), y luego restablecerá vptr al puntero a la tabla vtable para el objeto Cat dentro de Lion, antes de ejecutar el código entre paréntesis. No hay problema!

Compensación de herencia virtual


Deje que Cat virtualmente herede de Animal. Luego, la tabla vtable para Cat almacena no solo los punteros de función para las funciones de miembro virtual de Cat, sino también el desplazamiento del subobjeto Animal dentro de Cat. ( Godbolt .)

 struct Animal { const char *data = "hi"; }; struct Cat : virtual Animal { Cat() { puts(data); } }; struct Nermal : Cat {}; struct Garfield : Cat { int padding; }; 

El constructor Cat consulta al miembro Animal :: data. Si este objeto Cat es el subobjeto base del objeto Nermal, entonces sus datos de miembro están en el desplazamiento 8, justo detrás de vptr. Pero si el objeto Cat es el subobjeto subyacente del objeto Garfield, entonces los datos del miembro están en el desplazamiento 16, detrás de vptr y Garfield :: padding. Para hacer frente, Itanium ABI almacena los desplazamientos de los objetos de base virtuales en la tabla virtual del objeto Cat. La tabla para Cat-in-Nermal conserva el hecho de que Animal, el subobjeto de Cat subyacente, se almacena en el desplazamiento 8; La tabla para Cat-in-Garfield conserva el hecho de que Animal, el subobjeto de Cat subyacente, se almacena en el desplazamiento 16.

Ahora combine esto con la sección anterior. El compilador debe verificar que Cat :: Cat () (tanto la versión del objeto base como la versión del objeto completo) comience instalando vptr en la vtable para Cat-in-Nermal o en la vtable para Cat-in-Garfield, según el tipo ¡la más derivada facilidad! ¿Pero cómo funciona?

El constructor del objeto completo para el objeto más derivado debe precalcular a qué tabla vtable desea que haga referencia el vptr del subobjeto base durante el tiempo de construcción del objeto, y luego el constructor del objeto completo para el objeto más derivado debe pasar esta información al constructor del objeto base del subobjeto base como un parámetro oculto! Veamos el código generado para Cat :: Cat () ( Godbolt ):

 _ZN3CatC1Ev: #    Cat movq $_ZTV3Cat+24, (%rdi) # this->vptr = &vtable-for-Cat; retq _ZN3CatC2Ev: #     Cat movq (%rsi), %rax # fetch a value from rsi movq %rax, (%rdi) # this->vptr = *rsi; retq 

¡El constructor del objeto base acepta no solo este parámetro oculto en% rdi, sino también el parámetro VTT oculto en% rsi! El constructor del objeto base carga la dirección desde (% rsi) y almacena la dirección en la tabla v del objeto Cat.

Quien llame al constructor del objeto base Cat es responsable de predecir qué dirección Cat :: Cat () debe escribirse en vptr y de establecer el puntero en (% rsi) en esa dirección.

¿Por qué necesitamos otro nivel de identidad?


Considere el constructor de un objeto Nermal completo.

 _ZN3CatC2Ev: #    Cat movq (%rsi), %rax #    rsi movq %rax, (%rdi) # this->vptr = *rsi; retq _ZN6NermalC1Ev: #    Nermal pushq %rbx movq %rdi, %rbx movl $_ZTT6Nermal+8, %esi # %rsi = &VTT-for-Nermal callq _ZN3CatC2Ev #     Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq _ZTT6Nermal: .quad _ZTV6Nermal+24 # vtable-for-Nermal .quad _ZTC6Nermal0_3Cat+24 # construction-vtable-for-Cat-in-Nermal 

¿Por qué se encuentra _ZTC6Nermal0_3Cat + 24 en la sección de datos y su dirección se pasa a% rsi, en lugar de simplemente pasar directamente a _ZTC6Nermal0_3Cat + 24?

 #   ? _ZN3CatC2Ev: #     Cat movq %rsi, (%rdi) # this->vptr = rsi; retq _ZN6NermalC1Ev: #     Nermal pushq %rbx movq %rdi, %rbx movl $_ZTC6Nermal0_3Cat+24, %esi # %rsi = &construction-vtable-for-Cat-in-Nermal callq _ZN3CatC2Ev #     Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq 

¡Esto se debe a que podemos tener varios niveles de herencia! En cada nivel de herencia, el constructor del objeto base debe establecer vptr y luego, posiblemente, pasar el control más abajo en la cadena al siguiente constructor base, que puede establecer vptrs en algún otro valor. Esto implica una lista o una tabla de punteros a vtable.

Aquí hay un ejemplo concreto ( Godbolt ):

 struct VB { int member_of_vb = 42; }; struct Grandparent : virtual VB { Grandparent() {} }; struct Parent : Grandparent { Parent() {} }; struct Gretel : Parent { Gretel() : VB{1000} {} }; struct Hansel : Parent { int padding; Hansel() : VB{2000} {} }; 

El objeto constructor base Grandparent debe establecer su vptr en Grandparent, algo más, que es la clase más derivada. El constructor del objeto base Parent primero debe llamar a Grandparent :: Grandparent () con el% rsi apropiado y luego establecer vptr en Parent, algo más, que es la clase más derivada. Una forma de implementar esto para Gretel:

 Gretel::Gretel() [  ]: pushq %rbx movq %rdi, %rbx movl $1000, 8(%rdi) # imm = 0x3E8 movl $VTT for Gretel+8, %esi callq Parent::Parent() [  ] movq $vtable for Gretel+24, (%rbx) popq %rbx retq VTT for Gretel: .quad vtable for Gretel+24 .quad construction vtable for Parent-in-Gretel+24 .quad construction vtable for Grandparent-in-Gretel+24 

Puede ver en Godbolt que el constructor del objeto base de la clase Parent primero llama a Grandparent :: Grandparent () con% rsi + 8, luego establece su propio vptr en (% rsi). Entonces, aquí estamos usando el hecho de que Gretel, por así decirlo, estableció cuidadosamente un camino de migas de pan a lo largo del cual todas sus clases base siguieron durante la construcción.

El mismo VTT se usa en el destructor ( Godbolt ). Hasta donde yo sé, la fila nula de la tabla VTT nunca se usa. El constructor de Gretel carga la tabla vtable para Gretel + 24 en vptr, pero sabe que esta dirección es estática, nunca necesita cargarse desde VTT. Creo que la fila cero de la tabla se conservó simplemente por razones históricas. (Y, por supuesto, el compilador no puede simplemente tirarlo, porque será una violación de Itanium ABI y será imposible vincularlo a un código antiguo que se adhiere a Itanium-ABI).

Eso es todo, miramos una tabla de tablas virtuales, o VTT.

Más información


Puede encontrar información de VTT en estos lugares:

StackOverflow: “ ¿Cuál es el VTT para una clase? "
" Notas de VTable sobre herencia múltiple en el compilador GCC C ++ v4.0.1 " (Morgan Deters, 2005)
El Itanium C ++ ABI , sección "Orden VTT"

Finalmente, tengo que reiterar que VTT es una característica de Itanium C ++ ABI, y se usa en Linux, OSX, etc. El MSVC ABI utilizado en Windows no tiene VTT y utiliza un mecanismo completamente diferente para la herencia virtual. (Hasta ahora) no sé casi nada sobre MSVC ABI, pero tal vez algún día lo descubra todo y escriba una publicación al respecto.

Source: https://habr.com/ru/post/474318/


All Articles