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.