Perché è preferibile il "polimorfismo puro" rispetto all'uso di RTTI?


106

Quasi ogni risorsa C ++ che ho visto che discute questo genere di cose mi dice che dovrei preferire approcci polimorfici all'uso di RTTI (identificazione del tipo in fase di esecuzione). In generale, prendo seriamente questo tipo di consiglio e cercherò di capire la logica - dopo tutto, il C ++ è una bestia potente e difficile da capire nella sua profondità. Tuttavia, per questa particolare domanda, disegno uno spazio vuoto e vorrei vedere che tipo di consiglio può offrire Internet. Innanzitutto, vorrei riassumere ciò che ho imparato finora, elencando i motivi comuni che vengono citati per cui RTTI è "considerato dannoso":

Alcuni compilatori non lo usano / RTTI non è sempre abilitato

Davvero non mi piace questo argomento. È come dire che non dovrei usare le funzionalità di C ++ 14, perché ci sono compilatori là fuori che non lo supportano. Eppure, nessuno mi scoraggerebbe dall'usare le funzionalità di C ++ 14. La maggior parte dei progetti avrà influenza sul compilatore che stanno utilizzando e su come è configurato. Anche citando la manpage di gcc:

-fno-rtti

Disabilita la generazione di informazioni su ogni classe con funzioni virtuali per l'uso da parte delle funzionalità di identificazione del tipo in fase di esecuzione C ++ (dynamic_cast e typeid). Se non usi queste parti della lingua, puoi risparmiare spazio usando questo flag. Si noti che la gestione delle eccezioni utilizza le stesse informazioni, ma G ++ le genera secondo necessità. L'operatore dynamic_cast può ancora essere utilizzato per i cast che non richiedono informazioni sul tipo di runtime, ad esempio i cast a "void *" o a classi base non ambigue.

Ciò che questo mi dice è che se non sto usando RTTI, posso disabilitarlo. È come dire, se non stai usando Boost, non devi collegarti ad esso. Non devo pianificare il caso in cui qualcuno sta compilando -fno-rtti. Inoltre, in questo caso il compilatore fallirà forte e chiaro.

Costa memoria extra / Può essere lento

Ogni volta che sono tentato di usare RTTI, significa che ho bisogno di accedere a qualche tipo di informazione sul tipo o tratto della mia classe. Se implemento una soluzione che non utilizza RTTI, di solito questo significa che dovrò aggiungere alcuni campi alle mie classi per memorizzare queste informazioni, quindi l'argomento della memoria è un po 'nullo (darò un esempio di questo più avanti).

Un dynamic_cast può essere davvero lento. Di solito ci sono modi per evitare di doverlo usare in situazioni critiche per la velocità, però. E non vedo bene l'alternativa. Questa risposta SO suggerisce di utilizzare un enum, definito nella classe base, per memorizzare il tipo. Funziona solo se conosci a priori tutte le tue classi derivate. È un bel "se"!

Da quella risposta, sembra anche che il costo di RTTI non sia chiaro. Persone diverse misurano cose diverse.

Eleganti design polimorfici renderanno superfluo RTTI

Questo è il tipo di consiglio che prendo sul serio. In questo caso, semplicemente non riesco a trovare buone soluzioni non RTTI che coprano il mio caso d'uso RTTI. Faccio un esempio:

Supponiamo che stia scrivendo una libreria per gestire i grafici di un qualche tipo di oggetti. Voglio consentire agli utenti di generare i propri tipi quando usano la mia libreria (quindi il metodo enum non è disponibile). Ho una classe base per il mio nodo:

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

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

Ora, i miei nodi possono essere di diversi tipi. Cosa ne pensi di questi:

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

Diavolo, perché nemmeno uno di questi:

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

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

L'ultima funzione è interessante. Ecco un modo per scriverlo:

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

Sembra tutto chiaro e pulito. Non devo definire attributi o metodi dove non ne ho bisogno, la classe del nodo base può rimanere snella e meschina. Senza RTTI, da dove comincio? Forse posso aggiungere un attributo node_type alla 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 è una buona idea per un tipo? Forse no, ma cos'altro posso usare? Crea un numero e speri che nessun altro lo stia ancora usando? Inoltre, nel caso del mio orange_node, cosa succede se voglio usare i metodi da red_node e yellow_node? Dovrei memorizzare più tipi per nodo? Sembra complicato.

Conclusione

Questi esempi non sembrano eccessivamente complessi o insoliti (sto lavorando a qualcosa di simile nel mio lavoro quotidiano, in cui i nodi rappresentano l'hardware reale che viene controllato attraverso il software e che fa cose molto diverse a seconda di cosa sono). Eppure non saprei un modo pulito per farlo con modelli o altri metodi. Tieni presente che sto cercando di capire il problema, non di difendere il mio esempio. La mia lettura di pagine come la risposta SO che ho collegato sopra e questa pagina su Wikibooks sembra suggerire che sto abusando di RTTI, ma vorrei sapere perché.

Quindi, tornando alla mia domanda iniziale: perché è preferibile il "polimorfismo puro" rispetto all'uso di RTTI?


9
Quello che ti "manca" (come caratteristica del linguaggio) per risolvere il tuo esempio di poke oranges sarebbe l'invio multiplo ("multimetodi"). Quindi, cercare modi per emulare potrebbe essere un'alternativa. Di solito, quindi, viene utilizzato il modello visitatore.
Daniel Jour

1
L'uso di una stringa come tipo non è molto utile. L'uso di un puntatore a un'istanza di una classe "tipo" renderebbe l'operazione più veloce. Ma in pratica stai facendo manualmente ciò che farebbe RTTI.
Daniel Jour

4
@MargaretBloom No, non lo farà, RTTI sta per Runtime Type Information mentre CRTP è solo per i modelli - tipi statici, quindi.
edmz

2
@ mbr0wn: tutti i processi di ingegneria sono vincolati da alcune regole; la programmazione non fa eccezione. Le regole possono essere suddivise in due segmenti: regole soft (DOVREBBE) e regole rigide (MUST). (C'è anche un bucket di consigli / opzioni (COULD), per così dire.) Leggi come lo standard C / C ++ (o qualsiasi altro standard ingegneristico, per il fatto) li definisce. Immagino che il tuo problema derivi dal fatto che hai sbagliato "non usare RTTI" come regola rigida ("NON USARE RTTI"). In realtà è una regola morbida ("NON DOVRESTE usare RTTI"), il che significa che dovresti evitarlo quando possibile - e usarlo quando non puoi evitarlo

3
Ho notato che molte risposte non node_baseprendono atto dell'idea che il tuo esempio suggerisce fa parte di una libreria e gli utenti creeranno i propri tipi di nodo. Quindi non possono modificare node_baseper consentire un'altra soluzione, quindi forse RTTI diventa la loro migliore opzione allora. D'altra parte, ci sono altri modi per progettare una tale libreria in modo che i nuovi tipi di nodo possano adattarsi in modo molto più elegante senza bisogno di usare RTTI (e anche altri modi per progettare i nuovi tipi di nodo).
Matthew Walton

Risposte:


69

Un'interfaccia descrive ciò che è necessario sapere per interagire in una determinata situazione nel codice. Una volta estesa l'interfaccia con "l'intera gerarchia dei tipi", la "superficie" dell'interfaccia diventa enorme, il che rende più difficile ragionare su di essa .

Ad esempio, il tuo "colpire arance adiacenti" significa che io, come terza parte, non posso emulare di essere un'arancia! Hai dichiarato privatamente un tipo arancione, quindi utilizza RTTI per fare in modo che il tuo codice si comporti in modo speciale quando interagisci con quel tipo. Se voglio essere "arancia", io devo essere all'interno del vostro giardino privato.

Ora tutti coloro che si accoppiano con "aranciate" si accoppiano con il tuo intero tipo di arancia e implicitamente con tutto il tuo giardino privato, invece che con un'interfaccia definita.

Mentre a prima vista questo sembra un ottimo modo per estendere l'interfaccia limitata senza dover cambiare tutti i client (aggiungendo am_I_orange), ciò che tende invece ad accadere è che ossifica la base di codice e impedisce ulteriori estensioni. L'arancia speciale diventa inerente al funzionamento del sistema e impedisce di creare un sostituto "mandarino" per l'arancia che viene implementato in modo diverso e forse rimuove una dipendenza o risolve elegantemente qualche altro problema.

Ciò significa che la tua interfaccia deve essere sufficiente per risolvere il tuo problema. Da questo punto di vista, perché è necessario colpire solo le arance e, in tal caso, perché l'arancia non era disponibile nell'interfaccia? Se hai bisogno di un insieme fuzzy di tag che possono essere aggiunti ad-hoc, puoi aggiungerlo al tuo tipo:

class node_base {
  public:
    bool has_tag(tag_name);

Ciò fornisce un enorme ampliamento simile della tua interfaccia da specificatamente ristretto a ampio basato su tag. Tranne che invece di farlo attraverso RTTI e dettagli di implementazione (aka, "come sei implementato? Con il tipo arancione? Ok, passi"), lo fa con qualcosa di facilmente emulato attraverso un'implementazione completamente diversa.

Questo può anche essere esteso ai metodi dinamici, se necessario. "Sostieni il fatto di essere imbrogliato con argomenti Baz, Tom e Alice? Ok, imbrogliati." In un certo senso, questo è meno invadente di un cast dinamico per capire il fatto che l'altro oggetto è un tipo che conosci.

Ora gli oggetti mandarino possono avere il tag arancione e giocare insieme, pur essendo disaccoppiati dall'implementazione.

Può ancora portare a un enorme pasticcio, ma è almeno un pasticcio di messaggi e dati, non gerarchie di implementazione.

L'astrazione è un gioco di disaccoppiamento e occultamento delle irrilevanze. Rende più facile ragionare sul codice a livello locale. RTTI sta scavando un buco direttamente attraverso l'astrazione nei dettagli di implementazione. Ciò può semplificare la risoluzione di un problema, ma ha il costo di bloccarti in un'implementazione specifica molto facilmente.


14
+1 per l'ultimo paragrafo; non solo perché sono d'accordo con te, ma perché qui è il martello sul chiodo.

7
Come si arriva a una particolare funzionalità una volta che si sa che un oggetto è etichettato come supporto di quella funzionalità? O questo implica il casting, o c'è una classe di Dio con ogni possibile funzione di membro. La prima possibilità è il casting non controllato, nel qual caso il tagging è solo il proprio schema di controllo dinamico del tipo molto fallibile, o è controllato dynamic_cast(RTTI), nel qual caso i tag sono ridondanti. La seconda possibilità, una classe di Dio, è abominevole. Riassumendo, questa risposta ha molte parole che penso suonino piacevoli ai programmatori Java, ma il contenuto effettivo è privo di significato.
Saluti e salute. - Alf

2
@Falco: Questa è (una variante di) la prima possibilità che ho menzionato, casting non controllato in base al tag. Qui l'etichettatura è il proprio schema di controllo dinamico del tipo molto fragile e molto fallibile. Qualsiasi piccolo comportamento scorretto del codice client, e in C ++ uno è disattivato in UB-land. Non si ottengono eccezioni, come si potrebbe ottenere in Java, ma un comportamento non definito, come arresti anomali e / o risultati errati. Oltre ad essere estremamente inaffidabile e pericoloso, è anche estremamente inefficiente, rispetto al codice C ++ più sano. IOW., È molto molto brutto; estremamente così.
Saluti e salute. - Alf

1
Uhm. :) Tipi di argomenti?
Saluti e salute. - Alf

2
@JojOatXGME: Perché "polimorfismo" significa essere in grado di lavorare con una varietà di tipi. Se devi controllare se si tratta di un tipo particolare , oltre al controllo del tipo già esistente che hai usato per ottenere il puntatore / riferimento per cominciare, allora stai guardando dietro il polimorfismo. Non stai lavorando con una varietà di tipi; stai lavorando con un tipo specifico . Sì, ci sono "(grandi) progetti in Java" che lo fanno. Ma questo è Java ; il linguaggio consente solo il polimorfismo dinamico. C ++ ha anche un polimorfismo statico. Inoltre, solo perché qualcuno "grande" lo fa, non è una buona idea.
Nicol Bolas

31

La maggior parte della persuasione morale contro questa o quella caratteristica sono la tipicità originata dall'osservazione che ci sono una serie di usi mal concepiti di quella caratteristica.

Dove i moralisti falliscono è che presumono che TUTTI gli usi siano mal concepiti, mentre in realtà le caratteristiche esistono per una ragione.

Hanno quello che ho chiamato il "complesso idraulico": pensano che tutti i rubinetti non funzionino bene perché tutti i rubinetti che sono chiamati a riparare lo sono. La realtà è che la maggior parte dei rubinetti funziona bene: semplicemente non chiami un idraulico per loro!

Una cosa folle che può accadere è quando, per evitare di utilizzare una determinata funzionalità, i programmatori scrivono molto codice standard in realtà reimplementando privatamente esattamente quella funzionalità. (Hai mai incontrato classi che non utilizzano RTTI né chiamate virtuali, ma hanno un valore per tenere traccia di quale tipo derivato effettivo sono? Non è altro che la reinvenzione RTTI sotto mentite spoglie.)

C'è un modo generale di pensare il polimorfismo: IF(selection) CALL(something) WITH(parameters). (Mi dispiace, ma la programmazione, quando si ignora l'astrazione, è tutto su questo)

L'uso del polimorfismo in fase di progettazione (concetti) in fase di compilazione (basato sulla deduzione del modello), run-time (ereditarietà e basato su funzioni virtuali) o basato sui dati (RTTI e commutazione), dipende dalla quantità di decisioni note in ciascuna delle fasi della produzione e quanto sono variabili in ogni contesto.

L'idea è che:

più puoi anticipare, maggiori sono le possibilità di rilevare errori ed evitare bug che interessano l'utente finale.

Se tutto è costante (compresi i dati) puoi fare tutto con la meta-programmazione dei template. Dopo che la compilazione è avvenuta su costanti attualizzate, l'intero programma si riduce a un'istruzione return che sputa il risultato .

Se ci sono un numero di casi che sono tutti noti in fase di compilazione , ma non si conoscono i dati effettivi su cui devono agire, allora il polimorfismo in fase di compilazione (principalmente CRTP o simili) può essere una soluzione.

Se la selezione dei casi dipende dai dati (non valori noti in fase di compilazione) e la commutazione è monodimensionale (cosa fare può essere ridotto a un solo valore) allora l'invio basato su funzioni virtuali (o in generale "tabelle di puntatori di funzione ") è necessario.

Se la commutazione è multidimensionale, poiché in C ++ non esistono invii di runtime multipli nativi , è necessario:

  • Riduci a una dimensione con Goedelization : è che si trovano le basi virtuali e l'ereditarietà multipla, con rombi e parallelogrammi sovrapposti , ma questo richiede che il numero di combinazioni possibili sia noto e sia relativamente piccolo.
  • Concatena le dimensioni l'una nell'altra (come nel modello visitatori compositi, ma questo richiede che tutte le classi siano consapevoli degli altri fratelli, quindi non può "scalare" dal luogo in cui è stato concepito)
  • Invio di chiamate basate su più valori. Questo è esattamente ciò a cui serve RTTI.

Se non solo il passaggio, ma anche le azioni non sono note al momento della compilazione, è necessario eseguire script e analisi : i dati stessi devono descrivere l'azione da intraprendere su di essi.

Ora, poiché ognuno dei casi che ho elencato può essere visto come un caso particolare di ciò che lo segue, puoi risolvere ogni problema abusando della soluzione più in basso anche per problemi abbordabili con il più in alto.

Questo è ciò che la moralizzazione effettivamente spinge ad evitare. Ma ciò non significa che non esistano problemi che vivono negli ultimi domini!

Colpire RTTI solo per colpirlo, è come colpire gotosolo per colpirlo. Cose per pappagalli, non per programmatori.


Un buon resoconto dei livelli a cui ogni approccio è applicabile. Non ho sentito parlare di "Goedelization" però - è conosciuto anche con un altro nome? Potresti forse aggiungere un link o più spiegazioni? Grazie :)
j_random_hacker

1
@j_random_hacker: Anch'io sono curioso di questo uso della Godelizzazione. Normalmente si pensa alla godelizzazione come primo, mappatura da una stringa a un numero intero, e secondo, utilizzando questa tecnica per produrre affermazioni autoreferenziali in linguaggi formali. Non ho familiarità con questo termine nel contesto dell'invio virtuale e mi piacerebbe saperne di più.
Eric Lippert

1
In effetti sto abusando del termine: secondo Goedle, poiché ogni numero intero corrisponde a un intero n-ple (le potenze dei suoi fattori primi) e ogni n-ple corrisponde a un numero intero, ogni problema di indicizzazione n-dimensionale discreto può essere ridotto a monodimensionale . Ciò non significa che questo sia l'unico modo per farlo: è solo un modo per dire "è possibile". Tutto ciò di cui hai bisogno è un meccanismo "divide et impera". le funzioni virtuali sono il "divide" e l'ereditarietà multipla è la "conquista".
Emilio Garavaglia

... Quando tutto ciò che accade all'interno di un campo finito (un intervallo) le combinazioni lineari sono più efficaci (il classico i = r * C + c che ottiene l'indice in un array della cella di una matrice). In questo caso, il divario è il "visitatore" e il vincitore è il "composto". Poiché è coinvolta l'algebra lineare, la tecnica in questo caso corrisponde alla "diagonalizzazione"
Emilio Garavaglia

Non pensare a tutti questi come a tecniche. Sono solo analogie
Emilio Garavaglia

23

Sembra abbastanza pulito in un piccolo esempio, ma nella vita reale ti ritroverai presto con una lunga serie di tipi che possono colpire l'un l'altro, alcuni dei quali forse solo in una direzione.

Che mi dici di dark_orange_node, o black_and_orange_striped_node, o dotted_node? Può avere punti di colori diversi? E se la maggior parte dei punti sono arancioni, allora si può colpire?

E ogni volta che devi aggiungere una nuova regola, dovrai rivisitare tutte le poke_adjacentfunzioni e aggiungere più istruzioni if.


Come sempre, è difficile creare esempi generici, te lo do.

Ma se dovessi fare questo esempio specifico , aggiungerei un poke()membro a tutte le classi e lascerei che alcune di loro ignorino call ( void poke() {}) se non sono interessate.

Sicuramente sarebbe anche meno costoso rispetto al confronto degli typeids.


3
Dici "sicuramente", ma cosa ti rende così sicuro? Questo è davvero quello che sto cercando di capire. Diciamo che rinomino orange_node in pokable_node e sono gli unici su cui posso chiamare poke (). Ciò significa che la mia interfaccia dovrà implementare un metodo poke () che, diciamo, genera un'eccezione ("questo nodo non è pokable"). Sembra più costoso.
mbr0wn

2
Perché avrebbe bisogno di lanciare un'eccezione? Se ti interessa se l'interfaccia è "poke -eable" o meno, aggiungi una funzione "isPokeable" e chiamala prima di chiamare la funzione poke. O semplicemente fare quello che dice e "non fare nulla, in classi non punzonabili".
Brandon

1
@ mbr0wn: La domanda migliore è perché vuoi che i nodi selezionabili e non attivabili condividano la stessa classe di base.
Nicol Bolas

2
@NicolBolas Perché vorresti che mostri amichevoli e ostili condividessero la stessa classe base, o elementi dell'interfaccia utente attivabili e non attivabili o tastiere con tastierino numerico e tastiere senza tastierino numerico?
user253751

1
@ mbr0wn Questo suona come un modello di comportamento. L'interfaccia di base ha due metodi supportsBehavioure invokeBehaviourogni classe può avere un elenco di comportamenti. Un comportamento potrebbe essere Poke e potrebbe essere aggiunto all'elenco dei comportamenti supportati da tutte le classi che vogliono essere colpite.
Falco

20

Alcuni compilatori non lo usano / RTTI non è sempre abilitato

Credo che tu abbia frainteso tali argomenti.

Esistono numerose posizioni di codifica C ++ in cui RTTI non deve essere utilizzato. Dove le opzioni del compilatore vengono utilizzate per disabilitare forzatamente RTTI. Se stai codificando all'interno di un tale paradigma ... allora quasi sicuramente sei già stato informato di questa restrizione.

Il problema quindi è con le biblioteche . Cioè, se stai scrivendo una libreria che dipende da RTTI, la tua libreria non può essere utilizzata dagli utenti che disattivano RTTI. Se vuoi che la tua libreria sia usata da quelle persone, allora non può usare RTTI, anche se la tua libreria viene usata anche da persone che possono usare RTTI. Altrettanto importante, se non puoi usare RTTI, devi guardarti intorno un po 'più difficile per le librerie, poiché l'uso di RTTI è un affare per te.

Costa memoria extra / Può essere lento

Ci sono molte cose che non fai negli hot loop. Non allochi memoria. Non continui a scorrere gli elenchi collegati. E così via. RTTI certamente può essere un'altra di quelle cose "non farlo qui".

Tuttavia, considera tutti i tuoi esempi RTTI. In tutti i casi, si dispone di uno o più oggetti di tipo indeterminato e si desidera eseguire su di essi un'operazione che potrebbe non essere possibile per alcuni di essi.

È qualcosa su cui devi lavorare a livello di design . È possibile scrivere contenitori che non allocano memoria che si adatta al paradigma "STL". È possibile evitare le strutture dati degli elenchi collegati o limitarne l'utilizzo. È possibile riorganizzare array di strutture in strutture di array o altro. Cambia alcune cose, ma puoi mantenerlo in compartimenti stagni.

Cambiare un'operazione RTTI complessa in una normale chiamata di funzione virtuale? Questo è un problema di progettazione. Se devi cambiarlo, allora è qualcosa che richiede modifiche a ogni classe derivata. Cambia il modo in cui un sacco di codice interagisce con le varie classi. L'ambito di tale modifica si estende ben oltre le sezioni di codice critiche per le prestazioni.

Allora ... perché l'hai scritto nel modo sbagliato per cominciare?

Non devo definire attributi o metodi dove non ne ho bisogno, la classe del nodo base può rimanere snella e meschina.

A che scopo?

Dici che la classe base è "snella e meschina". Ma davvero ... è inesistente . In realtà non fa nulla .

Basta guardare il tuo esempio: node_base. Che cos'è? Sembra essere una cosa che ha altre cose adiacenti. Questa è un'interfaccia Java (pre-generica Java in questo caso): una classe che esiste esclusivamente per essere qualcosa che gli utenti possono trasmettere al tipo reale . Forse aggiungi alcune funzionalità di base come l'adiacenza (Java aggiunge ToString), ma è tutto.

C'è una differenza tra "magro e meschino" e "trasparente".

Come ha detto Yakk, tali stili di programmazione si limitano nell'interoperabilità, perché se tutte le funzionalità sono in una classe derivata, gli utenti al di fuori di quel sistema, senza accesso a quella classe derivata, non possono interagire con il sistema. Non possono sovrascrivere le funzioni virtuali e aggiungere nuovi comportamenti. Non possono nemmeno chiamare quelle funzioni.

Ma quello che fanno è anche rendere un grosso problema fare effettivamente cose nuove, anche all'interno del sistema. Considera la tua poke_adjacent_orangesfunzione. Cosa succede se qualcuno vuole un lime_nodetipo che può essere colpito proprio come orange_nodes? Ebbene, non possiamo derivare lime_nodeda orange_node; non ha senso.

Invece, dobbiamo aggiungere un nuovo lime_nodederivato da node_base. Quindi cambia il nome di poke_adjacent_orangesin poke_adjacent_pokables. E poi, prova a trasmettere a orange_nodee lime_node; qualunque sia il cast che funziona è quello che colpiamo.

Tuttavia, ha lime_nodebisogno di essere proprio poke_adjacent_pokables . E questa funzione deve eseguire gli stessi controlli di casting.

E se aggiungiamo un terzo tipo, non dobbiamo solo aggiungere la sua funzione, ma dobbiamo cambiare le funzioni nelle altre due classi.

Ovviamente, ora crei poke_adjacent_pokablesuna funzione libera, in modo che funzioni per tutti loro. Ma cosa pensi che succeda se qualcuno aggiunge un quarto tipo e si dimentica di aggiungerlo a quella funzione?

Ciao, rottura silenziosa . Il programma sembra funzionare più o meno bene, ma non lo è. Se fosse pokestata una funzione virtuale effettiva , il compilatore non avrebbe funzionato se non avessi sovrascritto la funzione virtuale pura da node_base.

A modo tuo, non hai questi controlli del compilatore. Oh certo, il compilatore non controllerà i virtual non puri, ma almeno hai protezione nei casi in cui la protezione è possibile (cioè: non c'è operazione predefinita).

L'uso di classi base trasparenti con RTTI porta a un incubo di manutenzione. In effetti, la maggior parte degli usi di RTTI porta a mal di testa da manutenzione. Ciò non significa che RTTI non sia utile (è vitale per boost::anylavorare, ad esempio). Ma è uno strumento molto specializzato per esigenze molto specializzate.

In questo modo, è "dannoso" allo stesso modo di goto. È uno strumento utile che non dovrebbe essere eliminato. Ma il suo utilizzo dovrebbe essere raro all'interno del codice.


Quindi, se non puoi usare classi base trasparenti e casting dinamico, come eviti le interfacce grasse? Come si fa a evitare di ribollire ogni funzione che si potrebbe voler chiamare su un tipo dal ribollire alla classe base?

La risposta dipende dallo scopo della classe base.

Classi di base trasparenti come node_basestanno semplicemente usando lo strumento sbagliato per il problema. Gli elenchi collegati vengono gestiti al meglio dai modelli. Il tipo di nodo e l'adiacenza sarebbero forniti da un tipo di modello. Se vuoi inserire un tipo polimorfico nell'elenco, puoi farlo. Basta usare BaseClass*come Tnell'argomento del modello. O il tuo puntatore intelligente preferito.

Ma ci sono altri scenari. Uno è un tipo che fa molte cose, ma ha alcune parti opzionali. Un'istanza particolare potrebbe implementare determinate funzioni, mentre un'altra no. Tuttavia, il design di tali tipi di solito offre una risposta adeguata.

La classe "entità" ne è un perfetto esempio. Questa classe ha afflitto da tempo gli sviluppatori di giochi. Concettualmente, ha un'interfaccia gigantesca, che vive all'intersezione di quasi una dozzina di sistemi completamente disparati. E entità diverse hanno proprietà diverse. Alcune entità non hanno alcuna rappresentazione visiva, quindi le loro funzioni di rendering non fanno nulla. E tutto questo è determinato in fase di esecuzione.

La soluzione moderna per questo è un sistema in stile componente. Entityè semplicemente un contenitore di un insieme di componenti, con un po 'di colla tra di loro. Alcuni componenti sono opzionali; un'entità che non ha rappresentazione visiva non ha la componente "grafica". Un'entità senza AI non ha un componente "controllore". E così via.

Le entità in un tale sistema sono solo puntatori ai componenti, con la maggior parte della loro interfaccia fornita accedendo direttamente ai componenti.

Lo sviluppo di un tale sistema di componenti richiede il riconoscimento, in fase di progettazione, che alcune funzioni sono concettualmente raggruppate insieme, in modo tale che tutti i tipi che ne implementano uno le implementeranno tutte. Ciò consente di estrarre la classe dalla potenziale classe di base e renderla un componente separato.

Questo aiuta anche a seguire il principio della responsabilità unica. Una tale classe a componenti ha solo la responsabilità di essere un detentore di componenti.


Di Matthew Walton:

Ho notato che molte risposte non prendono atto dell'idea che il tuo esempio suggerisce che node_base fa parte di una libreria e gli utenti creeranno i propri tipi di nodo. Quindi non possono modificare node_base per consentire un'altra soluzione, quindi forse RTTI diventa la loro migliore opzione allora.

OK, esploriamolo.

Affinché ciò abbia senso, ciò che dovresti avere è una situazione in cui una libreria L fornisce un contenitore o un altro contenitore strutturato di dati. L'utente può aggiungere dati a questo contenitore, iterare sui suoi contenuti, ecc. Tuttavia, la libreria non fa nulla con questi dati; ne gestisce semplicemente l'esistenza.

Ma non gestisce nemmeno la sua esistenza quanto la sua distruzione . Il motivo è che, se ci si aspetta che utilizzi RTTI per tali scopi, stai creando classi di cui L ignora. Ciò significa che il codice alloca l'oggetto e lo passa a L per la gestione.

Ora, ci sono casi in cui qualcosa del genere è un progetto legittimo. Segnalazione di eventi / passaggio di messaggi, code di lavoro thread-safe, ecc. Lo schema generale qui è questo: qualcuno sta eseguendo un servizio tra due parti di codice che è appropriato per qualsiasi tipo, ma il servizio non deve essere a conoscenza dei tipi specifici coinvolti .

In C, questo modello è scritto void*e il suo uso richiede molta cura per evitare di essere rotto. In C ++, questo modello è scritto std::experimental::any(presto essere scritto std::any).

Il modo in cui dovrebbe funzionare è che L fornisce una node_baseclasse che accetta un anyche rappresenta i dati effettivi. Quando si riceve il messaggio, l'elemento di lavoro della coda di thread o qualunque cosa si stia facendo, lo si esegue quindi nel cast del anytipo appropriato, che sia il mittente che il destinatario conoscono.

Così, invece di derivare orange_nodeda node_data, è sufficiente attaccare un orangeinterno di node_data's anycampo membro. L'utente finale lo estrae e lo utilizza any_castper convertirlo in orange. Se il cast fallisce, allora non lo è stato orange.

Ora, se hai familiarità con l'implementazione di any, probabilmente dirai: "hey aspetta un minuto: utilizza any internamente RTTI per any_castfunzionare". A cui rispondo: "... sì".

Questo è il punto di un'astrazione . Nel profondo dei dettagli, qualcuno sta usando RTTI. Ma al livello a cui dovresti operare, l'RTTI diretto non è qualcosa che dovresti fare.

Dovresti usare tipi che ti forniscono la funzionalità che desideri. Dopotutto, non vuoi davvero RTTI. Quello che vuoi è una struttura dati che possa memorizzare un valore di un dato tipo, nasconderlo a tutti tranne che alla destinazione desiderata, quindi essere riconvertito in quel tipo, verificando che il valore memorizzato sia effettivamente di quel tipo.

Si chiama any. Esso utilizza RTTI, ma utilizzando anyè di gran lunga superiore a utilizzare direttamente RTTI, in quanto si adatta più correttamente la semantica desiderati.


10

Se chiami una funzione, di regola non ti interessa veramente quali passi precisi richiederà, solo che qualche obiettivo di livello superiore sarà raggiunto entro certi vincoli (e come la funzione lo fa accadere è davvero un problema personale).

Quando usi RTTI per fare una preselezione di oggetti speciali che possono svolgere un certo lavoro, mentre altri nello stesso set non possono, stai rompendo quella comoda visione del mondo. All'improvviso si suppone che il chiamante sappia chi può fare cosa, invece di dire semplicemente ai suoi servi di andare avanti. Alcune persone sono infastidite da questo, e sospetto che questo sia in gran parte il motivo per cui RTTI è considerato un po 'sporco.

C'è un problema di prestazioni? Forse, ma non l'ho mai sperimentato, e potrebbe essere la saggezza di vent'anni fa, o di persone che credono onestamente che usare tre istruzioni di montaggio invece di due sia inaccettabile.

Quindi, come affrontarlo ... A seconda della situazione, potrebbe avere senso avere qualsiasi proprietà specifica del nodo raggruppata in oggetti separati (cioè l'intera API "arancione" potrebbe essere un oggetto separato). L'oggetto radice potrebbe quindi avere una funzione virtuale per restituire l'API "arancione", restituendo nullptr per impostazione predefinita per oggetti non arancioni.

Sebbene ciò possa essere eccessivo a seconda della situazione, ti consentirebbe di interrogare a livello di root se un nodo specifico supporta un'API specifica e, in caso affermativo, eseguire funzioni specifiche per quell'API.


6
Oggetto: il costo delle prestazioni - Ho misurato dynamic_cast <> come un costo di circa 2 µs nella nostra app su un processore a 3GHz, che è circa 1000 volte più lento del controllo di un enum. (La nostra app ha una scadenza per il ciclo principale di 11,1 ms, quindi teniamo molto ai microsecondi.)
Crashworks

6
Le prestazioni differiscono molto tra le implementazioni. GCC utilizza un confronto del puntatore typeinfo che è veloce. MSVC utilizza confronti di stringhe che non sono veloci. Tuttavia, il metodo di MSVC funzionerà con codice collegato a diverse versioni di librerie, statiche o DLL, dove il metodo puntatore di GCC ritiene che una classe in una libreria statica sia diversa da una classe in una libreria condivisa.
Zan Lynx

1
@ Crashworks Solo per avere un record completo qui: quale compilatore (e quale versione) era quello?
H. Guijt

@ Crashworks asseconda la richiesta di informazioni su quale compilatore ha prodotto i risultati osservati; Grazie.
underscore_d

@underscore_d: MSVC.
Crashworks

9

C ++ si basa sull'idea del controllo statico del tipo.

[1] RTTI, ovvero dynamic_caste type_id, è il controllo dinamico del tipo.

Quindi, essenzialmente ti stai chiedendo perché il controllo del tipo statico è preferibile al controllo del tipo dinamico. E la risposta semplice è se il controllo del tipo statico è preferibile al controllo del tipo dinamico, dipende . Molto. Ma il C ++ è uno dei linguaggi di programmazione progettati intorno all'idea del controllo statico del tipo. E questo significa che, ad esempio, il processo di sviluppo, in particolare il test, è tipicamente adattato al controllo del tipo statico e quindi si adatta meglio a quello.


Ri

" Non saprei un modo pulito per farlo con modelli o altri metodi

puoi eseguire questo processo-nodi-eterogenei-di-grafo con controllo statico del tipo e nessun casting tramite il pattern del visitatore, ad esempio in questo modo:

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

Ciò presuppone che l'insieme dei tipi di nodo sia noto.

Quando non lo è, il pattern del visitatore (ci sono molte variazioni) può essere espresso con pochi cast centralizzati, o solo uno.


Per un approccio basato su modelli, vedere la libreria Boost Graph. Triste a dirsi, non lo conosco, non l'ho usato. Quindi non sono sicuro di cosa fa e come, e in che misura utilizza il controllo del tipo statico invece di RTTI, ma poiché Boost è generalmente basato su modelli con il controllo del tipo statico come idea centrale, penso che lo troverai anche la sua libreria secondaria Graph si basa sul controllo statico del tipo.


[1] Informazioni sul tipo di tempo di esecuzione .


1
Una "cosa divertente" da notare è che si può ridurre la quantità di codice (modifiche quando si aggiungono tipi) necessaria per il pattern del visitatore è usare RTTI per "scalare" una gerarchia. Lo conosco come "modello visitatore aciclico".
Daniel Jour

3

Ovviamente c'è uno scenario in cui il polimorfismo non può aiutare: i nomi. typeidconsente di accedere al nome del tipo, sebbene il modo in cui questo nome è codificato è definito dall'implementazione. Ma di solito questo non è un problema poiché puoi confrontare due typeid:

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

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

Lo stesso vale per gli hash.

[...] RTTI è "considerato dannoso"

dannoso è sicuramente un'esagerazione: RTTI presenta alcuni inconvenienti, ma ha avere vantaggi.

Non devi veramente usare RTTI. RTTI è uno strumento per risolvere i problemi OOP: se dovessi usare un altro paradigma, questi probabilmente scomparirebbero. C non ha RTTI, ma funziona ancora. Il C ++ invece supporta pienamente l'OOP e offre molteplici strumenti per superare alcuni problemi che potrebbero richiedere informazioni di runtime: uno di questi è proprio RTTI, che però ha un prezzo. Se non puoi permettertelo, cosa che faresti meglio a dichiarare solo dopo un'analisi sicura delle prestazioni, c'è ancora la vecchia scuola void*: è gratis. A costo zero. Ma non ottieni protezione dai tipi. Quindi è tutta una questione di scambi.


  • Alcuni compilatori non usano / RTTI non è sempre abilitato,
    davvero non compro questo argomento. È come dire che non dovrei usare le funzionalità di C ++ 14, perché ci sono compilatori là fuori che non lo supportano. Eppure, nessuno mi scoraggerebbe dall'usare le funzionalità di C ++ 14.

Se scrivi (possibilmente rigorosamente) codice C ++ conforme, puoi aspettarti lo stesso comportamento indipendentemente dall'implementazione. Le implementazioni conformi agli standard supportano le funzionalità C ++ standard.

Ma si consideri che in alcuni ambienti definiti da C ++ (quelli «indipendenti»), non è necessario fornire RTTI e nemmeno eccezioni, virtuale così via. RTTI necessita di un livello sottostante per funzionare correttamente che si occupi di dettagli di basso livello come l'ABI e le informazioni sul tipo effettivo.


Sono d'accordo con Yakk per quanto riguarda RTTI in questo caso. Sì, potrebbe essere utilizzato; ma è logicamente corretto? Il fatto che la lingua ti permetta di bypassare questo controllo non significa che debba essere fatto.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.