¿Qué puede hacer que C ++ RTTI sea indeseable de usar?

En cuanto a la documentación de LLVM, mencionan que utilizan “una forma personalizada de RTTI” , y esta es la razón por la que tienen las funciones con plantilla isa , cast y dyn_cast .

Por lo general, leer que una biblioteca reimplementa alguna funcionalidad básica de un lenguaje es un olor de código terrible y simplemente invita a ejecutar. Sin embargo, estamos hablando de LLVM: los chicos están trabajando en un comstackdor de C ++ y un tiempo de ejecución de C ++. Si no saben lo que están haciendo, estoy bastante jodido porque prefiero clang de la versión gcc que viene con Mac OS.

Sin embargo, como tengo menos experiencia que ellos, me pregunto cuáles son los escollos de la RTTI normal. Sé que funciona solo para los tipos que tienen una tabla v, pero eso solo plantea dos preguntas:

  • Como solo necesitas un método virtual para tener una vtable, ¿por qué no solo marcan un método como virtual ? Los destructores virtuales parecen ser buenos en esto.
  • Si su solución no utiliza RTTI regular, ¿tiene alguna idea de cómo se implementó?

Hay varias razones por las que LLVM lanza su propio sistema RTTI. Este sistema es simple y poderoso, y se describe en una sección del Manual del Progtwigdor de LLVM . Como lo señaló otro póster, los Estándares de encoding plantean dos problemas principales con C ++ RTTI: 1) el costo del espacio y 2) el bajo rendimiento de su uso.

El costo de espacio de RTTI es bastante alto: cada clase con vtable (al menos un método virtual) obtiene información de RTTI, que incluye el nombre de la clase e información sobre sus clases base. Esta información se utiliza para implementar el operador typeid y dynamic_cast . Debido a que este costo se paga por cada clase con un vtable (y no, las optimizaciones de tiempo de enlace y PGO no ayudan, porque el vtable apunta a la información RTTI) LLVM construye con -fno-rtti. Empíricamente, esto ahorra en el orden del 5-10% del tamaño del ejecutable, que es bastante sustancial. LLVM no necesita un equivalente de typeid, por lo que mantenerse al margen de los nombres (entre otras cosas en type_info) para cada clase es solo un desperdicio de espacio.

El rendimiento deficiente es bastante fácil de ver si realiza alguna evaluación comparativa o si observa el código generado para operaciones simples. El operador LLVM es un <> normalmente compila hasta una carga única y una comparación con una constante (aunque las clases controlan esto en función de cómo implementan su método classof). Aquí hay un ejemplo trivial:

 #include "llvm/Constants.h" using namespace llvm; bool isConstantInt(Value *V) { return isa(V); } 

Esto comstack a:

 $ clang t.cc -S -o - -O3 -I $ HOME / llvm / include -D__STDC_LIMIT_MACROS -D__STDC_CONSTANT_MACROS -mkernel -fomit-frame-pointer
 ...
 __Z13isConstantIntPN4llvm5ValueE:
     cmpb $ 9, 8 (% rdi)
     sete% al
     movzbl% al,% eax
     jubilado

que (si no lee el ensamblaje) es una carga y se compara con una constante. En contraste, el equivalente con dynamic_cast es:

 #include "llvm/Constants.h" using namespace llvm; bool isConstantInt(Value *V) { return dynamic_cast(V) != 0; } 

que comstack hasta:

 clang t.cc -S -o - -O3 -I $ HOME / llvm / include -D__STDC_LIMIT_MACROS -D__STDC_CONSTANT_MACROS -mkernel -fomit-frame-pointer
 ...
 __Z13isConstantIntPN4llvm5ValueE:
     pushq% rax
     xorb% al,% al
     testq% rdi,% rdi
     je lbb0_2
     xorl% esi,% esi
     movq $ -1,% rcx
     xorl% edx,% edx
     callq ___dynamic_cast
     testq% rax,% rax
     setne% al
 LBB0_2:
     movzbl% al,% eax
     popq% rdx
     jubilado

Este es un código mucho más, pero el asesino es la llamada a __dynamic_cast, que luego tiene que arrastrarse a través de las estructuras de datos RTTI y hacer un recorrido muy general, dinámicamente calculado a través de estas cosas. Esto es varios órdenes de magnitud más lento que una carga y comparar.

Ok, ok, entonces es más lento, ¿por qué esto importa? Esto importa porque LLVM hace MUCHAS comprobaciones de tipo. Muchas partes de los optimizadores se construyen alrededor de construcciones específicas de coincidencia de patrones en el código y realizan sustituciones en ellas. Por ejemplo, aquí hay algunos códigos para hacer coincidir un patrón simple (que ya sabe que Op0 / Op1 son el lado izquierdo y derecho de una operación de resta de números enteros):

  // (X*2) - X -> X if (match(Op0, m_Mul(m_Specific(Op1), m_ConstantInt<2>()))) return Op1; 

El operador de coincidencia y m_ * son metaprogtwigs de plantilla que se reducen a una serie de llamadas isa / dyn_cast, cada una de las cuales tiene que hacer una verificación de tipo. El uso de dynamic_cast para este tipo de coincidencia de patrones de grano fino sería brutal y extremadamente lento.

Finalmente, hay otro punto, que es uno de expresividad. Los diferentes operadores ‘rtti’ que utiliza LLVM se utilizan para express diferentes cosas: tipo check, dynamic_cast, forzado (aserción), manejo de nulos, etc. El dynamic_cast de C ++ no ofrece (de forma nativa) ninguna de estas funciones.

Al final, hay dos formas de ver esta situación. En el lado negativo, C ++ RTTI está demasiado definido para lo que mucha gente quiere (reflexión completa) y es demasiado lento para ser útil incluso para cosas simples como lo que hace LLVM. En el lado positivo, el lenguaje C ++ es lo suficientemente poderoso como para que podamos definir abstracciones como este como un código de biblioteca, y optar por el uso de la función de lenguaje. Una de mis cosas favoritas de C ++ es lo poderosas y elegantes que pueden ser las bibliotecas. RTTI ni siquiera es muy alto entre mis funciones menos favoritas de C ++ :)!

-Chris

Los estándares de encoding LLVM parecen responder esta pregunta bastante bien:

En un esfuerzo por reducir el código y el tamaño del ejecutable, LLVM no usa RTTI (por ejemplo, dynamic_cast <>) o excepciones. Estas dos características de lenguaje violan el principio general de C ++ de “solo pagas por lo que usas”, lo que provoca una inflexión ejecutable incluso si las excepciones nunca se usan en el código base, o si RTTI nunca se usa para una clase. Debido a esto, los desactivamos globalmente en el código.

Dicho esto, LLVM hace un uso extensivo de una forma enrollada a mano de RTTI que usa plantillas como isa <>, cast <> y dyn_cast <>. Esta forma de RTTI es opcional y se puede agregar a cualquier clase. También es sustancialmente más eficiente que dynamic_cast <>.

Aquí hay un gran artículo sobre RTTI y por qué podría necesitar rodar su propia versión.

No soy un experto en C ++ RTTI, pero también he implementado mi propio RTTI porque definitivamente hay razones por las que tendrías que hacerlo. Primero, el sistema RTTI de C ++ no tiene muchas funciones, básicamente, todo lo que puede hacer es escribir y obtener información básica. Qué sucede si, en tiempo de ejecución, tiene una cadena con el nombre de una clase y desea construir un objeto de esa clase, buena suerte haciendo esto con C ++ RTTI. Además, C ++ RTTI no es realmente (o fácilmente) portátil a través de módulos (no puede identificar la clase de un objeto que se creó a partir de otro módulo (dll / so o exe). Del mismo modo, la implementación de C ++ RTTI es específica del comstackdor, y por lo general, es costoso activarlo en términos de sobrecarga adicional para implementar esto para todos los tipos. Finalmente, no es realmente persistente, por lo que no puede usarse para guardar / cargar archivos por ejemplo (por ejemplo, es posible que desee guardar el datos de un objeto en un archivo, pero también querría guardar el “typeid” de su clase, de modo que, en el momento de la carga, sepa qué objeto crear para cargar estos datos, que no se puede hacer de manera confiable con C ++ RTTI). Por todas o algunas de estas razones, muchos marcos tienen su propio RTTI (desde muy simple hasta muy rico en características). Los ejemplos son wxWidget, LLVM, Boost.Serialization, etc. Esto realmente no es tan raro.

Como solo necesitas un método virtual para tener una vtable, ¿por qué no solo marcan un método como virtual? Los destructores virtuales parecen ser buenos en esto.

Eso es probablemente lo que su sistema RTTI utiliza también. Las funciones virtuales son la base para la vinculación dinámica (vinculación en tiempo de ejecución) y, por lo tanto, es básicamente necesaria para realizar cualquier tipo de identificación / información de tipo de ejecución (no solo requerida por C ++ RTTI, sino que cualquier implementación de RTTI tendrá confiar en las llamadas virtuales de una manera u otra).

Si su solución no utiliza RTTI regular, ¿tiene alguna idea de cómo se implementó?

Claro, puedes buscar implementaciones RTTI en C ++. He hecho el mío y hay muchas bibliotecas que también tienen su propio RTTI. Es bastante simple escribir, la verdad. Básicamente, todo lo que necesita es un medio para representar de forma única un tipo (es decir, el nombre de la clase, o alguna versión mutilada de éste, o incluso una ID única para cada clase), una especie de estructura análoga a type_info que contiene toda la información sobre el tipo que necesita, entonces necesita una función virtual “oculta” en cada clase que devolverá este tipo de información a solicitud (si esta función está anulada en cada clase derivada, funcionará). Hay, por supuesto, algunas cosas adicionales que se pueden hacer, como un repository Singleton de todos los tipos, tal vez con funciones de fábrica asociadas (esto puede ser útil para crear objetos de un tipo cuando todo lo que se conoce en tiempo de ejecución es el nombre del tipo, como una cadena o el tipo ID). Además, es posible que desee agregar algunas funciones virtuales para permitir la conversión dinámica de tipos (por lo general, esto se realiza llamando a la función de static_cast de la clase más derivada y realizando static_cast hasta el tipo al que desea convertir).

La razón predominante es que luchan por mantener el uso de memoria lo más bajo posible.

RTTI solo está disponible para las clases que cuentan con al menos un método virtual, lo que significa que las instancias de la clase contendrán un puntero a la tabla virtual.

En una architecture de 64 bits (que es común hoy en día), un solo puntero es de 8 bytes. Dado que el comstackdor crea una gran cantidad de pequeños objetos, esto se acumula rápidamente.

Por lo tanto, hay un esfuerzo continuo para eliminar las funciones virtuales tanto como sea posible (y práctico), e implementar lo que habrían sido funciones virtuales con la instrucción de switch , que tiene una velocidad de ejecución similar pero un impacto de memoria significativamente menor.

Su preocupación constante por el consumo de memoria ha dado sus frutos, ya que Clang consume significativamente menos memoria que gcc, por ejemplo, lo cual es importante cuando ofrece la biblioteca a los clientes.

Por otro lado, también significa que agregar un nuevo tipo de nodo generalmente resulta en la edición de código en un buen número de archivos porque cada conmutador debe adaptarse (afortunadamente, los comstackdores emiten una advertencia si se olvida de un miembro de la enumeración en un conmutador). Así que aceptaron hacer el mantenimiento un poco más difícil en nombre de la eficiencia de la memoria.