c++ vtable dynamic-binding upcasting

c++ - ¿Cómo funcionan juntas y vtables para garantizar un enlace dinámico correcto?



dynamic-binding upcasting (9)

¿Qué entrada en vtable se refiere a la función de las clases derivadas "particulares" a las que se supone que se debe llamar en tiempo de ejecución?

Ninguna, no es una entrada en vtable, sino el puntero vtable que forma parte de cada instancia de objeto que determina cuáles son el conjunto correcto de funciones virtuales para ese objeto en particular. De esta manera, dependiendo de la vtable real a la que se apunta, invocar el "primer método virtual" desde la vtable puede resultar en la llamada de diferentes funciones para objetos de diferentes tipos en la misma jerarquía polimórfica.

Las implementaciones pueden variar, pero lo que personalmente considero lo más lógico y eficaz es que el puntero vtable sea el primer elemento en el diseño de la clase. De esta manera, puede eliminar la referencia a la dirección del objeto para determinar su tipo en función del valor del puntero que se encuentra en esa dirección, ya que todos los objetos de un tipo dado tendrán ese puntero que apunta a la misma vtable, que se crea de forma única para cada Objeto que tiene métodos virtuales, que se requiere para habilitar funciones que anulan ciertos métodos virtuales.

¿Cómo funcionan juntas y vtables para garantizar un enlace dinámico correcto?

Upcasting en sí no es estrictamente necesario, tampoco es downcasting. Recuerde que ya tiene el objeto asignado en la memoria, y ya tendrá su puntero vtable configurado en el vtable correcto para ese tipo, que es lo que lo garantiza; una conversión descendente no cambia el vtable para ese objeto, solo cambia el puntero con el que operas.

La reducción de inversión es necesaria cuando se desea acceder a una funcionalidad que no está disponible en la clase base y se declara en la clase derivada. Pero antes de intentar hacer eso, debe asegurarse de que un objeto en particular sea del mismo tipo o que herede el que declara esa funcionalidad, que es donde entra en dynamic_cast , cuando el lanzamiento dinámico del compilador genera una comprobación de esa entrada vtable y si se hereda. el tipo solicitado de otra tabla, generado en el momento de la compilación, y si es así, la conversión dinámica tiene éxito, de lo contrario falla.

El puntero por el que accede al objeto no se refiere al conjunto correcto de funciones virtuales a las que llamar, simplemente sirve como un indicador de las funciones en la tabla a las que puede referirse como desarrollador. Es por eso que es seguro realizar una conversión ascendente usando un estilo C o conversión estática, que no realiza verificaciones en tiempo de ejecución, porque entonces solo limita su indicador a las funciones disponibles en la clase base, que ya están disponibles en la clase derivada, por lo que hay No hay lugar para el error y el daño. Y es por eso que siempre debe usar una conversión dinámica o alguna otra técnica personalizada que aún se basa en el envío virtual cuando hace una bajada, porque tiene que estar seguro de que la tabla de texto asociada del objeto contiene la funcionalidad adicional que puede invocar.

De lo contrario, obtendrás un comportamiento indefinido, y el "tipo malo" significa que algo fatal ocurrirá, ya que la interpretación de datos arbitrarios como una dirección de una función de firma particular a la que se debe llamar es un gran no-no.

También tenga en cuenta que en un contexto estático, es decir, cuando se sabe en tiempo de compilación cuál es el tipo, es muy probable que el compilador no use vtable para llamar a funciones virtuales, sino que use llamadas estáticas directas o incluso ciertas funciones en línea, lo que las hará así. mucho mas rápido. En tales casos, la actualización y el uso de un puntero de clase base en lugar del objeto real solo disminuirán esa optimización.

Entonces, vtable es una tabla mantenida por el compilador que contiene punteros de función que apuntan a las funciones virtuales en esa clase.

y

Asignar un objeto de una clase derivada a un objeto de una clase ancestral se denomina conversión ascendente.

La conversión ascendente está manejando una instancia / objeto de clase derivada usando un puntero o referencia de clase base; los objetos no están "asignados a", lo que implica una sobrescritura de valor ala operator = invocation.
(Gracias a: Tony D )

Ahora, ¿cómo se conoce en tiempo de ejecución "a qué" función virtual de la clase se debe llamar?

¿Qué entrada en vtable se refiere a la función de las clases derivadas "particulares" a las que se supone que se debe llamar en tiempo de ejecución?


Polimorfismo y Despacho Dinámico (versión hiper abreviada)

Nota: No pude encajar la información suficiente sobre la herencia múltiple con bases virtuales, ya que no tiene nada de simple, y los detalles podrían saturar la exposición (más adelante). Esta respuesta demuestra los mecanismos utilizados para implementar el envío dinámico asumiendo solo una herencia individual.

La interpretación de los tipos abstractos y sus comportamientos visibles a través de los límites de los módulos requiere una Interfaz Binaria de Aplicación (ABI) común. El estándar C ++, por supuesto, no requiere la implementación de ninguna ABI en particular.

Un ABI describiría:

  • El diseño de tablas de despacho de métodos virtuales (vtables)
  • Los metadatos necesarios para las comprobaciones de tipo de tiempo de ejecución y las operaciones de conversión
  • Nombre de decoración (también conocido como mangling), convenciones de llamadas y muchas otras cosas.

Se supone que ambos módulos en el siguiente ejemplo, external.so y main.o , se han vinculado al mismo tiempo de ejecución. El enlace estático y dinámico da preferencia a los símbolos ubicados dentro del módulo de llamada.

Una biblioteca externa

external.h (distribuido a los usuarios):

class Base { __vfptr_t __vfptr; // For exposition public: __attribute__((dllimport)) virtual int Helpful(); __attribute__((dllimport)) virtual ~Base(); }; class Derived : public Base { public: __attribute__((dllimport)) virtual int Helpful() override; ~Derived() { // Visible destructor logic here. // Note: This is in the header! // __vft@Base gets treated like any other imported symbol: // The address is resolved at load time. // this->__vfptr = &__vft@Base; static_cast<Base *>(this)->~Base(); } }; __attribute__((dllimport)) Derived *ReticulateSplines();

external.cpp:

#include "external.h" // the version in which the attributes are dllexport __attribute__((dllexport)) int Base::Helpful() { return 47; } __attribute__((dllexport)) Base::~Base() { } __attribute__((dllexport)) int Derived::Helpful() { return 4449; } __attribute__((dllexport)) Derived *ReticulateSplines() { return new Derived(); // __vfptr = &__vft@Derived in external.so }

external.so (no es un diseño binario real):

__vft@Base: [offset to __type_info@Base] <-- in external.so [offset to Base::~Base] <------- in external.so [offset to Base::Helpful] <----- in external.so __vft@Derived: [offset to __type_info@Derived] <-- in external.so [offset to Derived::~Derived] <---- in external.so [offset to Derived::Helpful] <----- in external.so Etc... __type_info@Base: [null base offset field] [offset to mangled name] __type_info@Derived: [offset to __type_info@Base] [offset to mangled name] Etc...

Una aplicación que utiliza la biblioteca externa.

special.hpp:

#include <iostream> #include "external.h" class Special : public Base { public: int Helpful() override { return 55; } virtual void NotHelpful() { throw std::exception{"derp"}; } }; class MoreDerived : public Derived { public: int Helpful() override { return 21; } ~MoreDerived() { // Visible destructor logic here this->__vfptr = &__vft@Derived; // <- the version in main.o static_cast<Derived *>(this)->~Derived(); } }; class Related : public Base { public: virtual void AlsoHelpful() = 0; }; class RelatedImpl : public Related { public: void AlsoHelpful() override { using namespace std; cout << "The time for action... Is now!" << endl; } };

main.cpp:

#include "special.hpp" int main(int argc, char **argv) { Base *ptr = new Base(); // ptr->__vfptr = &__vft@Base (in external.so) auto r = ptr->Helpful(); // calls "Base::Helpful" in external.so // r = 47 delete ptr; // calls "Base::~Base" in external.so ptr = new Derived(); // ptr->__vfptr = &__vft@Derived (in main.o) r = ptr->Helpful(); // calls "Derived::Helpful" in external.so // r = 4449 delete ptr; // calls "Derived::~Derived" in main.o ptr = ReticulateSplines(); // ptr->__vfptr = &__vft@Derived (in external.so) r = ptr->Helpful(); // calls "Derived::Helpful" in external.so // r = 4449 delete ptr; // calls "Derived::~Derived" in external.so ptr = new Special(); // ptr->__vfptr = &__vft@Special (in main.o) r = ptr->Helpful(); // calls "Special::Helpful" in main.o // r = 55 delete ptr; // calls "Base::~Base" in external.so ptr = new MoreDerived(); // ptr->__vfptr = & __vft@MoreDerived (in main.o) r = ptr->Helpful(); // calls "MoreDerived::Helpful" in main.o // r = 21 delete ptr; // calls "MoreDerived::~MoreDerived" in main.o return 0; }

main.o:

__vft@Derived: [offset to __type_info@Derivd] <-- in main.o [offset to Derived::~Derived] <--- in main.o [offset to Derived::Helpful] <---- stub that jumps to import table __vft@Special: [offset to __type_info@Special] <-- in main.o [offset to Base::~Base] <---------- stub that jumps to import table [offset to Special::Helpful] <----- in main.o [offset to Special::NotHelpful] <-- in main.o __vft@MoreDerived: [offset to __type_info@MoreDerived] <---- in main.o [offset to MoreDerived::~MoreDerived] <-- in main.o [offset to MoreDerived::Helpful] <------- in main.o __vft@Related: [offset to __type_info@Related] <------ in main.o [offset to Base::~Base] <-------------- stub that jumps to import table [offset to Base::Helpful] <------------ stub that jumps to import table [offset to Related::AlsoHelpful] <----- stub that throws PV exception __vft@RelatedImpl: [offset to __type_info@RelatedImpl] <--- in main.o [offset to Base::~Base] <--------------- stub that jumps to import table [offset to Base::Helpful] <------------- stub that jumps to import table [offset to RelatedImpl::AlsoHelpful] <-- in main.o Etc... __type_info@Base: [null base offset field] [offset to mangled name] __type_info@Derived: [offset to __type_info@Base] [offset to mangled name] __type_info@Special: [offset to __type_info@Base] [offset to mangled name] __type_info@MoreDerived: [offset to __type_info@Derived] [offset to mangled name] __type_info@Related: [offset to __type_info@Base] [offset to mangled name] __type_info@RelatedImpl: [offset to __type_info@Related] [offset to mangled name] Etc...

¡La invocación es (o no puede ser) mágica!

Dependiendo del método y de lo que se pueda probar en el lado de enlace, una llamada de método virtual puede estar vinculada de forma estática o dinámica.

Una llamada de método virtual dinámico leerá la dirección de la función de destino del vtable al que apunta un miembro __vfptr .

El ABI describe cómo se ordenan las funciones en vtables. Por ejemplo: pueden ordenarse por clase, luego lexicográficamente por nombre mutilado (que incluye información sobre constancia, parámetros, etc.). Para la herencia única, este enfoque garantiza que el índice de envío virtual de una función siempre será el mismo, independientemente de cuántas implementaciones distintas haya.

En los ejemplos que se dan aquí, los destructores se colocan al comienzo de cada vtable, si corresponde. Si el destructor es trivial y no virtual (no definido o no hace nada), el compilador puede ignorarlo por completo y no asignarle una entrada vtable.

Base *ptr = new Special{}; MoreDerived *md_ptr = new MoreDerived{}; // The cast below is checked statically, which would // be a problem if "ptr" weren''t pointing to a Special. // Special *sptr = static_cast<Special *>(ptr); // In this case, it is possible to // prove that "ptr" could point only to // a Special, binding statically. // ptr->Helpful(); // Due to the cast above, a compiler might not // care to prove that the pointed-to type // cannot be anything but a Special. // // The call below might proceed as follows: // // reg = sptr->__vptr[__index_of@Base::Helpful] = &Special::Helpful in main.o // // push sptr // call reg // pop // // This will indirectly call Special::Helpful. // sptr->Helpful(); // No cast required: LSP is satisfied. ptr = md_ptr; // Once again: // // reg = ptr->__vfptr[__index_of@Base::Helpful] = &MoreDerived::Helpful in main.o // // push ptr // call reg // pop // // This will indirectly call MoreDerived::Helpful // ptr->Helpful();

La lógica anterior es la misma para cualquier sitio de invocación que requiera enlace dinámico. En el ejemplo anterior, no importa exactamente a qué tipo ptr o sptr apuntan; el código solo cargará un puntero en un desplazamiento conocido y luego lo llamará a ciegas.

Tipo de fundición: Subidas y bajadas

Toda la información sobre una jerarquía de tipos debe estar disponible para el compilador al traducir una expresión de llamada de función o de conversión. Simbólicamente, el lanzamiento es solo una cuestión de atravesar un gráfico dirigido.

La conversión en este simple ABI puede realizarse completamente en tiempo de compilación. El compilador solo necesita examinar la jerarquía de tipos para determinar si los tipos de origen y destino están relacionados (hay una ruta desde el origen al destino en el gráfico de tipo). Por el principio de sustitución , un puntero a un MoreDerived también apunta a una Base y puede interpretarse como tal. El miembro __vfptr está en el mismo desplazamiento para todos los tipos en esta jerarquía, por lo que la lógica RTTI no necesita manejar ningún caso especial (en ciertas implementaciones de VMI, tendría que tomar otro desplazamiento de un tipo de procesador para capturar otro vptr y pronto...).

Castigar, sin embargo, es diferente. Dado que la conversión de un tipo base a un tipo derivado implica determinar si el objeto apuntado tiene un diseño binario compatible, es necesario realizar una verificación de tipo explícita (conceptualmente, esto es "probar" que la información adicional existe más allá del final de La estructura asumida en tiempo de compilación).

Tenga en cuenta que hay varias instancias de vtable para el tipo Derived : una en external.so y otra en main.o Esto se debe a que un método virtual definido para Derived (su destructor) aparece en cada unidad de traducción que incluye external.h .

Aunque la lógica es idéntica en ambos casos, ambas imágenes en este ejemplo deben tener su propia copia. Esta es la razón por la que la comprobación de tipos no se puede realizar utilizando solo las direcciones.

A continuación, se realiza una conversión descendente recorriendo un gráfico de tipo (copiado en ambas imágenes) a partir del tipo de fuente descodificado en el tiempo de ejecución, comparando nombres mutilados hasta que el objetivo de tiempo de compilación coincida.

Por ejemplo:

Base *ptr = new MoreDerived(); // ptr->__vfptr = &__vft::MoreDerived in main.o // // This provides the code below with a starting point // for dynamic cast graph traversals. // All searches start with the type graph in the current image, // then all other linked images, and so on... // This example is not exhaustive! // Starts by grabbing &__type_info@MoreDerived // using the offset within __vft@MoreDerived resolved // at load time. // // This is similar to a virtual method call: Just grab // a pointer from a known offset within the table. // // Search path: // __type_info@MoreDerived (match!) // auto *md_ptr = dynamic_cast<MoreDerived *>(ptr); // Search path: // __type_info@MoreDerived -> // __type_info@Derived (match!) // auto *d_ptr = dynamic_cast<Derived *>(ptr); // Search path: // __type_info@MoreDerived -> // __type_info@Derived -> // __type_info@Base (no match) // // Did not find a path connecting RelatedImpl to MoreDerived. // // rptr will be nullptr // auto *rptr = dynamic_cast<RelatedImpl *>(ptr);

En ningún punto del código anterior, ptr->__vfptr que cambiar. La naturaleza estática de la deducción de tipo en C ++ requiere que la implementación satisfaga el principio de sustitución en tiempo de compilación, lo que significa que el tipo real de un objeto no puede cambiar en tiempo de ejecución.

Resumen

He entendido que esta pregunta es sobre los mecanismos detrás del envío dinámico.

Para mí, "¿Qué entrada en vtable se refiere a la función de las clases derivadas" particulares "a las que se supone que se debe llamar en tiempo de ejecución?" , está preguntando cómo funciona un vtable.

El objetivo de esta respuesta es demostrar que la conversión de tipos afecta solo a la vista de los datos de un objeto, y que la implementación del envío dinámico en estos ejemplos opera independientemente de ellos. Sin embargo, la conversión de tipos afecta el envío dinámico en el caso de la herencia múltiple, donde determinar qué vtable utilizar requiere varios pasos (una instancia de un tipo con múltiples bases puede tener múltiples vptrs).


Creo que esto se explica mejor al implementar el polimorfismo en C. Dadas estas dos clases de C ++:

class Foo { virtual void foo(int); }; class Bar : public Foo { virtual void foo(int); virtual void bar(double); };

Las definiciones de la estructura C (es decir, el archivo de encabezado) se verían así:

//For class Foo typedef struct Foo_vtable { void (*foo)(int); } Foo_vtable; typedef struct Foo { Foo_vtable* vtable; } Foo; //For class Bar typedef struct Bar_vtable { Foo_vtable super; void (*bar)(double); } typedef struct Bar { Foo super; } Bar;

Como ve, hay dos definiciones de estructura para cada clase, una para vtable y otra para la clase en sí. Tenga en cuenta también que ambas estructuras para class Barincluyen un objeto de clase base como su primer miembro que nos permite upcasting: tanto (Foo*)myBarPointery (Foo_vtable*)myBar_vtablePointerson válidos. Como tal, dada una Foo*, es seguro encontrar la ubicación del foo()miembro haciendo

Foo* basePointer = ...; (basePointer->vtable->foo)(7);

Ahora, veamos cómo podemos realmente llenar los vtables. Para eso escribimos algunos constructores que usan instancias de vtable definidas estáticamente, así es como podría verse el archivo foo.c

#include "..." static void foo(int) { printf("Foo::foo() called/n"); } Foo_vtable vtable = { .foo = &foo, }; void Foo_construct(Foo* me) { me->vtable = vtable; }

Esto asegura que es posible ejecutar (basePointer->vtable->foo)(7)en cada objeto al que se ha pasado Foo_construct(). Ahora, el código para Bares bastante similar:

#include "..." static void foo(int) { printf("Bar::foo() called/n"); } static void bar(double) { printf("Bar::bar() called/n"); } Bar_vtable vtable = { .super = { .foo = &foo }, .bar = &bar }; void Bar_construct(Bar* me) { Foo_construct(&me->super); //construct the base class. (me->vtable->foo)(7); //This will print Foo::foo() me->vtable = vtable; (me->vtable->foo)(7); //This will print Bar::foo() }

He usado declaraciones estáticas para las funciones miembro para evitar tener que inventar un nombre nuevo para cada implementación, static void foo(int)restringe la visibilidad de la función al archivo de origen. Sin embargo, todavía se puede llamar desde otros archivos mediante el uso de un puntero de función.

El uso de estas clases podría verse así:

#include "..." int main() { //First construct two objects. Foo myFoo; Foo_construct(&myFoo); Bar myBar; Bar_construct(&myBar); //Now make some pointers. Foo* pointer1 = &myFoo, pointer2 = (Foo*)&myBar; Bar* pointer3 = &myBar; //And the calls: (pointer1->vtable->foo)(7); //prints Foo::foo() (pointer2->vtable->foo)(7); //prints Bar::foo() (pointer3->vtable->foo)(7); //prints Bar::foo() (pointer3->vtable->bar)(7.0); //prints Bar::bar() }

Una vez que sepa cómo funciona esto, sabrá cómo funciona C ++ vtables. La única diferencia es que en C ++ el compilador hace el trabajo que hice yo mismo en el código anterior.


Puede imaginar (aunque la especificación de C ++ no dice esto) que vtable es un identificador (o algún otro metadato que se puede usar para "encontrar más información" sobre la clase en sí) y una lista de funciones.

Entonces, si tenemos una clase como esta:

class Base { public: virtual void func1(); virtual void func2(int x); virtual std::string func3(); virtual ~Base(); ... some other stuff we don''t care about ... };

El compilador producirá un VTable como este:

struct VTable_Base { int identifier; void (*func1)(Base* this); void (*func2)(Base* this, int x); std::string (*func3)(Base* this); ~Base(Base *this); };

El compilador luego creará una estructura interna que, algo como esto (esto no es posible compilar como C ++, es solo para mostrar lo que realmente hace el compilador, y lo llamo Sbase para diferenciar la class Base real class Base )

struct SBase { VTable_Base* vtable; inline void func1(Base* this) { vtable->func1(this); } inline void func2(Base* this, int x) { vtable->func2(this, x); } inline std::string func3(Base* this) { return vtable->func3(this); } inline ~Base(Base* this) { vtable->~Base(this); } };

También construye el verdadero vtable:

VTable_Base vtable_base = { 1234567, &Base::func1, &Base::func2, &Base::func3, &Base::~Base };

Y en el constructor para Base , establecerá vtable = vtable_base; .

Cuando luego agregamos una clase derivada, donde anulamos una función (y por defecto, el destructor, incluso si no declaramos una):

class Derived : public Base { virtual void func2(int x) override; };

El compilador ahora hará esta estructura:

struct VTable_Derived { int identifier; void (*func1)(Base* this); void (*func2)(Base* this, int x); std::string (*func3)(Base* this); ~Base(Derived *this); };

Y luego hace el mismo edificio "estructura":

struct SDerived { VTable_Derived* vtable; inline void func1(Base* this) { vtable->func1(this); } inline void func2(Base* this, int x) { vtable->func2(this, x); } inline std::string func3(Base* this) { return vtable->func3(this); } inline ~Derived(Derived* this) { vtable->~Derived(this); } };

Necesitamos esta estructura para cuando estamos utilizando Derived directamente en lugar de a través de la clase Base .

(Confiamos en la cadena del compilador en ~Derived para llamar ~Base también, al igual que los destructores normales que heredan)

Y finalmente, construimos una vtable real:

VTable_Derived vtable_derived = { 7654339, &Base::func1, &Derived::func2, &Base::func3, &Derived::~Derived };

Y nuevamente, el constructor Derived establecerá Dervied::vtable = vtable_derived para todas las instancias.

Editar para responder a la pregunta en los comentarios: El compilador debe colocar cuidadosamente los diversos componentes tanto en VTable_Derived como en SDerived para que coincida con VTable_Base y SBase , de modo que cuando tengamos un puntero a Base , Base::vtable y Base::funcN() coinciden con Derived::vtable y Derived::FuncN . Si eso no coincide, entonces la herencia no funcionará.

Si se agregan nuevas funciones virtuales a Derived , deben colocarse después de las heredadas de la Base .

Fin de edición.

Entonces, cuando hacemos:

Base* p = new Derived; p->func2();

el código buscará SBase::Func2 , que usará el Derived::func2 correcto (porque el vtable real dentro de p->vtable es VTable_Derived (según lo establece el constructor Derived que se llama junto con el new Derived ).


Tomaré una ruta diferente de las otras respuestas e intentaré llenar solo los vacíos específicos en su conocimiento, sin entrar mucho en los detalles. Me ocuparé de la mecánica lo suficiente para ayudar a su comprensión.

Entonces, vtable es una tabla mantenida por el compilador que contiene punteros de función que apuntan a las funciones virtuales en esa clase.

La forma más precisa de decir esto es la siguiente:

Cada clase con métodos virtuales, incluida cada clase que hereda de una clase con métodos virtuales, tiene su propia tabla virtual. La tabla virtual de una clase apunta a los métodos virtuales específicos de esa clase, es decir, métodos heredados, métodos anulados o métodos recién agregados. Cada instancia de una clase de este tipo contiene un puntero a la tabla virtual que coincide con la clase.

La conversión ascendente está manejando una instancia / objeto de clase derivada usando un puntero o referencia de clase base; (...)

Quizás más esclarecedor:

La conversión ascendente significa que un puntero o una referencia a una instancia de la clase Derived se trata como si fuera un puntero o una referencia a una instancia de la clase Base . La instancia en sí, sin embargo, sigue siendo puramente una instancia de Derived .

(Cuando un puntero se "trata como un puntero a la Base ", eso significa que el compilador genera código para tratar con un puntero a la Base . En otras palabras, el compilador y el código generado no saben mejor que el que están tratando con un puntero Por lo tanto, un puntero que se "trata como" tendrá que apuntar a un objeto que ofrece al menos la misma interfaz que las instancias de Base . Este es el caso de Derived debido a la herencia. Veremos cómo esto funciona a continuación.)

En este punto podemos responder la primera versión de su pregunta.

Ahora, ¿cómo se conoce en tiempo de ejecución "a qué" función virtual de la clase se debe llamar?

Supongamos que tenemos un puntero a una instancia de Derived . Primero lo subimos, por lo que se trata como un puntero a una instancia de Base . Luego llamamos a un método virtual sobre nuestro puntero al alza. Como el compilador sabe que el método es virtual, debe buscar el puntero de la tabla virtual en la instancia. Mientras tratamos el puntero como si apuntara a una instancia de Base , el objeto real no ha cambiado de valor y el puntero de la tabla virtual dentro de él todavía apunta a la tabla virtual de Derived . Entonces, en tiempo de ejecución, la dirección del método se toma de la tabla virtual de Derived .

Ahora, el método particular puede ser heredado de la Base o puede ser anulado en Derived . No importa; si se hereda, el puntero del método en la tabla virtual de Derived simplemente contiene la misma dirección que el puntero del método correspondiente en la tabla virtual de la Base . En otras palabras, ambas tablas apuntan a la misma implementación del método para ese método en particular. Si se invalida, el puntero del método en la tabla virtual de Derived difiere del puntero del método correspondiente en la tabla virtual de Base , por lo que las búsquedas de métodos en las instancias de Derived encontrarán el método alterado mientras que las búsquedas en instancias de Base encontrarán la versión original de Método: independientemente de si un puntero a la instancia se trata como un puntero a Base o un puntero a Derived .

Finalmente, ahora debería ser sencillo explicar por qué la segunda versión de su pregunta está un poco equivocada:

¿Qué entrada en vtable se refiere a la función de las clases derivadas "particulares" a las que se supone que se debe llamar en tiempo de ejecución?

Esta pregunta presupone que las búsquedas de vtable son primero por método y luego por clase. Es al revés: primero, el puntero vtable en la instancia se usa para encontrar el vtable para la clase correcta. Luego, el vtable para esa clase se usa para encontrar el método correcto.


casting casting es un concepto asociado a variable. Así que cualquier variable puede ser casteada. Se puede lanzar hacia arriba o hacia abajo.

char charVariable = ''A''; int intVariable = charVariable; // upcasting int intVariable = 20; char charVariale = intVariable; // downcasting

para el tipo de datos definido por el sistema, Upcast o downcast se basa en su variable actual y se relaciona principalmente con la cantidad de memoria que el compilador asigna a ambas variables comparadas.

Si está asignando una variable que está asignando menos memoria que el tipo al que se está convirtiendo, se llama conversión.

Si está asignando una variable que está asignando más memoria que el tipo al que se está convirtiendo, se llama down cast. La conversión descendente crea algún problema cuando el valor que se intenta emitir no se ajusta a esa área de memoria asignada.

Upcasting en el nivel de clase Al igual que el tipo de datos definidos por el sistema, podemos tener objeto de clase base y clase derivada. Entonces, si queremos convertir el tipo derivado al tipo base, se conoce como upcasting descendente. Esto se puede lograr mediante el puntero de una clase base que apunta a un tipo de clase derivado.

class Base{ public: void display(){ cout<<"Inside Base::display()"<<endl; } }; class Derived:public Base{ public: void display(){ cout<<"Inside Derived::display()"<<endl; } }; int main(){ Base *baseTypePointer = new Derived(); // Upcasting baseTypePointer.display(); // because we have upcasted we want the out put as Derived::display() as output }

salida

Base interior :: pantalla ()

Exceptuado

Inside Derived :: display ()

En el escenario anterior, la salida no fue la excepción. Es porque no tenemos la v-table y vptr (puntero virtual) en el objeto al que el puntero base llamará Base :: display () aunque hemos asignado el tipo derivado al puntero base.

Para evitar este problema, c ++ nos da un concepto virtual. Ahora la función de visualización de la clase base debe cambiarse a un tipo virtual.

virtual void display()

El código completo es:

class Base{ public: virtual void display(){ cout<<"Inside Base::display()"<<endl; } }; class Derived:public Base{ public: void display(){ cout<<"Inside Derived::display()"<<endl; } }; int main(){ Base *baseTypePointer = new Derived(); // Upcasting baseTypePointer.display(); // because we have upcasted we want the out put as Derived::display() as output }

salida

Inside Derived :: display ()

Exceptuado

Inside Derived :: display ()

Para entender esto necesitamos entender v-table y vptr; siempre que el compilador encuentre un virtual junto con una función, generará una tabla virtual para cada una de las clases (tanto la Base como todas las clases derivadas).

Si hay una función virtual presente, cada objeto contendrá vptr (puntero virtual) que apunta a la clase respectiva vtable y vtable contendrá el puntero a la función virtual de la clase respectiva. cuando llame a la función a través de vptr, se llamará a la función virutal e invocará la función de clase respectiva y lograremos la salida requerida.


Déjame intentar explicarlo con algunos ejemplos:

class Base { public: virtual void function1() {cout<<"Base :: function1()/n";}; virtual void function2() {cout<<"Base :: function2()/n";}; virtual ~Base(){}; }; class D1: public Base { public: ~D1(){}; virtual void function1() { cout<<"D1 :: function1()/n";}; }; class D2: public Base { public: ~D2(){}; virtual void function2() { cout<< "D2 :: function2/n";}; };

Entonces, el compilador generaría tres vtables uno para cada clase, ya que estas clases tienen funciones virtuales. (Aunque es dependiente del compilador).

NOTA: - vtables solo contiene punteros a funciones virtuales. Las funciones no virtuales aún se resolverían en tiempo de compilación ...

Tienes razón al decir que vtables no son nada más que indicadores de funciones. Vtables para estas clases sería como algo:

Vtable para Base: -

&Base::function1 (); &Base::function2 (); &Base::~Base ();

Vtable para D1: -

&D1::function1 (); &Base::function2 (); &D1::~D1();

Vtable para D2: -

&Base::function1 (); &D2::function2 (); &D2::~D2 ();

vptr es un puntero que se utiliza para fines de búsqueda en esta tabla. Cada objeto de la clase polimórfica tiene un espacio adicional asignado para vptr (aunque donde vptr estaría en el objeto depende totalmente de la implementación). Generalmente vptr está al principio del objeto.

Si tomo todo en cuenta, si hago una llamada a func, el compilador en tiempo de ejecución verificará a qué está apuntando b:

void func ( Base* b ) { b->function1 (); b->function2 (); }

Digamos que tenemos objeto de D1 pasado a func. El compilador resolvería las llamadas de la siguiente manera:

Primero obtendría vptr del objeto y luego lo usará para obtener la dirección correcta de la función a la que llamar. Entonces, en este caso vptr daría acceso a vtable de D1. y cuando busca la función1, obtiene la dirección de la función1 definida en la clase base. En caso de llamada a function2, obtendría la dirección de function2 de base.

Espero haberte aclarado tus dudas a tu entera satisfacción ...


Tras la creación de instancias, cada clase con al menos una función virtual obtiene un miembro oculto que generalmente se llama vTable (o tabla de envío virtual, VDT).

class Base { hidden: // not part of the language, just to illustrate. static VDT baseVDT; // per class VDT for base VDT *vTable; // per object instance private: ... public: virtual int base1(); virtual int base2(); ... };

El vTable contiene punteros a todas las funciones en Base.

Como parte oculta del constructor de Base, vTable se asigna a baseVDT.

VDT Base::baseVDT[] = { Base::base1, Base::base2 }; class Derived : public Base { hidden: static VDT derivedVDT; // per class VDT for derived private: ... public: virtual int base2(); ... };

La vTable para Derivados contiene punteros a todas las funciones definidas en Base seguidas de las funciones definidas en Derivados. Cuando se construyen objetos de tipo Derivado, vTable se establece en DerivDDT.

VDT derived::derivedVDT[] = { // functions first defined in Base Base::base1, Derived::base2, // override // functions first defined in Derived are appended Derived::derived3 }; // function 2 has an override in derived.

Ahora si tenemos

Base *bd = new Derived; Derived *dd = new Derived; Base *bb = new Base;

bd apunta a un objeto de tipo derivado que la vTable apunta a Derivado

Así que la función llama

x = bd->base2(); y = bb->base2();

en realidad es

// "base2" here is the index into vTable for base2. x = bd->vTable["base2"](); // vTable points to derivedVDT y = bb->vTable["base2"](); // vTable points to baseVDT

El índice es el mismo en ambos debido a la construcción del VDT. Esto también significa que el compilador conoce el índice en el momento de la compilación.

Esto también podría ser implementado como

// call absolute address to virtual dispatch function which calls the right base2. x = Base::base2Dispatch(bd->vTable["base2"]); inline Base::base2Dispatch(void *call) { return call(); // call through function pointer. }

Lo que con O2 o O3 será lo mismo.

Hay algunos casos especiales:

puntos dd para un objeto y Base2 derivado o más derivados se declara finala continuación,

z = dd->base2();

en realidad es

z = Derived::base2(); // absolute call to final method.

Si dd apunta a un objeto Base o cualquier otra cosa, estás en un terreno de comportamiento indefinido y el compilador todavía puede hacer esto.

El otro caso es que si el compilador ve que solo hay unas pocas clases derivadas de Base, podría generar una interfaz de Oracle para base2. [¿gratis después de un compilador de MS o Intel en alguna conferencia de C ++ en 2012 o 2013? mostrando que (¿~ 500%?) más código da (2+ veces?) aceleración en promedio]

inline Base::base2Dispatch(void *call) { if (call == Derived::base2) // most likely from compilers static analysis or profiling. return Derived::base2(); // call absolute address if (call == Base::base2) return Base::base2(); // call absolute address // Backup catch all solution in case of more derived classes return call(); // call through function pointer. }

¿Por qué demonios quieres hacer esto como un compilador? ¡Más código es malo, las ramas innecesarias disminuyen el rendimiento!

Debido a que llamar a un puntero a una función es muy lento en muchas arquitecturas, un ejemplo optimista

Obtener la dirección de la memoria, 3+ ciclos. La tubería retrasada mientras espera el valor de ip, 10 ciclos, en algunos procesadores de más de 19 ciclos.

Si los cpu modernos más complejos pueden predecir la dirección de salto real [BTB] y la predicción de bifurcación, esto podría ser una pérdida. De lo contrario, las ~ 8 instrucciones adicionales guardarán fácilmente las instrucciones 4 * (3 + 10) perdidas debido a las paradas de la tubería (si la tasa de falla de predicción es inferior al 10-20%).

Si las ramas de los dos, si ambos predicen (es decir, evalúa como falso) los ~ 2 ciclos perdidos, están bien cubiertos por la latencia de la memoria para obtener la dirección de la llamada y no estamos en peor situación.
Si uno de los ifs son predicciones erróneas, es probable que el BTB también esté equivocado. Entonces, el costo de las predicciones erróneas es de aproximadamente 8 ciclos, de los cuales 3 son pagados por la latencia de la memoria, y el correcto no se toma o el segundo si puede salvar el día o si pagamos el límite de 10+.
Si solo existen las 2 posibilidades , se tomará una de ellas y guardamos el bloqueo de la tubería desde la función de llamada de puntero y vamos a maximizar. Obtenga una predicción errónea que no produce un rendimiento peor (significativo) que llamar directamente. Si el retraso de la memoria es más largo y el resultado se predice correctamente, el efecto es mucho mayor.


La implementación es específica del compilador. Aquí voy a hacer algunas reflexiones que NO tienen NADA QUE HACER CON NINGÚN CONOCIMIENTO REAL de cómo se hace exactamente en los compiladores, sino solo con algunos requisitos mínimos que se necesitan para funcionar como se requiere. Tenga en cuenta que cada instancia de una clase con métodos virtuales conoce en tiempo de ejecución a qué clase pertenece también.

Supongamos que tenemos una cadena de clases base y derivadas con una longitud de 10 (por lo tanto, una clase derivada tiene un gran gran ... gran padre). Podemos llamar a estas clases base0 base1 ... base9 donde base9 deriva de base8, etc.

Cada una de estas clases define un método como: virtual void doit () {...}

Supongamos que en la clase base usamos ese método dentro de un método llamado "dowith_doit" no invalidado en ninguna clase derivada. La semántica de c ++ implica que, dependiendo de la clase base de la instancia que tengamos a mano, debemos aplicar a esa instancia el "doit" definido en la clase base de la instancia en cuestión.

Esencialmente tenemos dos formas posibles de hacerlo: a) Asignar a cualquier método virtual un número que debe ser diferente para cada método definido en la cadena de clases derivadas. En ese caso, el número podría ser también un hash del nombre del método. Cada clase define una tabla con 2 columnas donde la primera columna contiene el número del método y la segunda columna la dirección de la función. En ese caso, cada clase tendrá un vtable con tantas filas como el número de métodos virtuales definidos dentro de la clase. La ejecución del método ocurre al buscar dentro de la clase el método considerado. Esa búsqueda se puede hacer de forma lineal (lenta) o por bisecciones (cuando hay un orden basado en el número del método).

b) Asigne a cualquier método de este tipo un número entero progresivamente creciente (para cada método diferente en la cadena de clases), y para cada clase defina una tabla con una sola columna. Para los métodos virtuales definidos dentro de la clase, la dirección de la función estará en bruto definida por el número del método. Habrá muchas filas con punteros nulos porque cada clase no reemplaza siempre los métodos de las clases anteriores. La implementación puede elegir para mejorar la eficiencia para llenar filas nulas con la dirección retenida en la clase ancestro de la clase en consideración.

Esencialmente, no existen otras formas simples para trabajar de manera eficiente con métodos virtuales.

Supongo que solo la segunda solución (b) se usa en implementaciones reales, porque el intercambio entre la sobrecarga de espacio utilizada por métodos no existentes en comparación con la eficiencia de ejecución del caso (b) es favorable para el caso b (teniendo en cuenta también que los métodos son Número limitado - puede ser 10 20 50 pero no 5000).