¿Cómo se implementan las funciones virtuales y vtable?

Resuelto Brian R. Bondy asked hace 16 años • 12 respuestas

Todos sabemos qué funciones virtuales hay en C++, pero ¿cómo se implementan a un nivel profundo?

¿Se puede modificar la vtable o incluso acceder directamente a ella en tiempo de ejecución?

¿Existe la vtable para todas las clases, o solo para aquellas que tienen al menos una función virtual?

¿Las clases abstractas simplemente tienen un NULL para el puntero de función de al menos una entrada?

¿Tener una única función virtual ralentiza toda la clase? ¿O solo la llamada a la función que es virtual? ¿Y la velocidad se ve afectada si la función virtual se sobrescribe o no, o esto no tiene ningún efecto mientras sea virtual?

Brian R. Bondy avatar Sep 19 '08 10:09 Brian R. Bondy
Aceptado

¿Cómo se implementan las funciones virtuales a un nivel profundo?

De "Funciones virtuales en C++" :

Siempre que un programa tiene una función virtual declarada, se construye av - table para la clase. La tabla v consta de direcciones a las funciones virtuales para clases que contienen una o más funciones virtuales. El objeto de la clase que contiene la función virtual contiene un puntero virtual que apunta a la dirección base de la tabla virtual en la memoria. Siempre que hay una llamada a una función virtual, la tabla v se utiliza para resolver la dirección de la función. Un objeto de la clase que contiene una o más funciones virtuales contiene un puntero virtual llamado vptr al comienzo del objeto en la memoria. Por lo tanto, el tamaño del objeto en este caso aumenta según el tamaño del puntero. Este vptr contiene la dirección base de la tabla virtual en la memoria. Tenga en cuenta que las tablas virtuales son específicas de cada clase, es decir, sólo hay una tabla virtual para una clase independientemente del número de funciones virtuales que contenga. Esta tabla virtual a su vez contiene las direcciones base de una o más funciones virtuales de la clase. En el momento en que se llama a una función virtual en un objeto, el vptr de ese objeto proporciona la dirección base de la tabla virtual para esa clase en la memoria. Esta tabla se utiliza para resolver la llamada a la función, ya que contiene las direcciones de todas las funciones virtuales de esa clase. Así es como se resuelve el enlace dinámico durante una llamada a una función virtual.

¿Se puede modificar la vtable o incluso acceder directamente a ella en tiempo de ejecución?

Universalmente creo que la respuesta es "no". Podrías alterar un poco la memoria para encontrar la vtable, pero aún no sabrías cómo se ve la firma de la función para llamarla. Cualquier cosa que desee lograr con esta capacidad (que el lenguaje admita) debería ser posible sin acceder directamente a la vtable o modificarla en tiempo de ejecución. También tenga en cuenta que la especificación del lenguaje C++ no especifica que se requieran vtables; sin embargo, así es como la mayoría de los compiladores implementan funciones virtuales.

¿Existe la vtable para todos los objetos o solo para aquellos que tienen al menos una función virtual?

Creo que la respuesta aquí es "depende de la implementación", ya que, en primer lugar, la especificación no requiere vtables. Sin embargo, en la práctica, creo que todos los compiladores modernos solo crean una tabla virtual si una clase tiene al menos una función virtual. Hay una sobrecarga de espacio asociada con vtable y una sobrecarga de tiempo asociada con la llamada de una función virtual frente a una función no virtual.

¿Las clases abstractas simplemente tienen un NULL para el puntero de función de al menos una entrada?

La respuesta es que no está especificado en la especificación del idioma, por lo que depende de la implementación. Llamar a la función virtual pura da como resultado un comportamiento indefinido si no está definida (que normalmente no lo está) (ISO/IEC 14882:2003 10.4-2). En la práctica, asigna una ranura en vtable para la función pero no le asigna una dirección. Esto deja la vtable incompleta, lo que requiere que las clases derivadas implementen la función y completen la vtable. Algunas implementaciones simplemente colocan un puntero NULL en la entrada de vtable; otras implementaciones colocan un puntero a un método ficticio que hace algo similar a una afirmación.

Tenga en cuenta que una clase abstracta puede definir una implementación para una función virtual pura, pero esa función solo se puede llamar con una sintaxis de identificación calificada (es decir, especificando completamente la clase en el nombre del método, similar a llamar a un método de clase base desde un clase derivada). Esto se hace para proporcionar una implementación predeterminada fácil de usar, sin dejar de requerir que una clase derivada proporcione una anulación.

¿Tener una sola función virtual ralentiza toda la clase o solo la llamada a la función que es virtual?

Esto está llegando al límite de mis conocimientos, ¡así que alguien, por favor, ayúdeme si me equivoco!

Creo que solo las funciones que son virtuales en la clase experimentan el impacto en el rendimiento del tiempo relacionado con llamar a una función virtual frente a una función no virtual. El espacio superior para la clase está ahí de cualquier manera. Tenga en cuenta que si hay una vtable, solo hay 1 por clase , no una por objeto .

¿La velocidad se ve afectada si la función virtual se anula o no, o esto no tiene ningún efecto mientras sea virtual?

No creo que el tiempo de ejecución de una función virtual anulada disminuya en comparación con llamar a la función virtual base. Sin embargo, hay una sobrecarga de espacio adicional para la clase asociada con la definición de otra tabla virtual para la clase derivada frente a la clase base.

Recursos adicionales:

http://www.codersource.net/published/view/325/virtual_functions_in.aspx (a través de la máquina de regreso)
http://en.wikipedia.org/wiki/Virtual_table
http://www.codesourcery.com/public/ cxx-abi/abi.html#vtable

Zach Burlingame avatar Sep 19 '2008 03:09 Zach Burlingame
  • ¿Se puede modificar la vtable o incluso acceder directamente a ella en tiempo de ejecución?

No de forma portátil, pero si no te importan los trucos sucios, ¡seguro!

ADVERTENCIA : No se recomienda el uso de esta técnica por parte de niños, adultos menores de 969 años o pequeñas criaturas peludas de Alpha Centauri. Los efectos secundarios pueden incluir demonios que salen volando de su nariz , la aparición abrupta de Yog-Sothoth como aprobador requerido en todas las revisiones de código posteriores o la adición retroactiva de IHuman::PlayPiano()a todas las instancias existentes]

En la mayoría de los compiladores que he visto, vtbl * son los primeros 4 bytes del objeto, y el contenido de vtbl es simplemente una matriz de punteros de miembros allí (generalmente en el orden en que fueron declarados, con el primero de la clase base). Por supuesto, existen otros diseños posibles, pero eso es lo que he observado en general.

class A {
  public:
  virtual int f1() = 0;
};
class B : public A {
  public:
  virtual int f1() { return 1; }
  virtual int f2() { return 2; }
};
class C : public A {
  public:
  virtual int f1() { return -1; }
  virtual int f2() { return -2; }
};

A *x = new B;
A *y = new C;
A *z = new C;

Ahora a hacer algunas travesuras...

Cambiar de clase en tiempo de ejecución:

std::swap(*(void **)x, *(void **)y);
// Now x is a C, and y is a B! Hope they used the same layout of members!

Reemplazar un método para todas las instancias (monkeypatching una clase)

Esto es un poco más complicado, ya que el vtbl en sí probablemente esté en la memoria de solo lectura.

int f3(A*) { return 0; }

mprotect(*(void **)x,8,PROT_READ|PROT_WRITE|PROT_EXEC);
// Or VirtualProtect on win32; this part's very OS-specific
(*(int (***)(A *)x)[0] = f3;
// Now C::f1() returns 0 (remember we made x into a C above)
// so x->f1() and z->f1() both return 0

Es bastante probable que esto último haga que los detectores de virus y el enlace se activen y se den cuenta, debido a las manipulaciones de mprotect. En un proceso que utiliza el bit NX, es posible que falle.

puetzk avatar Sep 19 '2008 13:09 puetzk

¿Tener una única función virtual ralentiza toda la clase?

¿O solo la llamada a la función que es virtual? ¿Y la velocidad se ve afectada si la función virtual se sobrescribe o no, o esto no tiene ningún efecto mientras sea virtual?

Tener funciones virtuales ralentiza toda la clase en la medida en que es necesario inicializar, copiar,… un elemento más de datos cuando se trata de un objeto de dicha clase. Para una clase con aproximadamente media docena de miembros, la diferencia debería ser insignificante. Para una clase que solo contiene un charmiembro o ningún miembro, la diferencia puede ser notable.

Aparte de eso, es importante tener en cuenta que no todas las llamadas a una función virtual son llamadas a una función virtual. Si tiene un objeto de un tipo conocido, el compilador puede emitir código para una invocación de función normal, e incluso puede insertar dicha función si así lo desea. Solo cuando realiza llamadas polimórficas, a través de un puntero o referencia que podría apuntar a un objeto de la clase base o a un objeto de alguna clase derivada, necesita la dirección indirecta de vtable y paga por ella en términos de rendimiento.

struct Foo { virtual ~Foo(); virtual int a() { return 1; } };
struct Bar: public Foo { int a() { return 2; } };
void f(Foo& arg) {
  Foo x; x.a(); // non-virtual: always calls Foo::a()
  Bar y; y.a(); // non-virtual: always calls Bar::a()
  arg.a();      // virtual: must dispatch via vtable
  Foo z = arg;  // copy constructor Foo::Foo(const Foo&) will convert to Foo
  z.a();        // non-virtual Foo::a, since z is a Foo, even if arg was not
}

Los pasos que debe seguir el hardware son esencialmente los mismos, sin importar si la función se sobrescribe o no. La dirección de la vtable se lee del objeto, el puntero de función se recupera de la ranura apropiada y la función se llama mediante el puntero. En términos de rendimiento real, las predicciones de sucursales pueden tener algún impacto. Entonces, por ejemplo, si la mayoría de sus objetos se refieren a la misma implementación de una función virtual determinada, entonces existe alguna posibilidad de que el predictor de rama prediga correctamente qué función llamar incluso antes de que se haya recuperado el puntero. Pero no importa qué función sea la común: podrían ser la mayoría de los objetos que delegan al caso base no sobrescrito, o la mayoría de los objetos que pertenecen a la misma subclase y, por lo tanto, delegan al mismo caso sobrescrito.

¿Cómo se implementan a un nivel profundo?

Me gusta la idea de jheriko de demostrar esto mediante una implementación simulada. Pero usaría C para implementar algo parecido al código anterior, de modo que el nivel bajo se vea más fácilmente.

clase padre Foo

typedef struct Foo_t Foo;   // forward declaration
struct slotsFoo {           // list all virtual functions of Foo
  const void *parentVtable; // (single) inheritance
  void (*destructor)(Foo*); // virtual destructor Foo::~Foo
  int (*a)(Foo*);           // virtual function Foo::a
};
struct Foo_t {                      // class Foo
  const struct slotsFoo* vtable;    // each instance points to vtable
};
void destructFoo(Foo* self) { }     // Foo::~Foo
int aFoo(Foo* self) { return 1; }   // Foo::a()
const struct slotsFoo vtableFoo = { // only one constant table
  0,                                // no parent class
  destructFoo,
  aFoo
};
void constructFoo(Foo* self) {      // Foo::Foo()
  self->vtable = &vtableFoo;        // object points to class vtable
}
void copyConstructFoo(Foo* self,
                      Foo* other) { // Foo::Foo(const Foo&)
  self->vtable = &vtableFoo;        // don't copy from other!
}

barra de clase derivada

typedef struct Bar_t {              // class Bar
  Foo base;                         // inherit all members of Foo
} Bar;
void destructBar(Bar* self) { }     // Bar::~Bar
int aBar(Bar* self) { return 2; }   // Bar::a()
const struct slotsFoo vtableBar = { // one more constant table
  &vtableFoo,                       // can dynamic_cast to Foo
  (void(*)(Foo*)) destructBar,      // must cast type to avoid errors
  (int(*)(Foo*)) aBar
};
void constructBar(Bar* self) {      // Bar::Bar()
  self->base.vtable = &vtableBar;   // point to Bar vtable
}

función f realizando llamada a función virtual

void f(Foo* arg) {                  // same functionality as above
  Foo x; constructFoo(&x); aFoo(&x);
  Bar y; constructBar(&y); aBar(&y);
  arg->vtable->a(arg);              // virtual function call
  Foo z; copyConstructFoo(&z, arg);
  aFoo(&z);
  destructFoo(&z);
  destructBar(&y);
  destructFoo(&x);
}

Como puede ver, una vtable es solo un bloque estático en la memoria, que en su mayoría contiene punteros de función. Cada objeto de una clase polimórfica apuntará a la vtable correspondiente a su tipo dinámico. Esto también hace que la conexión entre RTTI y las funciones virtuales sea más clara: puede comprobar de qué tipo es una clase simplemente mirando a qué vtable apunta. Lo anterior está simplificado de muchas maneras, como por ejemplo, herencia múltiple, pero el concepto general es sólido.

Si arges de tipo Foo*y toma arg->vtable, pero en realidad es un objeto de tipo Bar, aún obtendrá la dirección correcta del vtable. Esto se debe a que vtablesiempre es el primer elemento en la dirección del objeto, sin importar si se llama vtableo base.vtableestá en una expresión escrita correctamente.

MvG avatar Apr 09 '2015 18:04 MvG

Generalmente con una VTable, una serie de punteros a funciones.

Lou Franco avatar Sep 19 '2008 03:09 Lou Franco