c++ - ¿Comprendiendo vptr en herencia múltiple?
multiple-inheritance vtable (5)
No pude ver ninguna razón por la cual hay un requisito de memoria separada en cada clase para vptr
En tiempo de ejecución, cuando invoca un método (virtual) a través de un puntero, la CPU no tiene conocimiento sobre el objeto real en el que se envía el método. Si tienes B* b = ...; b->some_method();
B* b = ...; b->some_method();
luego, la variable b puede apuntar a un objeto creado a través de la new B()
o la new D()
o incluso la new E()
donde E
es otra clase que hereda de ( B
) o D
Cada una de estas clases puede proporcionar su propia implementación (anulación) para some_method()
. Por lo tanto, la llamada b->some_method()
debe enviar la implementación desde B
, D
o E
dependiendo del objeto al que b apunta.
El vptr de un objeto permite que la CPU encuentre la dirección de la implementación de algún método que está vigente para ese objeto. Cada clase define su propio vtbl (que contiene direcciones de todos los métodos virtuales) y cada objeto de la clase comienza con un vptr que apunta a ese vtbl.
Estoy tratando de darle sentido a la declaración en el libro c ++. A continuación se muestra el diagrama de herencia para herencia múltiple.
Ahora el libro dice que se requiere memoria separada en cada clase para vptr. También hace la siguiente declaración.
Una rareza en el diagrama anterior es que solo hay tres vptrs aunque hay cuatro clases involucradas. Las implementaciones son libres de generar cuatro vptrs si lo desean, pero tres son suficientes (resulta que B y D pueden compartir un vptr), y la mayoría de las implementaciones aprovechan esta oportunidad para reducir la sobrecarga generada por el compilador.
No pude ver ninguna razón por la cual hay un requisito de memoria separada en cada clase para vptr. Comprendí que vptr se hereda de la clase base cualquiera que sea el tipo de herencia. Si asumimos que muestra la estructura de memoria resultante con vptr heredado, ¿cómo pueden hacer la declaración de que
B y D pueden compartir un vptr
¿Alguien puede aclarar un poco sobre vptr en herencia múltiple?
- ¿Necesitamos vptr por separado en cada clase?
- Además, si lo anterior es cierto, ¿por qué B y D pueden compartir vptr?
Creo que D necesita 2 o 3 vptrs.
Aquí A puede o no puede requerir un vptr. B necesita uno que no deba compartirse con A (porque A se hereda virtualmente). C necesita uno que no deba compartirse con A (ídem). D puede usar vftable de B o C para sus nuevas funciones virtuales (si las hay), por lo que puede compartir B o C.
Mi documento anterior "C ++: Under the Hood" explica la implementación de Microsoft C ++ de las clases base virtuales. http://www.openrce.org/articles/files/jangrayhood.pdf
Y (MS C ++) puede compilar con cl / d1reportAllClassLayout para obtener un informe de texto de los diseños de memoria de clase.
¡Feliz piratería!
Si una clase tiene miembros virtuales, hay que encontrar la forma de encontrar su dirección. Esos se recopilan en una tabla constante (la vtbl) cuya dirección se almacena en un campo oculto para cada objeto (vptr). Una llamada a un miembro virtual es esencialmente:
obj->_vptr[member_idx](obj, params...);
Una clase derivada que agrega miembros virtuales a su clase base también necesita un lugar para ellos. Así, un nuevo vtbl y un nuevo vptr para ellos. Una llamada a un miembro virtual heredado sigue siendo
obj->_vptr[member_idx](obj, params...);
y una llamada a un nuevo miembro virtual es:
obj->_vptr2[member_idx](obj, params...);
Si la base no es virtual, se puede hacer que el segundo vtbl se coloque inmediatamente después del primero, aumentando efectivamente el tamaño del vtbl. Y el _vptr2 ya no es necesario. Una llamada a un nuevo miembro virtual es así:
obj->_vptr[member_idx+num_inherited_members](obj, params...);
En el caso de herencia múltiple (no virtual), uno hereda dos vtbl y dos vptr. No pueden fusionarse, y las llamadas deben prestar atención para agregar un desplazamiento al objeto (para que los miembros de los datos heredados se encuentren en el lugar correcto). Las llamadas a los primeros miembros de la clase base serán
obj->_vptr_base1[member_idx](obj, params...);
y para el segundo
obj->_vptr_base2[member_idx](obj+offset, params...);
Los nuevos miembros virtuales pueden volver a incluirse en un nuevo vtbl, o agregarse al vtbl de la primera base (para que no se agreguen compensaciones en futuras llamadas).
Si una base es virtual, uno no puede agregar el nuevo vtbl al heredado ya que podría generar conflictos (en el ejemplo que dio, si B y C agregan sus funciones virtuales, ¿cómo puede D construir su versión?) .
Por lo tanto, A necesita un vtbl. B y C necesitan un vtbl y no se puede agregar a uno de A porque A es una base virtual de ambos. D necesita un vtbl, pero se puede agregar a B one, ya que B no es una clase base virtual de D.
Su pregunta es interesante, sin embargo, me temo que está apuntando demasiado grande como primera pregunta, así que responderé en varios pasos, si no le importa :)
Descargo de responsabilidad: no soy un compilador y, aunque ciertamente he estudiado el tema, mi palabra debe tomarse con cautela. Ahí van mis inexactitudes. Y no estoy muy versado en RTTI. Además, como esto no es estándar, lo que describo son posibilidades.
1. ¿Cómo implementar la herencia?
Nota: Dejaré de lado los problemas de alineación, solo significan que se podría incluir algún relleno entre los bloques
Dejemos de lado los métodos virtuales, por ahora, y concentrémonos en cómo se implementa la herencia, a continuación.
La verdad es que la herencia y la composición comparten mucho:
struct B { int t; int u; };
struct C { B b; int v; int w; };
struct D: B { int v; int w; };
Se van a ver como
B:
+-----+-----+
| t | u |
+-----+-----+
C:
+-----+-----+-----+-----+
| B | v | w |
+-----+-----+-----+-----+
D:
+-----+-----+-----+-----+
| B | v | w |
+-----+-----+-----+-----+
Impactante no es :)?
Esto significa, sin embargo, que la herencia múltiple es bastante simple de averiguar:
struct A { int r; int s; };
struct M: A, B { int v; int w; };
M:
+-----+-----+-----+-----+-----+-----+
| A | B | v | w |
+-----+-----+-----+-----+-----+-----+
Usando estos diagramas, veamos qué sucede cuando se lanza un puntero derivado a un puntero base:
M* pm = new M();
A* pa = pm; // points to the A subpart of M
B* pb = pm; // points to the B subpart of M
Usando nuestro diagrama anterior:
M:
+-----+-----+-----+-----+-----+-----+
| A | B | v | w |
+-----+-----+-----+-----+-----+-----+
^ ^
pm pb
pa
El hecho de que la dirección de pb
sea ligeramente diferente de la de pm
se maneja mediante la aritmética de punteros automáticamente por el compilador.
2. ¿Cómo implementar la herencia virtual?
La herencia virtual es complicada: debe asegurarse de que todos los demás subobjetos compartirán un solo objeto V
(para virtual). Vamos a definir una simple herencia de diamante.
struct V { int t; };
struct B: virtual V { int u; };
struct C: virtual V { int v; };
struct D: B, C { int w; };
Dejaré de lado la representación y me concentraré en garantizar que, en un objeto D
, las subpartes B
y C
compartan el mismo subobjeto. Cómo puede hacerse esto ?
- Recuerda que un tamaño de clase debe ser constante.
- Recuerde que cuando se diseñan, ni B ni C pueden prever si se usarán juntos o no
Por lo tanto, la solución encontrada es simple: B
y C
solo reservan espacio para un puntero a V
, y:
- Si construye una
B
independiente, el constructor asignará unaV
en el montón, que se manejará automáticamente - si compila
B
como parte de unaD
, la subparteB
esperará que el constructorD
pase el puntero a la ubicación deV
Y idem para C
, obviamente.
En D
, una optimización le permite al constructor reservar espacio para V
directamente en el objeto, porque D
no hereda virtualmente de B
ni de C
, lo que proporciona el diagrama que se muestra (aunque todavía no tenemos métodos virtuales).
B: (and C is similar)
+-----+-----+
| V* | u |
+-----+-----+
D:
+-----+-----+-----+-----+-----+-----+
| B | C | w | A |
+-----+-----+-----+-----+-----+-----+
Observe que la conversión de B
a A
es un poco más complicada que la simple aritmética de punteros: necesita seguir el puntero en B
en lugar de la simple aritmética de punteros.
Hay un caso peor, sin embargo, up-casting. Si te doy un puntero a A
, ¿cómo sabes cómo volver a B
?
En este caso, la magia se realiza mediante dynamic_cast
, pero esto requiere algún soporte (es decir, información) almacenado en algún lugar. Este es el llamado RTTI
(Información de tipo de tiempo de ejecución). dynamic_cast
primero determinará que A
es parte de una D
través de un poco de magia, luego consulta la información de tiempo de ejecución de D
para saber dónde está almacenado el subobjeto B
dentro de D
Si estuviéramos en el caso de que no haya un subobjeto B
, devolvería 0 (forma de puntero) o lanzaría una excepción bad_cast
(forma de referencia).
3. ¿Cómo implementar métodos virtuales?
En general, los métodos virtuales se implementan a través de una tabla v (es decir, una tabla de puntero a funciones) por clase y v-ptr a esta tabla por objeto. Esta no es la única implementación posible, y se ha demostrado que otros podrían ser más rápidos, sin embargo, es simple y con una sobrecarga predecible (tanto en términos de memoria como de velocidad de envío).
Si tomamos un objeto de clase base simple, con un método virtual:
struct B { virtual foo(); };
Para la computadora, no hay elementos como los métodos de miembro, así que de hecho tienes:
struct B { VTable* vptr; };
void Bfoo(B* b);
struct BVTable { RTTI* rtti; void (*foo)(B*); };
Cuando se deriva de B
:
struct D: B { virtual foo(); virtual bar(); };
Ahora tiene dos métodos virtuales, uno anula B::foo
, el otro es completamente nuevo. La representación de la computadora es similar a:
struct D { VTable* vptr; }; // single table, even for two methods
void Dfoo(D* d); void Dbar(D* d);
struct DVTable { RTTI* rtti; void (*foo)(D*); void (*foo)(B*); };
¿Observa cómo BVTable
y DVTable
son tan similares (ya que pusimos foo
antes de la bar
)? ¡Es importante!
D* d = /**/;
B* b = d; // noop, no needfor arithmetic
b->foo();
Traducamos la llamada a foo
en lenguaje de máquina (algo):
// 1. get the vptr
void* vptr = b; // noop, it''s stored at the first byte of B
// 2. get the pointer to foo function
void (*foo)(B*) = vptr[1]; // 0 is for RTTI
// 3. apply foo
(*foo)(b);
Esos vptrs son inicializados por los constructores de los objetos, al ejecutar el constructor de D
, esto es lo que sucedió:
-
D::D()
llama aB::B()
ante todo para iniciar su subpartida -
B::B()
inicializavptr
para que apunte a su vtable, luego regresa -
D::D()
inicializavptr
para que apunte a su vtable, anulando las B
Por lo tanto, vptr
aquí señaló vtable de D, y por lo tanto el foo
aplicado fue D''s. Para B
era completamente transparente.
Aquí B y D comparten el mismo vptr!
4. Tablas virtuales en multi-herencia.
Lamentablemente este intercambio no siempre es posible.
Primero, como hemos visto, en el caso de la herencia virtual, el elemento "compartido" se posiciona de forma extraña en el objeto completo final. Por lo tanto, tiene su propio vptr. Eso es 1 .
Segundo, en caso de herencia múltiple, la primera base se alinea con el objeto completo, pero la segunda base no puede ser (ambos necesitan espacio para sus datos), por lo tanto no puede compartir su vptr. Eso es 2
En tercer lugar, la primera base está alineada con el objeto completo, por lo que nos ofrece el mismo diseño que en el caso de la herencia simple (la misma oportunidad de optimización). Eso es 3
Bastante simple, ¿no?
Todo tiene que ver con cómo el compilador determina las direcciones reales de las funciones de los métodos. El compilador asume que el puntero de la tabla virtual se encuentra en un desplazamiento conocido de la base del objeto (normalmente en el desplazamiento 0). El compilador también necesita conocer la estructura de la tabla virtual para cada clase; en otras palabras, cómo buscar punteros a funciones en la tabla virtual.
La clase B y la clase C tendrán estructuras completamente diferentes de tablas virtuales ya que tienen métodos diferentes. La tabla virtual para la clase D puede parecerse a una tabla virtual para la clase B seguida de datos adicionales para los métodos de la clase C.
Cuando genera un objeto de la clase D, puede convertirlo como un puntero a B o como un puntero a C o incluso como un puntero a la clase A. Puede pasar estos punteros a los módulos que ni siquiera son conscientes de la existencia de la clase D , pero puede llamar a los métodos de clase B o C o A. Estos módulos deben saber cómo ubicar el puntero a la tabla virtual de la clase y deben saber cómo ubicar los punteros a los métodos de clase B / C / A en el mesa virtual Es por eso que necesita tener VPTR separados para cada clase.
La clase D es consciente de la existencia de la clase B y la estructura de su tabla virtual y, por lo tanto, puede extender su estructura y reutilizar el VPTR del objeto B.
Cuando lanza un puntero al objeto D a un puntero al objeto B o C o A, en realidad actualizará el puntero con algún desplazamiento, de modo que comience desde vptr correspondiente a esa clase base específica.