Por que o 'polimorfismo puro' é preferível ao uso de RTTI?

106

Quase todos os recursos C ++ que vi que discutem esse tipo de coisa me dizem que eu deveria preferir abordagens polimórficas ao usar RTTI (identificação de tipo em tempo de execução). Em geral, levo esse tipo de conselho a sério e tentarei entender a lógica - afinal, C ++ é uma besta poderosa e difícil de entender em toda a sua profundidade. No entanto, para esta questão em particular, estou em branco e gostaria de ver que tipo de conselho a Internet pode oferecer. Em primeiro lugar, deixe-me resumir o que aprendi até agora, listando os motivos comuns citados pelos quais o RTTI é "considerado prejudicial":

Alguns compiladores não o usam / RTTI nem sempre está habilitado

Eu realmente não aceito esse argumento. É como dizer que não devo usar os recursos do C ++ 14, porque existem compiladores por aí que não os suportam. E ainda, ninguém me desencorajaria de usar os recursos do C ++ 14. A maioria dos projetos terá influência sobre o compilador que estão usando e como ele está configurado. Mesmo citando a página de manual do gcc:

-fno-rtti

Desative a geração de informações sobre cada classe com funções virtuais para uso pelos recursos de identificação de tipo em tempo de execução C ++ (dynamic_cast e typeid). Se você não usa essas partes da linguagem, pode economizar espaço usando este sinalizador. Observe que o tratamento de exceções usa as mesmas informações, mas o G ++ as gera conforme necessário. O operador dynamic_cast ainda pode ser usado para conversões que não requerem informações de tipo de tempo de execução, ou seja, conversões para "void *" ou para classes de base não ambíguas.

O que isso me diz é que, se não estiver usando RTTI, posso desativá-lo. É como dizer que, se você não estiver usando o Boost, não será necessário criar um link para ele. Não tenho que planejar o caso em que alguém está compilando com -fno-rtti. Além disso, o compilador falhará em alto e bom som neste caso.

Custa memória extra / pode ser lento

Sempre que fico tentado a usar RTTI, isso significa que preciso acessar algum tipo de informação ou característica de minha classe. Se eu implementar uma solução que não use RTTI, isso geralmente significa que terei que adicionar alguns campos às minhas classes para armazenar essas informações, então o argumento de memória é meio vazio (darei um exemplo disso mais adiante).

Um dynamic_cast pode ser lento, de fato. No entanto, geralmente há maneiras de evitar ter de usá-lo em situações críticas de velocidade. E não vejo bem a alternativa. Essa resposta do SO sugere o uso de um enum, definido na classe base, para armazenar o tipo. Isso só funciona se você conhecer todas as suas classes derivadas a priori. É um grande "se"!

A partir dessa resposta, parece também que o custo do RTTI também não está claro. Pessoas diferentes medem coisas diferentes.

Desenhos polimórficos elegantes tornarão o RTTI desnecessário

Esse é o tipo de conselho que levo a sério. Nesse caso, simplesmente não consigo encontrar boas soluções não RTTI que cubram meu caso de uso RTTI. Deixe-me dar um exemplo:

Digamos que estou escrevendo uma biblioteca para lidar com gráficos de algum tipo de objeto. Quero permitir que os usuários gerem seus próprios tipos ao usar minha biblioteca (portanto, o método enum não está disponível). Eu tenho uma classe base para meu nó:

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

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

Agora, meus nós podem ser de tipos diferentes. Que tal estes:

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);
};

Inferno, por que nem mesmo um destes:

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

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

A última função é interessante. Esta é uma maneira de escrever:

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();
        }
    }
}

Tudo isso parece claro e limpo. Não preciso definir atributos ou métodos onde não preciso deles, a classe de nó base pode permanecer enxuta e média. Sem RTTI, por onde eu começo? Talvez eu possa adicionar um atributo node_type à classe 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;
};

Std :: string é uma boa ideia para um tipo? Talvez não, mas o que mais posso usar? Invente um número e espera que ninguém mais o esteja usando ainda? Além disso, no caso do meu orange_node, e se eu quiser usar os métodos de red_node e yellow_node? Eu teria que armazenar vários tipos por nó? Isso parece complicado.

Conclusão

Esses exemplos não parecem muito complexos ou incomuns (estou trabalhando em algo semelhante em meu trabalho diário, onde os nós representam o hardware real que é controlado pelo software e que faz coisas muito diferentes dependendo do que são). No entanto, eu não saberia uma maneira limpa de fazer isso com modelos ou outros métodos. Observe que estou tentando entender o problema, não defender meu exemplo. Minha leitura de páginas como a resposta SO que criei no link acima e esta página no Wikilivros parecem sugerir que estou usando RTTI incorretamente, mas gostaria de saber por quê.

Então, de volta à minha pergunta original: por que o 'polimorfismo puro' é preferível ao uso de RTTI?

mbr0wn
fonte
9
O que você está "perdendo" (como um recurso de linguagem) para resolver o seu exemplo de laranjas picadas seria o envio múltiplo ("multimétodos"). Portanto, procurar maneiras de emular isso pode ser uma alternativa. Normalmente, o padrão de visitante é usado portanto.
Daniel Jour
1
Usar uma string como tipo não é muito útil. Usar um ponteiro para uma instância de alguma classe de "tipo" tornaria isso mais rápido. Mas então você está basicamente fazendo manualmente o que o RTTI estaria fazendo.
Daniel Jour
4
@MargaretBloom Não, não vai, RTTI significa Runtime Type Information, enquanto CRTP é apenas para modelos - tipos estáticos, então.
edmz
2
@ mbr0wn: todos os processos de engenharia são limitados por algumas regras; a programação não é uma exceção. As regras podem ser divididas em dois grupos: regras suaves (DEVE) e regras rígidas (DEVE). (Há também um balde de conselhos / opções (PODERIA), por assim dizer.) Leia como o padrão C / C ++ (ou qualquer outro padrão de engenharia, pelo fato) os define. Acho que seu problema vem do fato de que você confundiu "não use RTTI" como uma regra rígida ("você NÃO DEVE usar RTTI"). Na verdade, é uma regra flexível ("você NÃO DEVE usar RTTI"), o que significa que você deve evitá-la sempre que possível - e apenas usar quando não puder evitar
3
Observo que muitas respostas não notam a ideia de que seu exemplo sugere que node_baseseja parte de uma biblioteca e que os usuários farão seus próprios tipos de nó. Então, eles não podem modificar node_basepara permitir outra solução, então talvez RTTI se torne sua melhor opção. Por outro lado, existem outras maneiras de projetar essa biblioteca de modo que novos tipos de nós possam se encaixar com muito mais elegância, sem a necessidade de usar RTTI (e outras maneiras de projetar os novos tipos de nós também).
Matthew Walton

Respostas:

69

Uma interface descreve o que é preciso saber para interagir em uma determinada situação no código. Depois de estender a interface com "toda a sua hierarquia de tipo", a "área de superfície" da interface torna-se enorme, o que torna o raciocínio mais difícil .

Por exemplo, seu "cutucar laranjas adjacentes" significa que eu, como terceiro, não posso emular ser uma laranja! Você declarou privativamente um tipo laranja e, em seguida, usa RTTI para fazer seu código se comportar de maneira especial ao interagir com esse tipo. Se eu quiser "ser laranja", que deve estar dentro de seu jardim privado.

Agora, todo mundo que acopla com a "laranja" se acopla com todo o seu tipo de laranja e, implicitamente, com todo o seu jardim privado, em vez de com uma interface definida.

Embora à primeira vista isso pareça uma ótima maneira de estender a interface limitada sem ter que mudar todos os clientes (adicionar am_I_orange), o que tende a acontecer em vez disso é que ossifica a base do código e evita outras extensões. A laranja especial torna-se inerente ao funcionamento do sistema e evita que você crie um substituto "tangerina" para o laranja que é implementado de forma diferente e talvez remova uma dependência ou resolva algum outro problema com elegância.

Isso significa que sua interface deve ser suficiente para resolver seu problema. Dessa perspectiva, por que você só precisa cutucar laranjas e, se sim, por que a laranja não estava disponível na interface? Se precisar de algum conjunto difuso de tags que pode ser adicionado ad-hoc, você pode adicioná-lo ao seu tipo:

class node_base {
  public:
    bool has_tag(tag_name);

Isso fornece uma ampliação massiva semelhante de sua interface de estritamente especificada para ampla baseada em tag. Exceto em vez de fazer isso por meio de RTTI e detalhes de implementação (também conhecido como "como você está implementado? Com ​​o tipo laranja? Ok, você passa."), Ele faz isso com algo facilmente emulado por meio de uma implementação completamente diferente.

Isso pode até ser estendido a métodos dinâmicos, se necessário. "Você apóia ser enganado com argumentos Baz, Tom e Alice? Ok, enganando você." Em um sentido amplo, isso é menos intrusivo do que um elenco dinâmico para chegar ao fato de que o outro objeto é um tipo que você conhece.

Agora, os objetos tangerina podem ter a etiqueta laranja e jogar junto, enquanto são desacoplados da implementação.

Isso ainda pode levar a uma grande confusão, mas é pelo menos uma confusão de mensagens e dados, não hierarquias de implementação.

Abstração é um jogo de desacoplamento e ocultação de irrelevâncias. Isso torna o código mais fácil de raciocinar localmente. O RTTI está abrindo um buraco direto da abstração para os detalhes de implementação. Isso pode tornar a solução de um problema mais fácil, mas tem o custo de prendê-lo em uma implementação específica com muita facilidade.

Yakk - Adam Nevraumont
fonte
14
1 para o último parágrafo; não só porque concordo com você, mas porque é o martelo no prego aqui.
7
Como alguém obtém uma funcionalidade específica depois de saber que um objeto está marcado como compatível com essa funcionalidade? Ou isso envolve fundição, ou existe uma classe Deus com todas as funções-membro possíveis. A primeira possibilidade é a conversão desmarcada, caso em que a marcação é apenas o próprio esquema de verificação de tipo dinâmico muito falível, ou é marcada dynamic_cast(RTTI), caso em que as tags são redundantes. A segunda possibilidade, uma classe de Deus, é abominável. Resumindo, essa resposta tem muitas palavras que acho que soam bem para os programadores Java, mas o conteúdo real não tem sentido.
Saúde e hth. - Alf
2
@Falco: Essa é (uma variante da) a primeira possibilidade que mencionei, a conversão não verificada com base na tag. Aqui, a marcação é o próprio esquema de verificação de tipo dinâmico muito frágil e muito falível. Qualquer pequeno comportamento incorreto do código do cliente, e em C ++, um está errado na terra do UB. Você não obtém exceções, como pode obter em Java, mas o comportamento indefinido, como travamentos e / ou resultados incorretos. Além de ser extremamente confiável e perigoso, também é extremamente ineficiente, em comparação com o código C ++ mais lógico. IOW., É muito nada bom; extremamente.
Saúde e hth. - Alf
1
Uhm. :) Tipos de argumentos?
Saúde e hth. - Alf
2
@JojOatXGME: Porque "polimorfismo" significa ser capaz de trabalhar com uma variedade de tipos. Se você tiver que verificar se é um tipo específico , além da verificação de tipo já existente que você usou para obter o ponteiro / referência para começar, então você está olhando para trás do polimorfismo. Você não está trabalhando com uma variedade de tipos; você está trabalhando com um tipo específico . Sim, existem "(grandes) projetos em Java" que fazem isso. Mas isso é Java ; a linguagem só permite polimorfismo dinâmico. C ++ também possui polimorfismo estático. Além disso, só porque alguém "grande" faz isso não significa que seja uma boa ideia.
Nicol Bolas
31

A maior parte da persuasão moral contra este ou aquele traço é tipicamente originada da observação de que há uma série de usos mal concebidos desse traço.

Onde os moralistas falham é que presumem que TODOS os usos são mal concebidos, enquanto na verdade os recursos existem por uma razão.

Eles têm o que eu costumava chamar de "complexo do encanador": acham que todas as torneiras estão funcionando mal porque todas as torneiras que foram chamados para consertar estão. A realidade é que a maioria das torneiras funcionam bem: você simplesmente não chama um encanador para elas!

Uma coisa maluca que pode acontecer é quando, para evitar o uso de um determinado recurso, os programadores escrevem muito código clichê, na verdade, reimplementando de forma privada exatamente esse recurso. (Você já conheceu classes que não usam RTTI nem chamadas virtuais, mas têm um valor para rastrear qual tipo derivado real são? Isso não é mais do que reinvenção RTTI disfarçada.)

Há uma maneira geral a pensar sobre o polimorfismo: IF(selection) CALL(something) WITH(parameters). (Desculpe, mas a programação, ao desconsiderar a abstração, é tudo sobre isso)

O uso de polimorfismo de tempo de design (conceitos) em tempo de compilação (baseado em dedução de modelo), tempo de execução (herança e função virtual) ou orientado a dados (RTTI e comutação), depende de quanto das decisões são conhecidas em cada uma das etapas da produção e quão variáveis são em cada contexto.

A ideia é que:

quanto mais você antecipar, melhor será a chance de detectar erros e evitar bugs que afetem o usuário final.

Se tudo for constante (incluindo os dados), você pode fazer tudo com meta-programação de modelo. Depois que a compilação ocorreu em constantes atualizadas, o programa inteiro se reduz a apenas uma instrução de retorno que expõe o resultado .

Se houver vários casos que são conhecidos em tempo de compilação , mas você não sabe sobre os dados reais sobre os quais eles devem agir, então o polimorfismo em tempo de compilação (principalmente CRTP ou similar) pode ser uma solução.

Se a seleção dos casos depende dos dados (não de valores conhecidos em tempo de compilação) e a comutação é monodimensional (o que fazer pode ser reduzido a apenas um valor), então o despacho baseado em função virtual (ou em geral "tabelas de ponteiros de função ") é preciso.

Se a comutação for multidimensional, uma vez que não existe despacho de tempo de execução múltiplo nativo em C ++, você deve:

  • Reduzir a uma dimensão por goedelização : é onde estão as bases virtuais e a herança múltipla, com diamantes e paralelogramos empilhados , mas isso requer que o número de combinações possíveis seja conhecido e seja relativamente pequeno.
  • Encadeie as dimensões uma na outra (como no padrão de visitantes compostos, mas isso requer que todas as classes estejam cientes de seus outros irmãos, portanto, não pode "escalar" a partir do local em que foi concebido)
  • Despache chamadas com base em vários valores. É exatamente para isso que serve o RTTI.

Se não apenas a troca, mas até mesmo as ações não sejam conhecidas em tempo de compilação, então o script e a análise são necessários: os próprios dados devem descrever a ação a ser executada neles.

Agora, como cada um dos casos que enumerei pode ser visto como um caso particular do que se segue, você pode resolver todos os problemas abusando da solução mais inferior também para problemas acessíveis com a mais alta.

Isso é o que a moralização realmente empurra para evitar. Mas isso não significa que os problemas que vivem nos domínios inferiores não existam!

Bashing RTTI apenas para atacá-lo é como gotoatacar apenas para atacá-lo. Coisas para papagaios, não programadores.

Emilio Garavaglia
fonte
Uma boa descrição dos níveis em que cada abordagem é aplicável. Eu nunca ouvi falar de "Goedelização" - ela também é conhecida por algum outro nome? Você poderia adicionar um link ou mais explicação? Obrigado :)
j_random_hacker
1
@j_random_hacker: Eu também estou curioso sobre o uso da Godelização. Normalmente, pensa-se na godelização como primeiro, mapeando de alguma string para algum inteiro e, em segundo lugar, usando essa técnica para produzir declarações autorreferenciais em linguagens formais. Não estou familiarizado com este termo no contexto de envio virtual e adoraria saber mais.
Eric Lippert,
1
Na verdade, estou abusando do termo: de acordo com Goedle, uma vez que todo número inteiro corresponde a um inteiro n-ple (as potências de seus fatores primos) e todo n-ple corresponde a um inteiro, todo problema de indexação n-dimensional discreto pode ser reduzido a um unidimensional . Isso não significa que esta seja a única forma de o fazer: é apenas uma forma de dizer "é possível". Tudo que você precisa é um mecanismo de "dividir para conquistar". as funções virtuais são a "divisão" e a herança múltipla é a "conquista".
Emilio Garavaglia
... Quando tudo isso acontece dentro de um campo finito (um intervalo), as combinações lineares são mais eficazes (o clássico i = r * C + c obtendo o índice em um array da célula de uma matriz). Nesse caso, a divisão id o "visitante" e a conquista é o "composto". Como a álgebra linear está envolvida, a técnica neste caso corresponde à "diagonalização"
Emilio Garavaglia
Não pense em tudo isso como técnicas. São apenas analogias
Emilio Garavaglia
23

Parece legal em um pequeno exemplo, mas na vida real você logo terminará com um longo conjunto de tipos que podem cutucar uns aos outros, alguns deles talvez apenas em uma direção.

Sobre dark_orange_node, ou black_and_orange_striped_node, ou dotted_node? Ele pode ter pontos de cores diferentes? E se a maioria dos pontos for laranja, ele pode ser cutucado então?

E cada vez que você precisar adicionar uma nova regra, terá que revisitar todas as poke_adjacentfunções e adicionar mais instruções if.


Como sempre, é difícil criar exemplos genéricos, vou te dar isso.

Mas se eu fizesse esse exemplo específico , adicionaria um poke()membro a todas as classes e deixaria que alguns deles ignorassem a chamada ( void poke() {}) se não estivessem interessados.

Certamente isso seria ainda mais barato do que comparar o typeids.

Bo Persson
fonte
3
Você diz "com certeza", mas o que o torna tão certo? Isso é realmente o que estou tentando descobrir. Digamos que eu renomeie orange_node para pokable_node, e eles são os únicos que posso chamar de poke (). Isso significa que minha interface precisará implementar um método poke () que, digamos, lance uma exceção ("este nó não é pokável"). Isso parece mais caro.
mbr0wn
2
Por que ele precisaria lançar uma exceção? Se você se preocupou se a interface é "habilitada para cutucar" ou não, basta adicionar uma função "isPokeable" e chamá-la antes de chamar a função cutucar. Ou apenas faça o que ele diz e "não faça nada, em classes não pokáveis".
Brandon
1
@ mbr0wn: A melhor pergunta é por que você deseja que os nós pokáveis ​​e não pokáveis ​​compartilhem a mesma classe base.
Nicol Bolas
2
@NicolBolas Por que você gostaria que monstros amigáveis ​​e hostis compartilhassem a mesma classe base, ou elementos de IU focalizáveis ​​e não focalizáveis, ou teclados com teclado numérico e teclados sem teclado numérico?
user253751
1
@ mbr0wn Isso soa como padrão de comportamento. A interface base tem dois métodos supportsBehavioure invokeBehavioure cada classe pode ter uma lista de comportamentos. Um comportamento seria Poke e poderia ser adicionado à lista de Behaviors suportados por todas as classes que desejam ser pokáveis.
Falco
20

Alguns compiladores não o usam / RTTI nem sempre está habilitado

Acredito que você tenha entendido mal esses argumentos.

Existem vários locais de codificação C ++ onde o RTTI não deve ser usado. Onde as opções do compilador são usadas para desabilitar o RTTI à força. Se você está codificando dentro de tal paradigma ... então quase certamente você já foi informado desta restrição.

O problema, portanto, é com as bibliotecas . Ou seja, se você está escrevendo uma biblioteca que depende de RTTI, sua biblioteca não pode ser usada por usuários que desligam o RTTI. Se você deseja que sua biblioteca seja usada por essas pessoas, ela não pode usar RTTI, mesmo que sua biblioteca também seja usada por pessoas que podem usar RTTI. Igualmente importante, se você não pode usar o RTTI, terá que pesquisar um pouco mais as bibliotecas, já que o uso do RTTI é um obstáculo para você.

Custa memória extra / pode ser lento

Há muitas coisas que você não faz em hot loops. Você não aloca memória. Você não fica iterando em listas vinculadas. E assim por diante. O RTTI certamente pode ser mais uma daquelas coisas "não faça isso aqui".

No entanto, considere todos os seus exemplos de RTTI. Em todos os casos, você tem um ou mais objetos de tipo indeterminado e deseja realizar alguma operação neles que pode não ser possível para alguns deles.

Isso é algo que você tem que trabalhar em um nível de design . Você pode escrever contêineres que não alocam memória que se encaixa no paradigma "STL". Você pode evitar estruturas de dados de listas vinculadas ou limitar seu uso. Você pode reorganizar arrays de structs em structs de arrays ou qualquer outra coisa. Ele muda algumas coisas, mas você pode mantê-lo compartimentado.

Mudar uma operação RTTI complexa em uma chamada de função virtual regular? Esse é um problema de design. Se você tiver que mudar isso, então é algo que requer mudanças para todas as classes derivadas. Ele muda como muitos códigos interagem com várias classes. O escopo de tal mudança se estende muito além das seções críticas de desempenho do código.

Então ... por que você escreveu da maneira errada para começar?

Não preciso definir atributos ou métodos onde não preciso deles, a classe de nó base pode permanecer enxuta e média.

Para quê?

Você diz que a classe base é "enxuta e média". Mas realmente ... é inexistente . Na verdade, não faz nada .

Basta olhar para o exemplo: node_base. O que é isso? Parece ser uma coisa que tem outras coisas adjacentes. Esta é uma interface Java (Java pré-genérico): uma classe que existe apenas para ser algo que os usuários possam lançar no tipo real . Talvez você adicione algum recurso básico como adjacência (Java adicionaToString ), mas é isso.

Há uma diferença entre "enxuto e médio" e "transparente".

Como disse Yakk, esses estilos de programação se limitam à interoperabilidade, porque se toda a funcionalidade estiver em uma classe derivada, os usuários fora desse sistema, sem acesso a essa classe derivada, não podem interoperar com o sistema. Eles não podem substituir funções virtuais e adicionar novos comportamentos. Eles nem conseguem chamar essas funções.

Mas o que eles também fazem é tornar muito difícil fazer coisas novas, mesmo dentro do sistema. Considere sua poke_adjacent_orangesfunção. O que acontece se alguém quiser um lime_nodetipo que possa ser cutucado como o orange_nodes? Bem, não podemos derivar lime_nodedeorange_node ; isso não faz sentido.

Em vez disso, temos que adicionar um novo lime_nodederivado de node_base. Em seguida, altere o nome de poke_adjacent_orangespara poke_adjacent_pokables. E então, tente lançar para orange_nodee lime_node; qualquer que seja o elenco, é aquele que cutucamos.

No entanto, lime_nodeprecisa do seu próprio poke_adjacent_pokables . E essa função precisa fazer as mesmas verificações de fundição.

E se adicionarmos um terceiro tipo, temos que não apenas adicionar sua própria função, mas devemos mudar as funções nas outras duas classes.

Obviamente, agora você faz poke_adjacent_pokables uma função livre, para que funcione para todos eles. Mas o que você acha que acontece se alguém adiciona um quarto tipo e se esquece de adicioná-lo a essa função?

Olá, quebra silenciosa . O programa parece funcionar mais ou menos bem, mas não está. Se pokefosse uma função virtual real , o compilador teria falhado se você não substituísse a função virtual pura de node_base.

Do seu jeito, você não tem tais verificações do compilador. Claro, o compilador não verificará se há virtuais não puros, mas pelo menos você tem proteção nos casos em que a proteção é possível (ou seja: não há operação padrão).

O uso de classes base transparentes com RTTI leva a um pesadelo de manutenção. Na verdade, a maioria dos usos do RTTI leva a dores de cabeça de manutenção. Isso não significa que o RTTI não seja útil (é vital para fazer boost::anyfuncionar, por exemplo). Mas é uma ferramenta muito especializada para muito especializada para necessidades especializadas.

Dessa forma, é "prejudicial" da mesma forma que goto. É uma ferramenta útil que não deve ser descartada. Mas seu uso deve ser raro em seu código.


Portanto, se você não pode usar classes base transparentes e conversão dinâmica, como evitar interfaces gordas? Como você evita que todas as funções que você deseja chamar em um tipo borbulhem até a classe base?

A resposta depende da finalidade da classe base.

Classes de base transparentes como node_baseestão apenas usando a ferramenta errada para o problema. Listas vinculadas são mais bem tratadas por modelos. O tipo de nó e a adjacência seriam fornecidos por um tipo de modelo. Se quiser colocar um tipo polimórfico na lista, você pode. Basta usar BaseClass*comoT no argumento do modelo. Ou seu ponteiro inteligente preferido.

Mas existem outros cenários. Um é um tipo que faz muitas coisas, mas tem algumas partes opcionais. Uma determinada instância pode implementar certas funções, enquanto outra não. No entanto, o design de tais tipos geralmente oferece uma resposta adequada.

A classe "entidade" é um exemplo perfeito disso. Esta classe há muito atormenta os desenvolvedores de jogos. Conceitualmente, ele tem uma interface gigantesca, vivendo na interseção de quase uma dúzia de sistemas totalmente díspares. E diferentes entidades têm diferentes propriedades. Algumas entidades não têm nenhuma representação visual, portanto, suas funções de renderização não fazem nada. E tudo isso é determinado em tempo de execução.

A solução moderna para isso é um sistema do tipo componente. Entityé meramente um contêiner de um conjunto de componentes, com alguma cola entre eles. Alguns componentes são opcionais; uma entidade que não possui representação visual não possui o componente "gráfico". Uma entidade sem IA não possui componente "controlador". E assim por diante.

As entidades em tal sistema são apenas ponteiros para componentes, com a maior parte de sua interface sendo fornecida acessando os componentes diretamente.

O desenvolvimento de tal sistema de componentes requer o reconhecimento, no estágio de design, de que certas funções são conceitualmente agrupadas, de modo que todos os tipos que implementam um irão implementá-las todas. Isso permite que você extraia a classe da classe base potencial e a torne um componente separado.

Isso também ajuda a seguir o Princípio da Responsabilidade Única. Essa classe componentizada tem apenas a responsabilidade de ser detentora de componentes.


De Matthew Walton:

Noto que muitas respostas não notam a ideia de que seu exemplo sugere que node_base é parte de uma biblioteca e os usuários farão seus próprios tipos de nó. Então, eles não podem modificar o node_base para permitir outra solução, então talvez o RTTI se torne sua melhor opção.

OK, vamos explorar isso.

Para que isso faça sentido, o que você precisa é uma situação em que alguma biblioteca L forneça um contêiner ou outro portador estruturado de dados. O usuário pode adicionar dados a este contêiner, iterar seu conteúdo, etc. No entanto, a biblioteca realmente não faz nada com esses dados; ele simplesmente gerencia sua existência.

Mas nem mesmo administra sua existência tanto quanto sua destruição . A razão é que, se espera-se que você use RTTI para tais propósitos, então você está criando classes que L desconhece. Isso significa que seu código aloca o objeto e o entrega a L para gerenciamento.

Agora, há casos em que algo assim é um design legítimo. Sinalização de eventos / passagem de mensagens, filas de trabalho com segurança de thread, etc. O padrão geral aqui é este: alguém está realizando um serviço entre duas partes de código que é apropriado para qualquer tipo, mas o serviço não precisa estar ciente dos tipos específicos envolvidos .

Em C, esse padrão é escrito void*e seu uso requer muito cuidado para não ser quebrado. Em C ++, esse padrão é escrito std::experimental::any(a ser escrito em breve std::any).

A maneira como isso deve funcionar é que L fornece uma node_baseclasse que recebe um anyque representa seus dados reais. Quando você recebe a mensagem, o item de trabalho da fila de encadeamento ou o que quer que esteja fazendo, você então converte anypara o tipo apropriado, que tanto o remetente quanto o receptor sabem.

Então, ao invés de derivar orange_nodea partir node_data, você simplesmente furar um orangedentro de node_data's anycampo de membro. O usuário final extrai e usa any_castpara convertê-lo em orange. Se o elenco falhar, então não foi orange.

Agora, se você estiver familiarizado com a implementação do any, provavelmente dirá, "ei, espere um minuto: usa RTTI any internamente para fazer o any_casttrabalho." Ao que eu respondo, "... sim".

Esse é o ponto de uma abstração . No fundo dos detalhes, alguém está usando RTTI. Mas no nível em que você deveria estar operando, o RTTI direto não é algo que você deveria estar fazendo.

Você deve usar tipos que fornecem a funcionalidade desejada. Afinal, você realmente não quer RTTI. O que você deseja é uma estrutura de dados que possa armazenar um valor de um determinado tipo, ocultá-lo de todos, exceto do destino desejado, e então ser convertido de volta para aquele tipo, com a verificação de que o valor armazenado realmente é desse tipo.

Isso é chamado any. Ele usa RTTI, mas usar anyé muito superior ao usar RTTI diretamente, pois se ajusta à semântica desejada de forma mais correta.

Nicol Bolas
fonte
10

Se você chamar uma função, como regra, você realmente não se importa com quais etapas precisas ela tomará, apenas que alguma meta de nível superior seja alcançada dentro de certas restrições (e como a função faz isso acontecer é realmente um problema nosso).

Quando você usa o RTTI para fazer uma pré-seleção de objetos especiais que podem fazer um determinado trabalho, enquanto outros no mesmo conjunto não podem, você está quebrando aquela visão confortável do mundo. De repente, o chamador deve saber quem pode fazer o quê, em vez de simplesmente dizer a seus lacaios para continuarem. Algumas pessoas ficam incomodadas com isso, e suspeito que essa seja uma grande parte da razão pela qual o RTTI é considerado um pouco sujo.

Existe um problema de desempenho? Talvez, mas nunca experimentei isso, e pode ser sabedoria de vinte anos atrás, ou de pessoas que honestamente acreditam que usar três instruções de montagem em vez de duas é um inchaço inaceitável.

Então, como lidar com isso ... Dependendo da sua situação, pode fazer sentido ter quaisquer propriedades específicas do nó agrupadas em objetos separados (ou seja, toda a API 'laranja' pode ser um objeto separado). O objeto raiz poderia então ter uma função virtual para retornar a API 'laranja', retornando nullptr por padrão para objetos não laranja.

Embora isso possa ser um exagero dependendo da sua situação, permitiria a você consultar no nível da raiz se um nó específico oferece suporte a uma API específica e, em caso afirmativo, executar funções específicas a essa API.

H. Guijt
fonte
6
Re: o custo de desempenho - medi dynamic_cast <> custando cerca de 2 µs em nosso aplicativo em um processador de 3GHz, o que é cerca de 1000 vezes mais lento do que verificar um enum. (Nosso aplicativo tem um prazo de loop principal de 11,1 ms, então nos preocupamos muito com microssegundos.)
Crashworks
6
O desempenho difere muito entre as implementações. O GCC usa uma comparação de ponteiro typeinfo que é rápida. MSVC usa comparações de string que não são rápidas. No entanto, o método MSVC funcionará com código vinculado a diferentes versões de bibliotecas, estáticas ou DLL, onde o método de ponteiro do GCC acredita que uma classe em uma biblioteca estática é diferente de uma classe em uma biblioteca compartilhada.
Zan Lynx
1
@Crashworks Só para ter um registro completo aqui: qual compilador (e qual versão) era esse?
H. Guijt
@Crashworks apoiando a solicitação de informações sobre qual compilador produziu seus resultados observados; obrigado.
underscore_d
@underscore_d: MSVC.
Crashworks de
9

C ++ é baseado na ideia de verificação de tipo estático.

[1] RTTI, ou seja, dynamic_castetype_id , é a verificação de tipo dinâmico.

Então, essencialmente, você está perguntando por que a verificação de tipo estática é preferível à verificação de tipo dinâmica. E a resposta simples é: depende se a verificação de tipo estática é preferível à verificação de tipo dinâmica . Em muito. Mas C ++ é uma das linguagens de programação projetadas em torno da ideia de verificação estática de tipo. E isso significa que, por exemplo, o processo de desenvolvimento, em particular o teste, é normalmente adaptado à verificação de tipo estática e, então, se ajusta melhor.


Eu não saberia uma maneira limpa de fazer isso com modelos ou outros métodos

você pode fazer este processo-heterogêneo-nós-de-um-gráfico com verificação de tipo estático e nenhuma conversão por meio do padrão de visitante, por exemplo:

#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();
}

Isso pressupõe que o conjunto de tipos de nós seja conhecido.

Quando não é, o padrão de visitante (há muitas variações dele) pode ser expresso com alguns lançamentos centralizados ou, apenas um único.


Para uma abordagem baseada em modelo, consulte a biblioteca Boost Graph. É triste dizer que não estou familiarizado com ele, não o usei. Portanto, não tenho certeza do que ele faz e como, e em que grau ele usa verificação de tipo estático em vez de RTTI, mas como o Boost é geralmente baseado em modelo com verificação de tipo estático como a ideia central, acho que você descobrirá que sua sub-biblioteca Graph também é baseada na verificação de tipo estático.


[1] Informações sobre o tipo de tempo de execução .

Saúde e hth. - Alf
fonte
1
Uma "coisa engraçada" de se notar é que é possível reduzir a quantidade de código (mudanças ao adicionar tipos) necessária para o padrão do visitante usando RTTI para "escalar" uma hierarquia. Eu conheço isso como "padrão de visitante acíclico".
Daniel Jour
3

Claro que existe um cenário em que o polimorfismo não pode ajudar: nomes. typeidpermite acessar o nome do tipo, embora a forma como esse nome é codificado seja definida pela implementação. Mas geralmente isso não é um problema, pois você pode comparar dois typeid-s:

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

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

O mesmo vale para hashes.

[...] RTTI é "considerado prejudicial"

prejudicial é definitivamente exagero: o RTTI tem algumas desvantagens, mas tem têm vantagens também.

Você realmente não precisa usar o RTTI. RTTI é uma ferramenta para resolver problemas de OOP: se você usar outro paradigma, eles provavelmente desaparecerão. C não tem RTTI, mas ainda funciona. Em vez disso, o C ++ oferece suporte total ao OOP e oferece várias ferramentas para superar alguns problemas que podem exigir informações de tempo de execução: uma delas é de fato RTTI, que, no entanto, tem um preço. Se você não pode pagar por isso, coisa que é melhor você afirmar somente após uma análise de desempenho segura, ainda existe a velha escola void*: é grátis. Sem custos. Mas você não tem segurança de tipo. Portanto, é tudo uma questão de comércio.


  • Alguns compiladores não usam / RTTI nem sempre está habilitado
    Eu realmente não aceito esse argumento. É como dizer que não devo usar os recursos do C ++ 14, porque existem compiladores por aí que não os suportam. E ainda, ninguém me desencorajaria de usar os recursos do C ++ 14.

Se você escrever (possivelmente estritamente) o código C ++, pode esperar o mesmo comportamento, independentemente da implementação. As implementações compatíveis com o padrão devem suportar os recursos padrão do C ++.

Mas considere que em alguns ambientes C ++ define (os «independentes»), RTTI não precisa ser fornecido e nem exceções, virtuale assim por diante. O RTTI precisa de uma camada subjacente para funcionar corretamente que lida com detalhes de baixo nível, como a ABI e as informações de tipo reais.


Concordo com Yakk em relação ao RTTI neste caso. Sim, pode ser usado; mas é logicamente correto? O fato de o idioma permitir que você ignore essa verificação não significa que isso deva ser feito.

edmz
fonte