Implementación de clases e interfaces abstractas puras

27

Aunque esto no es obligatorio en el estándar C ++, parece que la forma en que GCC, por ejemplo, implementa clases primarias, incluidas las abstractas puras, es mediante la inclusión de un puntero a la tabla v para esa clase abstracta en cada instancia de la clase en cuestión .

Naturalmente, esto aumenta el tamaño de cada instancia de esta clase mediante un puntero para cada clase principal que tiene.

Pero he notado que muchas clases y estructuras de C # tienen muchas interfaces principales, que son básicamente clases abstractas puras. Me sorprendería si cada instancia de decir Decimal, se hinchó con 6 punteros a todas sus diversas interfaces.

Entonces, si C # hace las interfaces de manera diferente, ¿cómo las hace, al menos en una implementación típica (entiendo que el estándar en sí mismo puede no definir tal implementación)? ¿Y las implementaciones de C ++ tienen una forma de evitar la hinchazón del tamaño del objeto al agregar padres virtuales puros a las clases?

Clinton
fuente
1
Los objetos C # generalmente tienen una gran cantidad de metadatos adjuntos, tal vez las vtables no son tan grandes en comparación con eso
max630
podría comenzar examinando el código compilado con el desensamblador
idl
C ++ hace una fracción significativa de sus "interfaces" estáticamente. Compare IComparerconCompare
Caleth
44
GCC, por ejemplo, utiliza un puntero de tabla vtable (un puntero a una tabla de vtables o un VTT) por objeto para clases con múltiples clases base. Por lo tanto, cada objeto tiene solo un puntero adicional en lugar de la colección que estás imaginando. Quizás eso significa que en la práctica no es un problema, incluso cuando el código está mal diseñado y hay una jerarquía de clases masiva involucrada.
Stephen M. Webb
1
@ StephenM.Webb Por lo que he entendido de esta respuesta SO , los VTT solo se usan para ordenar la construcción / destrucción con herencia virtual. No participan en el envío de métodos y no terminan ahorrando espacio en el objeto mismo. Dado que las transmisiones en C ++ realizan efectivamente el corte de objetos, no es posible colocar el puntero vtable en ningún otro lugar que no sea el objeto (que para MI agrega punteros vtable en el medio del objeto). Verifiqué mirando la g++-7 -fdump-class-hierarchysalida.
amon

Respuestas:

35

En las implementaciones de C # y Java, los objetos suelen tener un solo puntero a su clase. Esto es posible porque son idiomas de herencia única. La estructura de clases contiene la tabla vtable para la jerarquía de herencia única. Pero llamar a métodos de interfaz también tiene todos los problemas de herencia múltiple. Esto generalmente se resuelve colocando vtables adicionales para todas las interfaces implementadas en la estructura de clases. Esto ahorra espacio en comparación con las implementaciones de herencia virtual típicas en C ++, pero hace que el envío de métodos de interfaz sea más complicado, lo que puede compensarse parcialmente mediante el almacenamiento en caché.

Por ejemplo, en OpenJDK JVM, cada clase contiene una matriz de vtables para todas las interfaces implementadas (una interfaz vtable se llama itable ). Cuando se llama a un método de interfaz, se busca linealmente en esta matriz el itable de esa interfaz, luego el método se puede enviar a través de ese itable. El almacenamiento en caché se utiliza para que cada sitio de llamada recuerde el resultado del envío del método, por lo que esta búsqueda solo tiene que repetirse cuando cambia el tipo de objeto concreto. Pseudocódigo para el envío del método:

// Dispatch SomeInterface.method
Method const* resolve_method(
    Object const* instance, Klass const* interface, uint itable_slot) {

  Klass const* klass = instance->klass;

  for (Itable const* itable : klass->itables()) {
    if (itable->klass() == interface)
      return itable[itable_slot];
  }

  throw ...;  // class does not implement required interface
}

(Compare el código real en el intérprete de OpenJDK HotSpot o en el compilador x86 ).

C # (o más precisamente, el CLR) utiliza un enfoque relacionado. Sin embargo, aquí los itables no contienen punteros a los métodos, sino mapas de ranuras: apuntan a entradas en la tabla principal de la clase. Al igual que con Java, tener que buscar la solución correcta es solo el peor de los casos, y se espera que el almacenamiento en caché en el sitio de la llamada pueda evitar esta búsqueda casi siempre. El CLR utiliza una técnica llamada Virtual Stub Dispatch para parchear el código de máquina compilado JIT con diferentes estrategias de almacenamiento en caché. Pseudocódigo:

Method const* resolve_method(
    Object const* instance, Klass const* interface, uint interface_slot) {

  Klass const* klass = instance->klass;

  // Walk all base classes to find slot map
  for (Klass const* base = klass; base != nullptr; base = base->base()) {
    // I think the CLR actually uses hash tables instead of a linear search
    for (SlotMap const* slot_map : base->slot_maps()) {
      if (slot_map->klass() == interface) {
        uint vtable_slot = slot_map[interface_slot];
        return klass->vtable[vtable_slot];
      }
    }
  }

  throw ...;  // class does not implement required interface
}

La principal diferencia con el pseudocódigo OpenJDK es que en OpenJDK cada clase tiene una matriz de todas las interfaces implementadas directa o indirectamente, mientras que el CLR solo mantiene una matriz de mapas de ranuras para las interfaces que se implementaron directamente en esa clase. Por lo tanto, debemos recorrer la jerarquía de herencia hacia arriba hasta que se encuentre un mapa de ranuras. Para jerarquías de herencia profundas, esto resulta en un ahorro de espacio. Estos son particularmente relevantes en CLR debido a la forma en que se implementan los genéricos: para una especialización genérica, la estructura de clases se copia y los métodos en la tabla principal pueden ser reemplazados por especializaciones. Los mapas de ranuras continúan apuntando a las entradas vtable correctas y, por lo tanto, se pueden compartir entre todas las especializaciones genéricas de una clase.

Como nota final, hay más posibilidades para implementar el envío de interfaz. En lugar de colocar el puntero vtable / itable en el objeto o en la estructura de clase, podemos usar punteros gordos para el objeto, que son básicamente un (Object*, VTable*)par. El inconveniente es que esto duplica el tamaño de los punteros y que las transmisiones (de un tipo concreto a un tipo de interfaz) no son gratuitas. Pero es más flexible, tiene menos indirección y también significa que las interfaces se pueden implementar externamente desde una clase. Las interfaces Go, los rasgos Rust y las clases de tipos Haskell utilizan enfoques relacionados.

Referencias y lecturas adicionales:

  • Wikipedia: almacenamiento en caché en línea . Analiza los enfoques de almacenamiento en caché que se pueden usar para evitar la búsqueda costosa de métodos. Por lo general, no es necesario para el despacho basado en vtable, pero es muy deseable para mecanismos de despacho más costosos como las estrategias de despacho de interfaz anteriores.
  • OpenJDK Wiki (2013): Llamadas de interfaz . Discute itables.
  • Pobar, Neward (2009): SSCLI 2.0 Internals. El Capítulo 5 del libro discute mapas de tragamonedas con gran detalle. Nunca fue publicado pero puesto a disposición por los autores en sus blogs . El enlace PDF se ha movido desde entonces. Este libro probablemente ya no refleja el estado actual del CLR.
  • CoreCLR (2006): Despacho virtual de trozos . En: Libro del tiempo de ejecución. Discute los mapas de tragamonedas y el almacenamiento en caché para evitar búsquedas costosas.
  • Kennedy, Syme (2001): Diseño e implementación de genéricos para .NET Common Language Runtime . ( Enlace PDF ). Discute varios enfoques para implementar genéricos. Los genéricos interactúan con el envío de métodos porque los métodos pueden estar especializados, por lo que es posible que sea necesario reescribir vtables.
amon
fuente
¡Gracias, @amon, gran respuesta y espero con ansias los detalles adicionales sobre cómo Java y el CLR logran esto!
Clinton
@Clinton Actualicé la publicación con algunas referencias. También puede leer el código fuente de las máquinas virtuales, pero me resultó difícil seguirlo. Mis referencias son un poco viejas, si encuentras algo nuevo me interesaría bastante. Esta respuesta es básicamente un extracto de notas que tenía por ahí para una publicación de blog, pero nunca pude publicarla: /
amon
1
callvirtAKA CEE_CALLVIRTen CoreCLR es la instrucción CIL que maneja los métodos de interfaz de llamada, si alguien quiere leer más sobre cómo el tiempo de ejecución maneja esta configuración.
jrh
Tenga en cuenta que el callcódigo de operación se usa para staticmétodos, curiosamente callvirtse usa incluso si la clase es sealed.
jrh
1
Re, "los objetos [C #] normalmente tienen un puntero único a su clase ... porque [C # es] un lenguaje de herencia única". Incluso en C ++, con todo su potencial para redes complejas de tipos de herencia múltiple, solo se le permite especificar un tipo en el punto donde su programa crea una nueva instancia. Debería ser posible, en teoría, diseñar un compilador de C ++ y una biblioteca de soporte en tiempo de ejecución de modo que ninguna instancia de clase tenga más de un puntero de RTTI.
Solomon Slow
2

Naturalmente, esto aumenta el tamaño de cada instancia de esta clase mediante un puntero para cada clase principal que tiene.

Si por 'clase padre' te refieres a 'clase base', este no es el caso en gcc (ni lo espero en ningún otro compilador).

En el caso de C deriva de B deriva de A donde A es una clase polimórfica, la instancia de C tendrá exactamente una vtable.

El compilador tiene toda la información que necesita para fusionar los datos de la tabla de A en B y de B en C.

Aquí hay un ejemplo: https://godbolt.org/g/sfdtNh

Verá que solo hay una inicialización de una vtable.

Copié la salida del ensamblaje para la función principal aquí con anotaciones:

main:
        push    rbx

# allocate space for a C on the stack
        sub     rsp, 16

# initialise c's vtable (note: only one)
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for C+16

# use c    
        lea     rdi, [rsp+8]
        call    do_something(C&)

# destruction sequence through virtual destructor
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for B+16
        lea     rdi, [rsp+8]
        call    A::~A() [base object destructor]

        add     rsp, 16
        xor     eax, eax
        pop     rbx
        ret
        mov     rbx, rax
        jmp     .L10

Fuente completa para referencia:

struct A
{
    virtual void foo() = 0;
    virtual ~A();
};

struct B : A {};

struct C : B {

    virtual void extrafoo()
    {
    }

    void foo() override {
        extrafoo();
    }

};

int main()
{
    extern void do_something(C&);
    auto c = C();
    do_something(c);
}
Richard Hodges
fuente
Si tomamos un ejemplo en el que hereda directamente de las subclases de dos clases de base gusta class Derived : public FirstBase, public SecondBaseentonces no puede haber dos vtables. Puede ejecutar g++ -fdump-class-hierarchypara ver el diseño de la clase (también se muestra en mi publicación de blog vinculada). Godbolt luego muestra un incremento de puntero adicional antes de la llamada para seleccionar la 2da vtable.
amon