¿Por qué es preferible el 'polimorfismo puro' al uso de RTTI?

106

Casi todos los recursos de C ++ que he visto que discuten este tipo de cosas me dicen que debería preferir enfoques polimórficos al uso de RTTI (identificación de tipo en tiempo de ejecución). En general, me tomo este tipo de consejo en serio y trataré de comprender la lógica; después de todo, C ++ es una bestia poderosa y difícil de entender en toda su profundidad. Sin embargo, para esta pregunta en particular, me estoy quedando en blanco y me gustaría ver qué tipo de consejos puede ofrecer Internet. Primero, permítanme resumir lo que he aprendido hasta ahora, enumerando las razones comunes que se citan por las que RTTI se "considera perjudicial":

Algunos compiladores no lo usan / RTTI no siempre está habilitado

Realmente no compro este argumento. Es como decir que no debería usar las funciones de C ++ 14, porque hay compiladores que no lo admiten. Y, sin embargo, nadie me disuadiría de usar las funciones de C ++ 14. La mayoría de los proyectos tendrán influencia sobre el compilador que están usando y cómo está configurado. Incluso citando la página de manual de gcc:

-fno-rtti

Deshabilite la generación de información sobre cada clase con funciones virtuales para su uso por las características de identificación de tipo en tiempo de ejecución de C ++ (dynamic_cast y typeid). Si no usa esas partes del idioma, puede ahorrar algo de espacio usando esta bandera. Tenga en cuenta que el manejo de excepciones utiliza la misma información, pero G ++ la genera según sea necesario. El operador dynamic_cast todavía se puede usar para conversiones que no requieren información de tipo en tiempo de ejecución, es decir, conversiones a "void *" o a clases base inequívocas.

Lo que esto me dice es que si no estoy usando RTTI, puedo desactivarlo. Eso es como decir, si no está usando Boost, no tiene que vincularlo. No tengo que planificar el caso en el que alguien esté compilando -fno-rtti. Además, el compilador fallará alto y claro en este caso.

Cuesta memoria extra / Puede ser lento

Siempre que me siento tentado a usar RTTI, eso significa que necesito acceder a algún tipo de información o rasgo de mi clase. Si implemento una solución que no usa RTTI, esto generalmente significa que tendré que agregar algunos campos a mis clases para almacenar esta información, por lo que el argumento de la memoria es algo nulo (daré un ejemplo de esto más adelante).

De hecho, un dynamic_cast puede ser lento. Sin embargo, generalmente hay formas de evitar tener que usarlo en situaciones críticas para la velocidad. Y no veo la alternativa. Esta respuesta SO sugiere usar una enumeración, definida en la clase base, para almacenar el tipo. Eso solo funciona si conoce todas sus clases derivadas a priori. ¡Eso es un gran "si"!

A partir de esa respuesta, también parece que el costo de RTTI tampoco está claro. Diferentes personas miden diferentes cosas.

Los elegantes diseños polimórficos harán que RTTI sea innecesario

Este es el tipo de consejo que me tomo en serio. En este caso, simplemente no puedo encontrar buenas soluciones que no sean RTTI que cubran mi caso de uso de RTTI. Déjame darte un ejemplo:

Digamos que estoy escribiendo una biblioteca para manejar gráficos de algún tipo de objetos. Quiero permitir que los usuarios generen sus propios tipos cuando usan mi biblioteca (por lo que el método enum no está disponible). Tengo una clase base para mi nodo:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();
};

Ahora, mis nodos pueden ser de diferentes tipos. Que tal esto:

class red_node : virtual public node_base
{
  public:
    red_node();
    virtual ~red_node();

    void get_redness();
};

class yellow_node : virtual public node_base
{
  public:
    yellow_node();
    virtual ~yellow_node();

    void set_yellowness(int);
};

Demonios, ¿por qué ni siquiera uno de estos?

class orange_node : public red_node, public yellow_node
{
  public:
    orange_node();
    virtual ~orange_node();

    void poke();
    void poke_adjacent_oranges();
};

La última función es interesante. He aquí una forma de escribirlo:

void orange_node::poke_adjacent_oranges()
{
    auto adj_nodes = get_adjacent_nodes();
    foreach(auto node, adj_nodes) {
        // In this case, typeid() and static_cast might be faster
        std::shared_ptr<orange_node> o_node = dynamic_cast<orange_node>(node);
        if (o_node) {
             o_node->poke();
        }
    }
}

Todo esto parece claro y limpio. No tengo que definir atributos o métodos donde no los necesito, la clase de nodo base puede permanecer delgada y mezquina. Sin RTTI, ¿por dónde empiezo? Tal vez pueda agregar un atributo node_type a la clase base:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();

  private:
    std::string my_type;
};

¿Es std :: string una buena idea para un tipo? Quizás no, pero ¿qué más puedo usar? ¿Inventa un número y espera que nadie más lo esté usando todavía? Además, en el caso de mi orange_node, ¿qué pasa si quiero usar los métodos de red_node y yellow_node? ¿Tendría que almacenar varios tipos por nodo? Eso parece complicado.

Conclusión

Estos ejemplos no parecen demasiado complejos o inusuales (estoy trabajando en algo similar en mi trabajo diario, donde los nodos representan el hardware real que se controla a través del software y que hacen cosas muy diferentes dependiendo de lo que sean). Sin embargo, no conocería una forma limpia de hacer esto con plantillas u otros métodos. Tenga en cuenta que estoy tratando de comprender el problema, no defender mi ejemplo. Mi lectura de páginas como la respuesta SO que vinculé anteriormente y esta página en Wikilibros parece sugerir que estoy haciendo un mal uso de RTTI, pero me gustaría saber por qué.

Entonces, volviendo a mi pregunta original: ¿Por qué es preferible el 'polimorfismo puro' al uso de RTTI?

mbr0wn
fuente
9
Lo que te "falta" (como característica de lenguaje) para resolver tu ejemplo de poke oranges sería el envío múltiple ("métodos múltiples"). Por lo tanto, buscar formas de emular eso podría ser una alternativa. Por lo tanto, generalmente se usa el patrón de visitante.
Daniel Jour
1
Usar una cadena como tipo no es muy útil. Usar un puntero a una instancia de alguna clase de "tipo" lo haría más rápido. Pero básicamente estás haciendo manualmente lo que haría RTTI.
Daniel Jour
4
@MargaretBloom No, no lo hará, RTTI significa Runtime Type Information, mientras que CRTP es solo para plantillas: tipos estáticos, entonces.
edmz
2
@ mbr0wn: todos los procesos de ingeniería están sujetos a algunas reglas; la programación no es una excepción. Las reglas se pueden dividir en dos cubos: suaves reglas (debería) y duras reglas (MUST). (También hay un depósito de consejos / opciones (PODRÍA), por así decirlo.) Lea cómo el estándar C / C ++ (o cualquier otro estándar de ingeniería, por el hecho) los define. Creo que el problema viene del hecho de que has confundido "no usar RTTI" como un disco regla ( "No debes utilizar RTTI"). En realidad, es una regla suave ("NO DEBE usar RTTI"), lo que significa que debe evitarlo siempre que sea posible, y usarlo cuando no pueda evitarlo
3
Noto que muchas respuestas no tienen en cuenta la idea que sugiere su ejemplo node_basees parte de una biblioteca y los usuarios crearán sus propios tipos de nodos. Entonces no pueden modificar node_basepara permitir otra solución, por lo que quizás RTTI se convierta en su mejor opción en ese momento. Por otro lado, hay otras formas de diseñar una biblioteca de este tipo para que los nuevos tipos de nodos puedan encajar de forma mucho más elegante sin necesidad de utilizar RTTI (y otras formas de diseñar los nuevos tipos de nodos también).
Matthew Walton

Respuestas:

69

Una interfaz describe lo que uno necesita saber para interactuar en una situación dada en el código. Una vez que amplía la interfaz con "la jerarquía de tipo entero", la interfaz de "superficie" se pone muy grande, lo que hace razonar sobre ello más difícil .

Como ejemplo, su "empujar naranjas adyacentes" significa que yo, como tercero, ¡no puedo emular ser una naranja! Usted declaró de forma privada un tipo naranja, luego usó RTTI para hacer que su código se comporte de manera especial al interactuar con ese tipo. Si quiero "ser naranja", debo estar dentro de su jardín privado.

Ahora todos los que se emparejan con "orangeness" se emparejan con todo su tipo de naranja, e implícitamente con todo su jardín privado, en lugar de con una interfaz definida.

Si bien a primera vista parece una excelente manera de extender la interfaz limitada sin tener que cambiar todos los clientes (agregando am_I_orange ), lo que tiende a suceder es que osifica la base del código y evita una mayor extensión. La naranja especial se vuelve inherente al funcionamiento del sistema y le impide crear un reemplazo "mandarina" para naranja que se implementa de manera diferente y tal vez elimine una dependencia o resuelva algún otro problema con elegancia.

Esto significa que su interfaz debe ser suficiente para resolver su problema. Desde esa perspectiva, ¿por qué solo necesita pinchar naranjas y, de ser así, por qué no estaba disponible la naranja en la interfaz? Si necesita un conjunto difuso de etiquetas que se pueden agregar ad-hoc, puede agregarlo a su tipo:

class node_base {
  public:
    bool has_tag(tag_name);

Esto proporciona una ampliación masiva similar de su interfaz desde una especificación estricta hasta una amplia basada en etiquetas. Excepto que en lugar de hacerlo a través de RTTI y los detalles de implementación (también conocido como "¿cómo se implementa? ¿Con el tipo naranja? Ok, pasa"), lo hace con algo fácilmente emulado a través de una implementación completamente diferente.

Esto incluso se puede extender a métodos dinámicos, si lo necesita. "¿Apoyas ser engañado con argumentos de Baz, Tom y Alice? Ok, engañarte". En un sentido amplio, esto es menos intrusivo que un elenco dinámico para llegar al hecho de que el otro objeto es del tipo que conoces.

Ahora los objetos mandarina pueden tener la etiqueta naranja y seguir el juego, mientras están desacoplados de implementación.

Todavía puede conducir a un gran lío, pero al menos es un lío de mensajes y datos, no jerarquías de implementación.

La abstracción es un juego de disociar y ocultar irrelevancia. Hace que el código sea más fácil de razonar localmente. RTTI está abriendo un agujero directamente desde la abstracción hasta los detalles de implementación. Esto puede facilitar la resolución de un problema, pero tiene el costo de bloquearlo en una implementación específica con mucha facilidad.

Yakk - Adam Nevraumont
fuente
14
+1 para el último párrafo; no solo porque estoy de acuerdo contigo, sino porque aquí es el martillo en el clavo.
7
¿Cómo se llega a una funcionalidad particular una vez que se sabe que un objeto está etiquetado como compatible con esa funcionalidad? O esto implica el casting, o hay una clase de Dios con todas las funciones posibles de los miembros. La primera posibilidad es la conversión sin marcar, en cuyo caso el etiquetado es solo el propio esquema de verificación de tipo dinámico falible de uno, o está marcado dynamic_cast(RTTI), en cuyo caso las etiquetas son redundantes. La segunda posibilidad, una clase de Dios, es abominable. En resumen, esta respuesta tiene muchas palabras que creo que suenan bien para los programadores de Java, pero el contenido real no tiene sentido.
Saludos y hth. - Alf
2
@Falco: Esa es (una variante de) la primera posibilidad que mencioné, casting sin marcar basado en la etiqueta. Aquí, el etiquetado es un esquema de verificación de tipo dinámico muy frágil y falible. Cualquier pequeño mal comportamiento del código del cliente, y en C ++ uno está apagado en UB-land. No obtiene excepciones, como podría obtener en Java, pero comportamiento indefinido, como bloqueos y / o resultados incorrectos. Además de ser extremadamente poco confiable y peligroso, también es extremadamente ineficiente, en comparación con el código C ++ más sano. IOW., Es muy, muy malo; extremadamente así.
Saludos y hth. - Alf
1
Uhm. :) ¿Tipos de argumentos?
Saludos y hth. - Alf
2
@JojOatXGME: Porque "polimorfismo" significa poder trabajar con una variedad de tipos. Si tiene que verificar si es un tipo en particular , más allá de la verificación de tipo ya existente que usó para obtener el puntero / referencia para empezar, entonces está buscando detrás del polimorfismo. No está trabajando con una variedad de tipos; estás trabajando con un tipo específico . Sí, hay "proyectos (grandes) en Java" que hacen esto. Pero eso es Java ; el lenguaje solo permite polimorfismos dinámicos. C ++ también tiene polimorfismo estático. Además, el hecho de que alguien "grande" lo haga no significa que sea una buena idea.
Nicol Bolas
31

La mayor parte de la persuasión moral contra este o aquel rasgo es la tipicidad que se origina a partir de la observación de que hay una serie de usos erróneos de ese rasgo.

Donde fallan los moralistas es que presumen que TODOS los usos están mal concebidos, mientras que de hecho las características existen por una razón.

Ellos tienen lo que yo solía llamar el "fontanero compleja": piensan todos los grifos están funcionando mal debido a que todos los grifos que están llamados a reparación son. La realidad es que la mayoría de los grifos funcionan bien: ¡simplemente no llama a un plomero para ellos!

Algo loco que puede suceder es cuando, para evitar el uso de una función determinada, los programadores escriben una gran cantidad de código repetitivo en realidad, reimplementando de forma privada exactamente esa función. (¿Alguna vez conociste clases que no usan RTTI ni llamadas virtuales, pero tienen un valor para rastrear qué tipo derivado real son? Eso no es más que una reinvención de RTTI disfrazada).

Hay una manera general de pensar polimorfismo: IF(selection) CALL(something) WITH(parameters). (Lo siento, pero la programación, al ignorar la abstracción, se trata de eso)

El uso de polimorfismo en tiempo de diseño (conceptos) en tiempo de compilación (basado en plantilla-deducción), tiempo de ejecución (herencia y basado en funciones virtuales) o basado en datos (RTTI y conmutación), depende de la cantidad de decisiones que se conozcan. en cada una de las etapas de la producción y cuán variables son en cada contexto.

La idea es que:

cuanto más pueda anticipar, mayor será la posibilidad de detectar errores y evitar errores que afecten al usuario final.

Si todo es constante (incluidos los datos), puede hacer todo con la metaprogramación de plantillas. Después de que se realizó la compilación en constantes actualizadas, todo el programa se reduce a solo una declaración de retorno que escupe el resultado .

Si hay varios casos que se conocen todos en tiempo de compilación , pero no conoce los datos reales sobre los que deben actuar, entonces el polimorfismo en tiempo de compilación (principalmente CRTP o similar) puede ser una solución.

Si la selección de los casos depende de los datos (no de los valores conocidos en tiempo de compilación) y la conmutación es unidimensional (lo que se puede hacer se puede reducir a un solo valor), entonces el envío basado en funciones virtuales (o en general "tablas de punteros de función ") es necesario.

Si el cambio es multidimensional, dado que no existe un despacho de tiempo de ejecución múltiple nativo en C ++, entonces tiene que:

  • Reducir a una dimensión por Goedelización : ahí es donde están las bases virtuales y la herencia múltiple, con diamantes y paralelogramos apilados , pero esto requiere que el número de combinaciones posibles se conozca y sea relativamente pequeño.
  • Encadenar las dimensiones una en la otra (como en el patrón de visitantes compuestos, pero esto requiere que todas las clases sean conscientes de sus otros hermanos, por lo que no puede "escalar" fuera del lugar en el que fue concebido)
  • Envíe llamadas basadas en múltiples valores. Para eso es exactamente RTTI.

Si no solo se conoce el cambio, sino incluso las acciones, no se conoce el tiempo de compilación, entonces se requieren secuencias de comandos y análisis : los datos en sí deben describir la acción que se tomará en ellos.

Ahora, dado que cada uno de los casos que enumeré puede verse como un caso particular de lo que le sigue, puede resolver cada problema abusando de la solución más baja también para problemas asequibles con la más alta.

Eso es lo que la moralización realmente empuja a evitar. ¡Pero eso no significa que los problemas que viven en los dominios más bajos no existan!

Golpear RTTI solo para golpearlo, es como golpear gotosolo para golpearlo. Cosas para loros, no programadores.

Emilio Garavaglia
fuente
Una buena descripción de los niveles en los que se aplica cada enfoque. Sin embargo, no he oído hablar de "Goedelización". ¿También se le conoce con otro nombre? ¿Podría agregar un enlace o más explicación? Gracias :)
j_random_hacker
1
@j_random_hacker: Yo también tengo curiosidad por este uso de la godelización. Normalmente, uno piensa en la godelización como primero, mapeando de una cadena a algún número entero, y segundo, usando esta técnica para producir declaraciones autorreferenciales en lenguajes formales. No estoy familiarizado con este término en el contexto del envío virtual y me encantaría saber más.
Eric Lippert
1
De hecho, estoy abusando del término: de acuerdo con Goedle, dado que cada número entero corresponde a un número entero n-ple (las potencias de sus factores primos) y cada n-ple corresponde a un número entero, cada problema de indexación n-dimensional discreto puede ser reducido a uno mono-dimensional . Eso no significa que esta sea la única forma de hacerlo: es solo una forma de decir "es posible". Todo lo que necesita es un mecanismo de "divide y vencerás". las funciones virtuales son la "división" y la herencia múltiple es la "conquista".
Emilio Garavaglia
... Cuando todo eso sucede dentro de un campo finito (un rango), las combinaciones lineales son más efectivas (el clásico i = r * C + c obtiene el índice en un arreglo de la celda de una matriz). En este caso, la división identifica al "visitante" y la conquista es el "compuesto". Dado que el álgebra lineal está involucrada, la técnica en este caso corresponde a la "diagonalización"
Emilio Garavaglia
No piense en todo esto como técnicas. Son solo analogías
Emilio Garavaglia
23

Se ve un poco ordenado en un pequeño ejemplo, pero en la vida real pronto terminarás con un conjunto largo de tipos que pueden empujarse entre sí, algunos de ellos quizás solo en una dirección.

¿Qué pasa dark_orange_node, o black_and_orange_striped_node, o dotted_node? ¿Puede tener puntos de diferentes colores? ¿Qué pasa si la mayoría de los puntos son naranjas, entonces se pueden pinchar?

Y cada vez que tenga que agregar una nueva regla, tendrá que volver a visitar todas las poke_adjacentfunciones y agregar más declaraciones if.


Como siempre, es difícil crear ejemplos genéricos, te lo daré.

Pero si tuviera que hacer este ejemplo específico , agregaría un poke()miembro a todas las clases y dejaría que algunos de ellos ignoren la llamada ( void poke() {}) si no están interesados.

Seguramente eso sería incluso menos costoso que comparar los typeids.

Bo Persson
fuente
3
Dices "seguramente", pero ¿qué te hace tan seguro? Eso es realmente lo que estoy tratando de averiguar. Digamos que cambio el nombre de orange_node a pokable_node, y son los únicos en los que puedo llamar a poke (). Eso significa que mi interfaz necesitará implementar un método poke () que, digamos, arroje una excepción ("este nodo no es pokable"). Eso parece más caro.
mbr0wn
2
¿Por qué tendría que lanzar una excepción? Si le importaba si la interfaz es "pokeable", simplemente agregue una función "isPokeable" y llámela primero antes de llamar a la función poke. O simplemente haz lo que él dice y "no hagas nada, en clases no pinchables".
Brandon
1
@ mbr0wn: La mejor pregunta es por qué quiere que los nodos pokable y nonpokable compartan la misma clase base.
Nicol Bolas
2
@NicolBolas ¿Por qué querrías que monstruos amistosos y hostiles compartieran la misma clase base, o elementos de IU enfocables y no enfocables, o teclados con un teclado numérico y teclados sin un teclado numérico?
user253751
1
@ mbr0wn Esto suena como un patrón de comportamiento. La interfaz base tiene dos métodos supportsBehavioury invokeBehaviourcada clase puede tener una lista de comportamientos. Un comportamiento sería Poke y podría ser agregado a la lista de comportamientos admitidos por todas las clases que quieran ser pinchables.
Falco
20

Algunos compiladores no lo usan / RTTI no siempre está habilitado

Creo que ha entendido mal esos argumentos.

Hay una serie de lugares de codificación C ++ donde no se debe utilizar RTTI. Donde se utilizan conmutadores de compilador para desactivar RTTI a la fuerza. Si está codificando dentro de ese paradigma ... es casi seguro que ya se le ha informado de esta restricción.

Por tanto, el problema está en las bibliotecas . Es decir, si está escribiendo una biblioteca que depende de RTTI, los usuarios que desactivan RTTI no pueden usar su biblioteca. Si desea que esas personas usen su biblioteca, entonces no puede usar RTTI, incluso si su biblioteca también es utilizada por personas que pueden usar RTTI. Lo que es igualmente importante, si no puede usar RTTI, tiene que buscar un poco más para las bibliotecas, ya que el uso de RTTI es un factor decisivo para usted.

Cuesta memoria extra / Puede ser lento

Hay muchas cosas que no se pueden hacer en bucles en caliente. No asignas memoria. No vas iterando a través de listas enlazadas. Etcétera. RTTI ciertamente puede ser otra de esas cosas de "no hagas esto aquí".

Sin embargo, considere todos sus ejemplos de RTTI. En todos los casos, tiene uno o más objetos de tipo indeterminado y desea realizar alguna operación en ellos que puede no ser posible para algunos de ellos.

Eso es algo con lo que tienes que trabajar a nivel de diseño . Puede escribir contenedores que no asignen memoria y que encajen en el paradigma "STL". Puede evitar las estructuras de datos de listas vinculadas o limitar su uso. Puede reorganizar matrices de estructuras en estructuras de matrices o lo que sea. Cambia algunas cosas, pero puede mantenerlo compartimentado.

¿Cambiar una operación RTTI compleja en una llamada de función virtual regular? Eso es un problema de diseño. Si tiene que cambiar eso, entonces es algo que requiere cambios en cada clase derivada. Cambia la forma en que la cantidad de código interactúa con varias clases. El alcance de tal cambio se extiende mucho más allá de las secciones de código críticas para el rendimiento.

Entonces ... ¿por qué lo escribiste de manera incorrecta para empezar?

No tengo que definir atributos o métodos donde no los necesito, la clase de nodo base puede permanecer delgada y mezquina.

¿A que final?

Dices que la clase base es "magra y mezquina". Pero realmente ... es inexistente . En realidad no hace nada .

Basta con mirar a tu ejemplo: node_base. ¿Qué es? Parece ser una cosa que tiene otras cosas adyacentes. Esta es una interfaz de Java (Java pre-genérico en eso): una clase que existe únicamente para ser algo que los usuarios pueden transmitir a la realidad tipo . Tal vez agregue alguna característica básica como adyacencia (agrega Java ToString), pero eso es todo.

Hay una diferencia entre "delgado y mezquino" y "transparente".

Como dijo Yakk, tales estilos de programación se limitan a la interoperabilidad, porque si toda la funcionalidad está en una clase derivada, los usuarios fuera de ese sistema, sin acceso a esa clase derivada, no pueden interoperar con el sistema. No pueden anular funciones virtuales y agregar nuevos comportamientos. Ni siquiera pueden llamar esas funciones.

Pero lo que también hacen es hacer que sea un gran dolor hacer cosas nuevas, incluso dentro del sistema. Considere su poke_adjacent_orangesfunción. ¿Qué sucede si alguien quiere un lime_nodetipo que se pueda pinchar como orange_nodes? Bueno, no podemos derivar lime_nodede orange_node; eso no tiene sentido.

En su lugar, tenemos que agregar un nuevo lime_nodederivado de node_base. Luego cambie el nombre de poke_adjacent_orangesa poke_adjacent_pokables. Y luego, intente transmitir a orange_nodey lime_node; el elenco que funcione es el que pinchamos.

Sin embargo, lime_nodenecesita su propio poke_adjacent_pokables . Y esta función necesita realizar las mismas comprobaciones de lanzamiento.

Y si agregamos un tercer tipo, no solo tenemos que agregar su propia función, sino que debemos cambiar las funciones en las otras dos clases.

Obviamente, ahora haces poke_adjacent_pokables una función gratuita, para que funcione para todos. Pero, ¿qué supone que sucede si alguien agrega un cuarto tipo y se olvida de agregarlo a esa función?

Hola, quiebra silenciosa . El programa parece funcionar más o menos bien, pero no lo es. Si hubiera pokesido una función virtual real , el compilador habría fallado cuando no anuló la función virtual pura denode_base .

A su manera, no tiene tales comprobaciones de compilador. Oh, claro, el compilador no buscará virtuales no puros, pero al menos tiene protección en los casos en que la protección es posible (es decir, no hay una operación predeterminada).

El uso de clases base transparentes con RTTI conduce a una pesadilla de mantenimiento. De hecho, la mayoría de los usos de RTTI provocan dolores de cabeza por mantenimiento. Eso no significa que RTTI no sea útil (es vital para que boost::anyfuncione, por ejemplo). Pero es una herramienta muy especializada para necesidades muy especializadas.

De esa manera, es "dañino" de la misma manera que goto. Es una herramienta útil que no debería descartarse. Pero su uso debería ser poco común dentro de su código.


Entonces, si no puede usar clases base transparentes y casting dinámico, ¿cómo puede evitar las interfaces pesadas? ¿Cómo evita que se propaguen todas las funciones a las que podría querer llamar en un tipo para que no se propaguen a la clase base?

La respuesta depende de para qué sirve la clase base.

Las clases base transparentes como node_basesimplemente están usando la herramienta incorrecta para el problema. Las listas vinculadas se manejan mejor con plantillas. El tipo de nodo y la adyacencia los proporcionaría un tipo de plantilla. Si desea poner un tipo polimórfico en la lista, puede hacerlo. Solo use BaseClass*como Ten el argumento de la plantilla. O su puntero inteligente preferido.

Pero hay otros escenarios. Uno es un tipo que hace muchas cosas, pero tiene algunas partes opcionales. Una instancia en particular podría implementar ciertas funciones, mientras que otra no lo haría. Sin embargo, el diseño de estos tipos suele ofrecer una respuesta adecuada.

La clase "entidad" es un ejemplo perfecto de esto. Esta clase lleva mucho tiempo plagando a los desarrolladores de juegos. Conceptualmente, tiene una interfaz gigantesca, que vive en la intersección de casi una docena de sistemas completamente dispares. Y diferentes entidades tienen diferentes propiedades. Algunas entidades no tienen ninguna representación visual, por lo que sus funciones de representación no hacen nada. Y todo esto se determina en tiempo de ejecución.

La solución moderna para esto es un sistema de estilo de componentes. Entityes simplemente un contenedor de un conjunto de componentes, con algo de pegamento entre ellos. Algunos componentes son opcionales; una entidad que no tiene representación visual no tiene el componente "gráficos". Una entidad sin IA no tiene un componente "controlador". Etcétera.

Las entidades en un sistema de este tipo son solo indicadores de componentes, y la mayor parte de su interfaz se proporciona al acceder a los componentes directamente.

El desarrollo de un sistema de componentes de este tipo requiere reconocer, en la etapa de diseño, que ciertas funciones están agrupadas conceptualmente, de modo que todos los tipos que implementan una las implementarán todas. Esto le permite extraer la clase de la posible clase base y convertirla en un componente separado.

Esto también ayuda a seguir el principio de responsabilidad única. Una clase de componentes de este tipo solo tiene la responsabilidad de ser titular de componentes.


De Matthew Walton:

Observo que muchas respuestas no tienen en cuenta la idea de que su ejemplo sugiere que node_base es parte de una biblioteca y los usuarios crearán sus propios tipos de nodos. Entonces no pueden modificar node_base para permitir otra solución, por lo que quizás RTTI se convierta en su mejor opción en ese momento.

Bien, exploremos eso.

Para que esto tenga sentido, lo que tendría que tener es una situación en la que alguna biblioteca L proporcione un contenedor u otro contenedor estructurado de datos. El usuario puede agregar datos a este contenedor, iterar sobre su contenido, etc. Sin embargo, la biblioteca no hace nada con estos datos; simplemente gestiona su existencia.

Pero ni siquiera gestiona tanto su existencia como su destrucción . La razón es que, si se espera que use RTTI para tales propósitos, entonces está creando clases que L ignora. Esto significa que su código asigna el objeto y lo entrega a L para su administración.

Ahora bien, hay casos en los que algo como esto es un diseño legítimo. Señalización de eventos / paso de mensajes, colas de trabajo seguras para subprocesos, etc. El patrón general aquí es el siguiente: alguien está realizando un servicio entre dos piezas de código que es apropiado para cualquier tipo, pero el servicio no necesita ser consciente de los tipos específicos involucrados .

En C, este patrón está escrito void*y su uso requiere mucho cuidado para evitar que se rompa. En C ++, este patrón está escrito std::experimental::any(pronto se escribirá std::any).

La forma en que esto debería funcionar es que L proporciona una node_baseclase que toma un anyque representa sus datos reales. Cuando recibe el mensaje, el elemento de trabajo de la cola de subprocesos o lo que sea que esté haciendo, lo anyenvía al tipo apropiado, que tanto el remitente como el receptor conocen.

Así que en lugar de derivar orange_nodede node_data, simplemente se pega un orangeinterior de node_data's anycampo de miembro. El usuario final lo extrae y lo usa any_castpara convertirlo a orange. Si el elenco falla, entonces no lo fue orange.

Ahora, si usted está en todo familiarizado con la implementación de any, lo más probable es decir, "Hey, espera un minuto: any internamente usa RTTI para hacerany_cast . Trabajo" A lo que respondo, "... sí".

Ese es el punto de una abstracción . En el fondo de los detalles, alguien está usando RTTI. Pero al nivel en el que debería estar operando, RTTI directo no es algo que debería estar haciendo.

Debería utilizar tipos que le proporcionen la funcionalidad que desea. Después de todo, realmente no quieres RTTI. Lo que desea es una estructura de datos que pueda almacenar un valor de un tipo determinado, ocultarlo de todos excepto del destino deseado y luego volver a convertirlo a ese tipo, con la verificación de que el valor almacenado realmente es de ese tipo.

Eso se llama any. Se utiliza RTTI, pero el uso anyes muy superior a la utilización de RTTI directamente, ya que se ajusta más correctamente la semántica deseados.

Nicol Bolas
fuente
10

Si llama a una función, como regla, realmente no le importa qué pasos precisos tomará, solo que algún objetivo de nivel superior se logre dentro de ciertas restricciones (y cómo la función hace que eso suceda es realmente su propio problema).

Cuando usa RTTI para hacer una preselección de objetos especiales que pueden hacer un determinado trabajo, mientras que otros en el mismo conjunto no pueden, está rompiendo esa cómoda visión del mundo. De repente, se supone que la persona que llama sabe quién puede hacer qué, en lugar de simplemente decirle a sus secuaces que sigan adelante. A algunas personas les molesta esto, y sospecho que esta es una gran parte de la razón por la que RTTI se considera un poco sucio.

¿Hay algún problema de rendimiento? Tal vez, pero nunca lo he experimentado, y podría ser sabiduría de hace veinte años, o de personas que creen honestamente que usar tres instrucciones de ensamblaje en lugar de dos es una hinchazón inaceptable.

Entonces, cómo lidiar con esto ... Dependiendo de su situación, podría tener sentido tener propiedades específicas de nodo agrupadas en objetos separados (es decir, toda la API 'naranja' podría ser un objeto separado). El objeto raíz podría tener una función virtual para devolver la API 'naranja', devolviendo nullptr por defecto para los objetos que no son de color naranja.

Si bien esto puede ser excesivo dependiendo de su situación, le permitiría consultar en el nivel raíz si un nodo específico admite una API específica y, si lo hace, ejecutar funciones específicas para esa API.

H. Guijt
fuente
6
Re: el costo del rendimiento - Medí que dynamic_cast <> costaba alrededor de 2µs en nuestra aplicación en un procesador de 3GHz, que es aproximadamente 1000 veces más lento que verificar una enumeración. (Nuestra aplicación tiene una fecha límite de ciclo principal de 11.1ms, por lo que nos preocupamos mucho por los microsegundos).
Crashworks
6
El rendimiento difiere mucho entre las implementaciones. GCC utiliza una comparación de punteros typeinfo que es rápida. MSVC utiliza comparaciones de cadenas que no son rápidas. Sin embargo, el método de MSVC funcionará con código vinculado a diferentes versiones de bibliotecas, estáticas o DLL, donde el método de puntero de GCC cree que una clase en una biblioteca estática es diferente de una clase en una biblioteca compartida.
Zan Lynx
1
@Crashworks Solo para tener un registro completo aquí: ¿qué compilador (y qué versión) fue ese?
H. Guijt
@Crashworks secunda la solicitud de información sobre qué compilador produjo los resultados observados; Gracias.
underscore_d
@underscore_d: MSVC.
Crashworks
9

C ++ se basa en la idea de la verificación de tipos estáticos.

[1] RTTI, es decir, dynamic_casty type_ides verificación de tipo dinámica.

Entonces, esencialmente se pregunta por qué la verificación de tipo estática es preferible a la verificación de tipo dinámica. Y la respuesta simple es, si la verificación de tipo estática es preferible a la verificación de tipo dinámica, depende . En mucho. Pero C ++ es uno de los lenguajes de programación que están diseñados en torno a la idea de la verificación de tipos estáticos. Y esto significa que, por ejemplo, el proceso de desarrollo, en particular las pruebas, normalmente se adapta a la verificación de tipos estáticos y luego se ajusta mejor.


Re

" No sabría una forma clara de hacer esto con plantillas u otros métodos

puede hacer este proceso-nodos-heterogéneos-de-un-gráfico con verificación de tipo estático y sin conversión de ningún tipo a través del patrón de visitante, por ejemplo, así:

#include <iostream>
#include <set>
#include <initializer_list>

namespace graph {
    using std::set;

    class Red_thing;
    class Yellow_thing;
    class Orange_thing;

    struct Callback
    {
        virtual void handle( Red_thing& ) {}
        virtual void handle( Yellow_thing& ) {}
        virtual void handle( Orange_thing& ) {}
    };

    class Node
    {
    private:
        set<Node*> connected_;

    public:
        virtual void call( Callback& cb ) = 0;

        void connect_to( Node* p_other )
        {
            connected_.insert( p_other );
        }

        void call_on_connected( Callback& cb )
        {
            for( auto const p : connected_ ) { p->call( cb ); }
        }

        virtual ~Node(){}
    };

    class Red_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        auto redness() -> int { return 255; }
    };

    class Yellow_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }
    };

    class Orange_thing
        : public Red_thing
        , public Yellow_thing
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        void poke() { std::cout << "Poked!\n"; }

        void poke_connected_orange_things()
        {
            struct Poker: Callback
            {
                void handle( Orange_thing& obj ) override
                {
                    obj.poke();
                }
            } poker;

            call_on_connected( poker );
        }
    };
}  // namespace graph

auto main() -> int
{
    using namespace graph;

    Red_thing   r;
    Yellow_thing    y1, y2;
    Orange_thing    o1, o2, o3;

    for( Node* p : std::initializer_list<Node*>{ &y1, &y2, &r, &o2, &o3 } )
    {
        o1.connect_to( p );
    }
    o1.poke_connected_orange_things();
}

Esto supone que se conoce el conjunto de tipos de nodos.

Cuando no lo es, el patrón de visitantes (hay muchas variaciones) se puede expresar con unos pocos lanzamientos centralizados, o solo uno.


Para un enfoque basado en plantillas, consulte la biblioteca Boost Graph. Es triste decir que no estoy familiarizado con él, no lo he usado. Así que no estoy seguro exactamente de qué hace y cómo, y en qué grado usa la verificación de tipo estático en lugar de RTTI, pero dado que Boost generalmente se basa en plantillas con la verificación de tipo estático como la idea central, creo que encontrará que su sub-biblioteca Graph también se basa en la verificación de tipos estáticos.


[1] Información del tipo de tiempo de ejecución .

Saludos y hth. - Alf
fuente
1
Una "cosa graciosa" a tener en cuenta es que se puede reducir la cantidad de código (cambios al agregar tipos) necesaria para el patrón de visitante mediante el uso de RTTI para "escalar" una jerarquía. Lo sé como "patrón de visitante acíclico".
Daniel Jour
3

Por supuesto, hay un escenario en el que el polimorfismo no puede ayudar: los nombres. typeidle permite acceder al nombre del tipo, aunque la forma en que se codifica este nombre está definida por la implementación. Pero generalmente esto no es un problema, ya que puede comparar dos typeid-s:

if ( typeid(5) == "int" )
    // may be false

if ( typeid(5) == typeid(int) )
   // always true

Lo mismo se aplica a los hash.

[...] RTTI se "considera perjudicial"

dañina es, sin duda una exageración: RTTI tiene algunos inconvenientes, pero lo hace tener ventajas también.

Realmente no tiene que usar RTTI. RTTI es una herramienta para resolver problemas de programación orientada a objetos: si utiliza otro paradigma, estos probablemente desaparecerán. C no tiene RTTI, pero aún funciona. En cambio, C ++ es totalmente compatible con OOP y le brinda múltiples herramientas para superar algunos problemas que pueden requerir información de tiempo de ejecución: una de ellas es RTTI, que aunque tiene un precio. Si no puede pagarlo, lo que es mejor que declare solo después de un análisis de rendimiento seguro, todavía existe la vieja escuela void*: es gratis. Gratuito. Pero no obtienes seguridad de tipo. Así que se trata de intercambios.


  • Algunos compiladores no usan / RTTI no siempre está habilitado
    . Realmente no compro este argumento. Es como decir que no debería usar las funciones de C ++ 14, porque hay compiladores que no lo admiten. Y, sin embargo, nadie me disuadiría de usar las funciones de C ++ 14.

Si escribe (posiblemente estrictamente) código C ++ conforme, puede esperar el mismo comportamiento independientemente de la implementación. Las implementaciones que cumplen con los estándares deben admitir las características estándar de C ++.

Pero tenga en cuenta que en algunos entornos C ++ define (los «independientes»), no es necesario proporcionar RTTI y tampoco las excepciones, virtualetc. RTTI necesita una capa subyacente para funcionar correctamente que se ocupa de los detalles de bajo nivel, como el ABI y la información de tipo real.


Estoy de acuerdo con Yakk con respecto a RTTI en este caso. Sí, podría usarse; pero ¿es lógicamente correcto? El hecho de que el idioma le permita omitir esta verificación no significa que deba hacerlo.

edmz
fuente