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?
fuente
IComparer
conCompare
g++-7 -fdump-class-hierarchy
salida.Respuestas:
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:
(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:
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:
fuente
callvirt
AKACEE_CALLVIRT
en 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.call
código de operación se usa parastatic
métodos, curiosamentecallvirt
se usa incluso si la clase essealed
.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:
Fuente completa para referencia:
fuente
class Derived : public FirstBase, public SecondBase
entonces no puede haber dos vtables. Puede ejecutarg++ -fdump-class-hierarchy
para 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.