Articulo de referencia

Tabla de métodos virtuales

En programación informática , una tabla de métodos virtuales ( VMT ), tabla de funciones virtuales , tabla de llamadas virtuales , tabla de despacho , vtable o vftable es un mec...

En programación informática , una tabla de métodos virtuales ( VMT ), tabla de funciones virtuales , tabla de llamadas virtuales , tabla de despacho , vtable o vftable es un mecanismo utilizado en un lenguaje de programación para admitir el despacho dinámico (o enlace de métodos en tiempo de ejecución ).

Cuando una clase define una función (o método ) virtual , la mayoría de los compiladores añaden una variable miembro oculta a la clase que apunta a una matriz de punteros a funciones (virtuales), denominada tabla de métodos virtuales. Estos punteros se utilizan en tiempo de ejecución para invocar las implementaciones de función adecuadas, ya que en tiempo de compilación puede que aún no se sepa si se debe llamar a la función base o a una derivada implementada por una clase que hereda de la clase base.

Existen diversas maneras de implementar este despacho dinámico, pero el uso de tablas de métodos virtuales es especialmente común en C++ y lenguajes relacionados (como D y C# ). Los lenguajes que separan la interfaz programática de los objetos de su implementación, como Visual Basic y Delphi , también tienden a utilizar este enfoque, ya que permite que los objetos utilicen una implementación diferente simplemente mediante el uso de un conjunto distinto de punteros a métodos. Este método permite la creación de bibliotecas externas, algo que otras técnicas quizás no permitan. [ 1 ]

Supongamos que un programa contiene tres clases en una jerarquía de herencia: una superclase , Cat, y dos subclases , HouseCaty Lion. La clase Cat define una función virtual llamada speak(), por lo que sus subclases pueden proporcionar una implementación apropiada (por ejemplo, meow()o roar()). Cuando el programa llama a la función speak en una referencia a Cat (que puede referirse a una instancia de , o a una instancia de o ), el código debe poder determinar a qué implementación de la función debe dirigirse la llamada . Esto depende de la clase real del objeto, no de la clase de la referencia a él ( ). La clase generalmente no se puede determinar estáticamente (es decir, en tiempo de compilación ), por lo que el compilador tampoco puede decidir qué función llamar en ese momento. La llamada debe dirigirse a la función correcta dinámicamente (es decir, en tiempo de ejecución ).CatHouseCatLionCat

Implementación

La tabla de métodos virtuales de un objeto contiene las direcciones de los métodos vinculados dinámicamente del objeto. Las llamadas a métodos se realizan obteniendo la dirección del método de la tabla de métodos virtuales del objeto. La tabla de métodos virtuales es la misma para todos los objetos que pertenecen a la misma clase y, por lo tanto, normalmente se comparte entre ellos. Los objetos que pertenecen a clases compatibles en tipo (por ejemplo, hermanos en una jerarquía de herencia) tendrán tablas de métodos virtuales con la misma estructura: la dirección de un método dado aparecerá en el mismo desplazamiento para todas las clases compatibles en tipo. Por lo tanto, al obtener la dirección del método desde un desplazamiento dado en una tabla de métodos virtuales, se obtendrá el método correspondiente a la clase real del objeto. [ 2 ]

Los estándares de C++ no especifican exactamente cómo debe implementarse el despacho dinámico, pero los compiladores generalmente utilizan pequeñas variaciones del mismo modelo básico.

Normalmente, el compilador crea una tabla de métodos virtuales independiente para cada clase. Al crear un objeto, se añade un puntero a esta tabla, denominado puntero a la tabla virtual ( vpointer o VPTR) , como miembro oculto del objeto. Por lo tanto, el compilador también debe generar código oculto en los constructores de cada clase para inicializar el puntero a la tabla virtual del nuevo objeto con la dirección de la tabla de métodos virtuales de su clase.

Muchos compiladores colocan el puntero de la tabla virtual como el último miembro del objeto; otros compiladores lo colocan como el primero; el código fuente portable funciona de ambas maneras. [ 3 ] Por ejemplo, g++ anteriormente colocaba el puntero al final del objeto. [ 4 ]

Ejemplo

Consideremos las siguientes declaraciones de clase en C++ :

importar std ;clase Base1 { privado : int b1 = 0 ; público : Base1 explícito ( int b1 ) : b1 { b1 } {}virtual ~ Base1 () = predeterminado ;void nonVirtual () { std :: println ( "Base1::nonVirtual() llamada!" ); }virtual void fn1 () { std :: println ( "¡Base1::fn1() llamada!" ); } };clase Base2 { privado : int b2 = 0 ; público : Base2 explícito ( int b2 ) : b2 { b2 } {}virtual ~ Base2 () = predeterminado ;virtual void fn2 () { std :: println ( "¡Base2::fn2() llamada!" ); } };clase Derivada : pública Base1 , pública Base2 { privada : int d = 0 ; pública : explícita Derivada ( int b1 , int b2 , int d ) : Base1 ( b1 ), Base2 ( b2 ), d { d } {}~ Derivado () = predeterminado ;void fn3 () { std :: println ( "¡Se llamó a Derived::fn3()!" ); }void fn2 () override { std :: println ( "¡Derived::fn2() llamado!" ); } };int main () { Base2 * base2 = new Base2 (); Derived * derived = new Derived ();// ...eliminar base2 ; eliminar derivado ; }

g++ 3.4.6 de GCC produce la siguiente disposición de memoria de 32 bits para el objeto base2: [ nb 1 ]

b2: +0: ​​puntero a la tabla de métodos virtuales de Base2 +4: valor de b2 Tabla de métodos virtuales de Base2: +0: ​​Base2::fn2() 

y la siguiente distribución de memoria para el objeto derived:

derivado: +0: ​​puntero a la tabla de métodos virtuales de Derived (para Base1) +4: valor de b1 +8: puntero a la tabla de métodos virtuales de Derived (para Base2) +12: valor de b2 +16: valor de d Tamaño total: 20 bytes. Tabla de métodos virtuales de Derivados (para Base1): +0: ​​Base1::fn1() // Base1::fn1() no se sobrescribe Tabla de métodos virtuales de Derivados (para Base2): +0: ​​Derived::fn2() // Base2::fn2() es sobrescrito por Derived::fn2() 

Tenga en cuenta que aquellas funciones que no llevan la palabra clave virtualen su declaración (como nonVirtual()y d()) generalmente no aparecen en la tabla de métodos virtuales. Hay excepciones para casos especiales como los planteados por el constructor predeterminado .

También observe los destructores virtuales en las clases base Base1y Base2. Son necesarios para asegurar delete derived;que se pueda liberar memoria no solo para Derived, sino también para Base1y Base2, si derivedes un puntero o referencia a los tipos Base1o B2. Se excluyeron de las distribuciones de memoria para mantener el ejemplo simple. [ nb 2 ]

La sobrescritura del métodofn2() en la clase Derivedse implementa duplicando la tabla de métodos virtuales de Base2y reemplazando el puntero a Base2::fn2()con un puntero a Derived::fn2().

Herencia múltiple y thunks

El compilador g++ implementa la herencia múltiple de las clases Base1y Base2en clase Derivedusando dos tablas de métodos virtuales, una para cada clase base. (Hay otras formas de implementar la herencia múltiple, pero esta es la más común). Esto lleva a la necesidad de "correcciones de punteros", también llamadas thunks , al realizar conversiones .

Considere el siguiente código C++:

Derivado * derivado = nuevo Derivado (); Base1 * base1 = derivado ; Base2 * base2 = derivado ;

Mientras que derivedy base1apuntarán a la misma ubicación de memoria después de la ejecución de este código, base2apuntará a la ubicación derived + 8(ocho bytes más allá de la ubicación de memoria de derived). Por lo tanto, base2apunta a la región dentro derivedde que "parece" una instancia de Base2, es decir, tiene la misma disposición de memoria que una instancia de Base2.

Invocación

Una llamada a derived->fn1()se maneja desreferenciando el vpointer derivedde Derived::Base1, buscando la fn1entrada en la tabla de métodos virtuales y luego desreferenciando ese puntero para llamar al código.

Herencia simple

En el caso de herencia simple (o en un lenguaje con solo herencia simple), si el puntero virtual es siempre el primer elemento derived(como ocurre con muchos compiladores), esto se reduce al siguiente pseudocódigo C++:

( * (( * derivado )[ 0 ]))( derivado )

Donde *derivedse refiere a la tabla de métodos virtuales de Derivedy [0]se refiere al primer método en la tabla de métodos virtuales. El parámetro derivedse convierte en el puntero "this " al objeto.

Herencia múltiple

En el caso más general, llamar a Base1::fn1()o Derived::fn2()es más complicado:

// Llamar a derived->fn1() ( * ( * ( derived [ 0 ] /*puntero a la tabla de métodos virtuales de Derived (para Base1)*/ )[ 0 ]))( derived )// Llamar a derived->fn2() ( * ( * ( derived [ 8 ] /*puntero a la tabla de métodos virtuales de Derived (para Base2)*/ )[ 0 ]))( derived + 8 )

La llamada derived->fn1()pasa un Base1puntero como parámetro. La llamada derived->fn2()pasa un Base2puntero como parámetro. Esta segunda llamada requiere una corrección para producir el puntero correcto. La ubicación de Base2::fn2no está en la tabla de métodos virtuales para Derived.

En comparación, una llamada a derived->fnonvirtual()es mucho más sencilla:

( * Base1 :: fnonvirtual )( derivado )

Eficiencia

Una llamada virtual requiere al menos una desreferenciación indexada adicional y, a veces, una adición de "corrección", en comparación con una llamada no virtual, que es simplemente un salto a un puntero compilado. Por lo tanto, llamar a funciones virtuales es inherentemente más lento que llamar a funciones no virtuales. Un experimento realizado en 1996 indica que aproximadamente entre el 6 y el 13 % del tiempo de ejecución se dedica simplemente a enviar a la función correcta, aunque la sobrecarga puede llegar a ser tan alta como el 50 %. [ 5 ] El costo de las funciones virtuales puede no ser tan alto en las arquitecturas de CPU modernas debido a cachés mucho más grandes y una mejor predicción de bifurcaciones .

Además, en entornos donde no se utiliza la compilación JIT , las llamadas a funciones virtuales generalmente no se pueden insertar en línea . En ciertos casos, el compilador puede realizar un proceso conocido como desvirtualización , en el que, por ejemplo, la búsqueda y la llamada indirecta se reemplazan por una ejecución condicional de cada cuerpo insertado en línea, pero tales optimizaciones no son comunes.

Para evitar esta sobrecarga, los compiladores suelen evitar el uso de tablas de métodos virtuales siempre que la llamada pueda resolverse en tiempo de compilación .

Por lo tanto, la llamada fn1anterior puede no requerir una búsqueda en la tabla porque el compilador puede determinar que derivedsolo puede contener un Deriveden este punto y Derivedno sobrescribe fn1. O el compilador (u optimizador) puede detectar que no hay subclases de en Base1ninguna parte del programa que sobrescriban fn1. La llamada a Base1::fn1o Base2::fn2probablemente no requerirá una búsqueda en la tabla porque la implementación se especifica explícitamente (aunque todavía requiere la thiscorrección del puntero -).

Comparación con alternativas

La tabla de métodos virtuales generalmente ofrece un buen equilibrio entre rendimiento y capacidad para lograr el despacho dinámico, pero existen alternativas, como el despacho mediante árbol binario , con un rendimiento superior en algunos casos típicos, pero con diferentes compensaciones. [ 1 ] [ 6 ]

Sin embargo, las tablas de métodos virtuales solo permiten un único despacho en el parámetro especial "this", a diferencia del despacho múltiple (como en CLOS , Dylan o Julia ), donde se pueden tener en cuenta los tipos de todos los parámetros en el despacho.

Las tablas de métodos virtuales solo funcionan si el despacho está restringido a un conjunto conocido de métodos, por lo que se pueden colocar en una matriz simple construida en tiempo de compilación, a diferencia de los lenguajes de tipado dinámico (como Smalltalk , Python o JavaScript ).

Los lenguajes que ofrecen una o ambas de estas características suelen realizar el despacho buscando una cadena en una tabla hash o mediante algún otro método equivalente. Existen diversas técnicas para acelerar este proceso (por ejemplo, internar /tokenizar nombres de métodos, almacenar en caché las búsquedas, compilación justo a tiempo ).

Véase también

Notas

  1. El argumento de G++-fdump-class-hierarchy(a partir de la versión 8:-fdump-lang-class) se puede usar para volcar las tablas de métodos virtuales para su inspección manual. Para el compilador AIX VisualAge XlC, use-qdump_class_hierarchypara volcar la jerarquía de clases y la disposición de la tabla de funciones virtuales.
  2. "C++ - por qué hay dos destructores virtuales en la tabla virtual y dónde está la dirección de la función no virtual (gcc4.6.3)" .

Referencias

  • Margaret A. Ellis y Bjarne Stroustrup (1990) Manual de referencia anotado de C++. Reading, MA: Addison-Wesley. ( ISBN) 0-201-51459-1)
  1. ^ Zendra , Olivier; Colnet, Dominique; Collin, Suzanne (1997). Despacho dinámico eficiente sin tablas de funciones virtuales: el compilador SmallEiffel - 12.ª Conferencia anual de ACM SIGPLAN sobre sistemas, lenguajes y aplicaciones de programación orientada a objetos (OOPSLA'97), ACM SIGPLAN, octubre de 1997, Atlanta, Estados Unidos. págs.125-141. inria-00565627 . Centro de Investigación en Informática de Nancy Campus Scientifique, Bâtiment LORIA. pag. 16. 
  2. Ellis y Stroustrup 1990, págs. 227–232
  3. Danny Kalev. "Guía de referencia de C++: El modelo de objetos II" . 2003. Encabezados "Herencia y polimorfismo" y "Herencia múltiple".
  4. "Problemas cerrados de ABI de C++" . Archivado del original el 25 de julio de 2011. Consultado el 17 de junio de 2011 .{{cite web}}: CS1 maint: bot: estado de la URL original desconocido ( enlace )
  5. Driesen, Karel; Hölzle, Urs (1996). "El costo directo de las llamadas a funciones virtuales en C++" (PDF) . OOPSLA.
  6. Zendra, Olivier y Driesen, Karel, "Pruebas de estrés de las estructuras de control para el despacho dinámico en Java" , págs. 105-118, Actas del 2.º Simposio de Investigación y Tecnología de la Máquina Virtual Java de USENIX, 2002 (JVM '02)