Il polimorfismo o i condizionali promuovono un design migliore?

Recentemente ho trovato questa voce nel blog di test di Google sulle linee guida per scrivere un codice più verificabile. Sono stato d’accordo con l’autore fino a questo punto:

Favorisci il polimorfismo rispetto ai condizionali: se vedi un’istruzione switch dovresti pensare ai polimorfismi. Se vedi la stessa condizione, se ripetuta in molti punti della tua class, dovresti pensare ancora al polimorfismo. Il polimorfismo spezzerà la tua class complessa in diverse classi più piccole e più semplici che definiscono chiaramente quali parti del codice sono correlate ed eseguite insieme. Questo aiuta a testare da quando una class più semplice / più piccola è più facile da testare.

Non riesco semplicemente a capirlo. Riesco a capire come usare il polimorfismo al posto di RTTI (o DIY-RTTI, a seconda del caso), ma sembra una dichiarazione così ampia che non riesco a immaginare che possa essere effettivamente utilizzata nel codice di produzione. Mi sembra, piuttosto, che sarebbe più facile aggiungere ulteriori casi di test per i metodi che hanno istruzioni switch, piuttosto che scomporre il codice in dozzine di classi separate.

Inoltre, avevo l’impressione che il polimorfismo potesse portare a tutti i tipi di altri piccoli bug e problemi di progettazione, quindi sono curioso di sapere se il compromesso qui varrebbe la pena. Qualcuno può spiegarmi esattamente cosa si intende per questa linea guida di test?

In realtà ciò rende più facile la verifica e il codice da scrivere.

Se hai un’istruzione switch basata su un campo interno, probabilmente hai lo stesso switch in più punti che fa cose leggermente diverse. Ciò causa problemi quando si aggiunge un nuovo caso in quanto è necessario aggiornare tutte le istruzioni switch (se è ansible trovarle).

Utilizzando il polimorfismo è ansible utilizzare le funzioni virtuali per ottenere la stessa funzionalità e, poiché un nuovo caso è una nuova class, non è necessario cercare il codice per le cose che devono essere verificate, è tutto isolato per ogni class.

class Animal { public: Noise warningNoise(); Noise pleasureNoise(); private: AnimalType type; }; Noise Animal::warningNoise() { switch(type) { case Cat: return Hiss; case Dog: return Bark; } } Noise Animal::pleasureNoise() { switch(type) { case Cat: return Purr; case Dog: return Bark; } } 

In questo semplice caso, ogni nuova causa animale richiede che entrambe le istruzioni siano aggiornate.
Ne dimentichi uno? Qual è l’impostazione predefinita? SCOPPIO!!

Usando il polimorfismo

 class Animal { public: virtual Noise warningNoise() = 0; virtual Noise pleasureNoise() = 0; }; class Cat: public Animal { // Compiler forces you to define both method. // Otherwise you can't have a Cat object // All code local to the cat belongs to the cat. }; 

Usando il polimorfismo puoi testare la class Animal.
Quindi testare separatamente ciascuna delle classi derivate.

Inoltre, questo ti consente di spedire la class Animal ( Closed for alteration ) come parte della tua libreria binaria. Ma le persone possono ancora aggiungere nuovi Animali ( aperti per estensione ) derivando nuove classi derivate dall’intestazione Animale. Se tutte queste funzionalità sono state catturate all’interno della class Animal, tutti gli animali devono essere definiti prima della spedizione (Chiuso / Chiuso).

Non avere paura…

Immagino che il tuo problema sia dovuto alla familiarità, non alla tecnologia. Familiarizzare con C ++ OOP.

C ++ è un linguaggio OOP

Tra i suoi molteplici paradigmi, ha funzionalità OOP ed è più che in grado di supportare il confronto con il linguaggio OO più puro.

Non lasciare che la “parte C in C ++” ti faccia credere che C ++ non possa gestire altri paradigmi. C ++ è in grado di gestire molti paradigmi di programmazione abbastanza graziosamente. E tra questi, OOP C ++ è il più maturo dei paradigmi C ++ dopo il paradigma procedurale (cioè la suddetta “parte C”).

Il polimorfismo è ok per la produzione

Non esiste una cosa “bug sottile” o “non adatto per codice di produzione”. Ci sono sviluppatori che restano a loro agio e sviluppatori che impareranno come utilizzare gli strumenti e utilizzare gli strumenti migliori per ogni attività.

interruttore e polimorfismo sono [quasi] simili …

… Ma il polimorfismo ha rimosso la maggior parte degli errori.

La differenza è che devi gestire gli interruttori manualmente, mentre il polimorfismo è più naturale, una volta che ti sei abituato a sostituire il metodo di ereditarietà.

Con gli switch, dovrai confrontare una variabile di tipo con tipi diversi e gestire le differenze. Con il polimorfismo, la variabile stessa sa come comportarsi. Devi solo organizzare le variabili in modi logici e sovrascrivere i metodi giusti.

Ma alla fine, se dimentichi di gestire un caso in switch, il compilatore non te lo dirà, mentre ti verrà detto se deriverai da una class senza sovrascrivere i suoi metodi puramente virtuali. Pertanto la maggior parte degli errori di commutazione viene evitata.

Tutto sumto, le due caratteristiche riguardano le scelte. Ma il polimorfismo ti consente di rendere le scelte più complesse e allo stesso tempo più naturali e quindi più facili.

Evita di usare RTTI per trovare il tipo di un object

RTTI è un concetto interessante e può essere utile. Ma la maggior parte delle volte (vale a dire il 95% delle volte), l’override del metodo e l’ereditarietà saranno più che sufficienti e la maggior parte del codice non dovrebbe nemmeno conoscere il tipo esatto dell’object gestito, ma fidarsi del fatto che faccia la cosa giusta.

Se usi RTTI come interruttore glorificato, ti manca il punto.

(Disclaimer: Sono un grande fan del concetto RTTI e di dynamic_casts, ma bisogna usare lo strumento giusto per il compito da svolgere e la maggior parte delle volte RTTI è usato come un interruttore glorificato, che è sbagliato)

Confronta il polimorfismo dinamico rispetto a quello statico

Se il codice non conosce il tipo esatto di un object in fase di compilazione, utilizza il polimorfismo dinamico (ad esempio ereditarietà classica, metodi virtuali che eseguono l’override, ecc.)

Se il tuo codice conosce il tipo al momento della compilazione, forse potresti usare il polimorfismo statico, ovvero il pattern CRTP http://en.wikipedia.org/wiki/Curiously_Recurring_Template_Pattern

Il CRTP ti permetterà di avere un codice che odora di polimorfismo dinamico, ma la cui chiamata a ogni metodo verrà risolta staticamente, che è l’ideale per alcuni codici molto critici.

Esempio di codice di produzione

Un codice simile a questo (dalla memoria) viene utilizzato in produzione.

La soluzione più semplice ruotava intorno a una procedura chiamata da message loop (un WinProc in Win32, ma ho scritto una versione più semplice, per ragioni di semplicità). Quindi riassumendo, era qualcosa di simile:

 void MyProcedure(int p_iCommand, void *p_vParam) { // A LOT OF CODE ??? // each case has a lot of code, with both similarities // and differences, and of course, casting p_vParam // into something, depending on hoping no one // did a mistake, associating the wrong command with // the wrong data type in p_vParam switch(p_iCommand) { case COMMAND_AAA: { /* A LOT OF CODE (see above) */ } break ; case COMMAND_BBB: { /* A LOT OF CODE (see above) */ } break ; // etc. case COMMAND_XXX: { /* A LOT OF CODE (see above) */ } break ; case COMMAND_ZZZ: { /* A LOT OF CODE (see above) */ } break ; default: { /* call default procedure */} break ; } } 

Ogni aggiunta di comando ha aggiunto un caso.

Il problema è che alcuni comandi sono simili e condividono in parte la loro implementazione.

Quindi mescolare i casi era un rischio per l’evoluzione.

Ho risolto il problema utilizzando il pattern Command, ovvero creando un object Command base, con un metodo process ().

Così ho riscritto la procedura del messaggio, riducendo al minimo il codice pericoloso (cioè giocando con void *, ecc.) Al minimo, e l’ho scritto per essere sicuro che non avrei mai più bisogno di toccarlo di nuovo:

 void MyProcedure(int p_iCommand, void *p_vParam) { switch(p_iCommand) { // Only one case. Isn't it cool? case COMMAND: { Command * c = static_cast(p_vParam) ; c->process() ; } break ; default: { /* call default procedure */} break ; } } 

E poi, per ogni comando ansible, invece di aggiungere codice nella procedura, e mescolare (o peggio, copiare / incollare) il codice da comandi simili, ho creato un nuovo comando e l’ho derivato dall’object Command o da uno di i suoi oggetti derivati:

Ciò ha portato alla gerarchia (rappresentata come un albero):

 [+] Command | +--[+] CommandServer | | | +--[+] CommandServerInitialize | | | +--[+] CommandServerInsert | | | +--[+] CommandServerUpdate | | | +--[+] CommandServerDelete | +--[+] CommandAction | | | +--[+] CommandActionStart | | | +--[+] CommandActionPause | | | +--[+] CommandActionEnd | +--[+] CommandMessage 

Ora, tutto ciò che dovevo fare era sovrascrivere il processo per ciascun object.

Semplice e facile da estendere.

Ad esempio, si suppone che CommandAction esegua il suo processo in tre fasi: “prima”, “mentre” e “dopo”. Il suo codice sarebbe qualcosa di simile:

 class CommandAction : public Command { // etc. virtual void process() // overriding Command::process pure virtual method { this->processBefore() ; this->processWhile() ; this->processAfter() ; } virtual void processBefore() = 0 ; // To be overriden virtual void processWhile() { // Do something common for all CommandAction objects } virtual void processAfter() = 0 ; // To be overriden } ; 

Ad esempio, CommandActionStart potrebbe essere codificato come:

 class CommandActionStart : public CommandAction { // etc. virtual void processBefore() { // Do something common for all CommandActionStart objects } virtual void processAfter() { // Do something common for all CommandActionStart objects } } ; 

Come ho detto: facile da capire (se commentato correttamente), e molto facile da estendere.

Il commutatore è ridotto al minimo (vale a dire if-like, perché dovevamo ancora debind i comandi di Windows alla procedura predefinita di Windows) e non c’era bisogno di RTTI (o peggio di RTTI interno).

Lo stesso codice all’interno di un interruttore sarebbe piuttosto divertente, suppongo (anche se giudicando solo dalla quantità di codice “storico” che ho visto nella nostra app al lavoro).

Unit test di un programma OO significa testare ogni class come un’unità. Un principio che vuoi imparare è “Aperto per l’estensione, chiuso alla modifica”. L’ho preso da Head First Design Patterns. Ma sostanzialmente dice che vuoi avere la possibilità di estendere facilmente il tuo codice senza modificare il codice testato esistente.

Il polimorfismo rende ansible ciò eliminando quelle dichiarazioni condizionali. Considera questo esempio:

Supponiamo di avere un object personaggio che porta un’arma. Puoi scrivere un metodo di attacco come questo:

 If (weapon is a rifle) then //Code to attack with rifle else If (weapon is a plasma gun) //Then code to attack with plasma gun 

eccetera.

Con il polimorfismo il personaggio non deve “conoscere” il tipo di arma, semplicemente

 weapon.attack() 

funzionerebbe. Cosa succede se una nuova arma è stata inventata? Senza polimorfismo dovrai modificare la tua dichiarazione condizionale. Con il polimorfismo dovrai aggiungere una nuova class e lasciare la class di caratteri testata da sola.

Sono un po ‘scettico: credo che l’eredità spesso aggiunga più complessità di quanto rimuova.

Penso che tu stia facendo una bella domanda, però, e una cosa che considero è questa:

Ti stai dividendo in più classi perché hai a che fare con cose diverse? O è davvero la stessa cosa, agendo in un modo diverso?

Se è davvero un nuovo tipo , allora vai avanti e crea una nuova class. Ma se è solo un’opzione, generalmente la tengo nella stessa class.

Credo che la soluzione predefinita sia quella a una sola class, e l’onere è sul programmatore che propone l’ereditarietà per dimostrare il proprio caso.

Non è un esperto delle implicazioni per i casi di test, ma da una prospettiva di sviluppo del software:

  • Principio a porte aperte : le classi dovrebbero essere chiuse alle modifiche, ma aperte all’estensione. Se gestisci le operazioni condizionali tramite un costrutto condizionale, se viene aggiunta una nuova condizione, la class deve essere modificata. Se usi il polimorfismo, la class base non deve cambiare.

  • Non ripeterti : una parte importante delle linee guida è la ” stessa condizione se”. Ciò indica che la tua class ha alcune distinte modalità operative che possono essere prese in considerazione in una class. Quindi, tale condizione viene visualizzata in un punto del codice, quando si crea un’istanza dell’object per quella modalità. E ancora, se ne arriva uno nuovo, hai solo bisogno di cambiare un pezzo di codice.

Il polimorfismo è una delle pietre angolari di OO e certamente è molto utile. Dividendo le preoccupazioni su più classi crei unità isolate e testabili. Quindi, invece di fare un interruttore … caso in cui si chiamano metodi su diversi tipi o implementi, si crea un’interfaccia unificata, con più implementazioni. Quando è necessario aggiungere un’implementazione, non è necessario modificare i client, come nel caso di switch … caso. Molto importante in quanto aiuta ad evitare la regressione.

È inoltre ansible semplificare l’algoritmo del client gestendo un solo tipo: l’interfaccia.

Per me è molto importante che il polimorfismo sia usato al meglio con un puro schema di interfaccia / implementazione (come il venerabile Shape <- Circle etc ...). È inoltre possibile avere il polimorfismo nelle classi concrete con i metodi template (ovvero gli hook), ma la sua efficacia diminuisce con l'aumentare della complessità.

Il polimorfismo è la base su cui viene costruito il codice base della nostra azienda, quindi lo considero molto pratico.

Interruttori e polimorfismo fa la stessa cosa.

Nel polimorfismo (e nella programmazione basata sulla class in generale) si raggruppano le funzioni in base al loro tipo. Quando si usano interruttori si raggruppano i tipi per funzione. Decidi quale vista è buona per te.

Quindi se la tua interfaccia è fissa e aggiungi solo nuovi tipi, il polimorfismo è tuo amico. Ma se aggiungi nuove funzioni all’interfaccia, dovrai aggiornare tutte le implementazioni.

In alcuni casi, potresti avere una quantità fissa di tipi e possono venire nuove funzioni, quindi gli switch sono migliori. Ma l’aggiunta di nuovi tipi ti consente di aggiornare ogni switch.

Con gli interruttori si stanno duplicando gli elenchi di sottotipi. Con il polimorfismo si stanno duplicando elenchi di operazioni. Hai scambiato un problema per averne uno diverso. Questo è il cosiddetto problema di espressione , che non è risolto da nessun paradigma di programmazione che conosca. La radice del problema è la natura unidimensionale del testo utilizzato per rappresentare il codice.

Dato che i punti di pro-polimorfismo sono ben discussi qui, permettimi di fornire un punto di riferimento.

OOP ha schemi di progettazione per evitare le insidie ​​più comuni. Anche la programmazione procedurale ha schemi di progettazione (ma nessuno ha ancora scritto AFAIK, abbiamo bisogno di un’altra nuova Gang of N per creare un libro bestseller di quelli …). Uno schema di progettazione potrebbe sempre includere un caso predefinito .

Gli switch possono essere fatti bene:

 switch (type) { case T_FOO: doFoo(); break; case T_BAR: doBar(); break; default: fprintf(stderr, "You, who are reading this, add a new case for %d to the FooBar function ASAP!\n", type); assert(0); } 

Questo codice punterà il tuo debugger preferito nella posizione in cui ti sei dimenticato di gestire un caso. Un compilatore può forzarti ad implementare la tua interfaccia, ma questo ti costringe a testare accuratamente il tuo codice (almeno per vedere che il nuovo caso viene notato).

Ovviamente se un particolare interruttore dovesse essere usato più di un punto, è ritagliato in una funzione ( non ripetersi ).

Se si desidera estendere questi parametri basta fare un grep 'case[ ]*T_BAR' rn . (su Linux) e sputerà le location che vale la pena guardare. Dal momento che è necessario esaminare il codice, verrà visualizzato un contesto che consente di aggiungere correttamente il nuovo caso. Quando si utilizza il polimorfismo, i siti di chiamata sono nascosti all’interno del sistema e dipendono dalla correttezza della documentazione, se esiste.

L’estensione degli switch non interrompe anche l’OCP, dal momento che non si alterano i casi esistenti, basta aggiungere un nuovo caso.

Gli switch aiutano anche il prossimo a cercare di abituarsi e capire il codice:

  • I casi possibili sono davanti ai tuoi occhi. Questa è una buona cosa quando leggi il codice (meno di saltare in giro).
  • Ma le chiamate al metodo virtuale sono come normali chiamate di metodo. Non si può mai sapere se una chiamata è virtuale o normale (senza guardare la class). Questo è male.
  • Ma se la chiamata è virtuale, i casi possibili non sono ovvi (senza trovare tutte le classi derivate). Anche questo è male.

Quando fornisci un’interfaccia a una terza parte, in modo che possano aggiungere dati comportamentali e utente a un sistema, è un’altra questione. (Possono impostare i callback e i puntatori ai dati dell’utente e gli si danno maniglie)

Ulteriori discussioni possono essere trovate qui: http://c2.com/cgi/wiki?SwitchStatementsSmell

Temo che la mia “sindrome di C-hacker” e l’anti-Oopismo finiranno per bruciare tutta la mia reputazione qui. Ma ogni volta che avevo bisogno o dovevo incidere o incastrare qualcosa in un sistema C procedurale, l’ho trovato abbastanza facile, la mancanza di vincoli, l’incapsulamento forzato e meno livelli di astrazione mi fanno “farlo e basta”. Ma in un sistema C ++ / C # / Java in cui decine di livelli di astrazione sono impilati l’uno sull’altro nel corso della vita del software, ho bisogno di passare molte ore a volte per scoprire come aggirare correttamente tutti i vincoli e le limitazioni che altri programmatori integrato nel loro sistema per evitare che altri “scherzino con la loro class”.

Questo è principalmente a che fare con l’incapsulamento della conoscenza. Iniziamo con un esempio ovvio: toString (). Questo è Java, ma si trasferisce facilmente in C ++. Supponiamo di voler stampare una versione umana di un object a scopo di debug. Potresti fare:

 switch(obj.type): { case 1: cout << "Type 1" << obj.foo <<...; break; case 2: cout << "Type 2" << ... 

Ciò sarebbe tuttavia chiaramente sciocco. Perché un metodo dovrebbe sapere da qualche parte come stampare tutto. Sarà spesso meglio per l'object stesso sapere come stampare se stesso, ad esempio:

 cout << object.toString(); 

In questo modo toString () può accedere ai campi membri senza bisogno di cast. Possono essere testati indipendentemente. Possono essere cambiati facilmente.

Si potrebbe tuttavia obiettare che il modo in cui un object stampato non dovrebbe essere associato ad un object, dovrebbe essere associato al metodo di stampa. In questo caso, è utile un altro modello di progettazione, che è il pattern Visitor, utilizzato per falsificare Double Dispatch. Descriverlo completamente è troppo lungo per questa risposta, ma qui puoi leggere una buona descrizione .

Se stai usando le istruzioni switch ovunque ti imbatti nella possibilità che durante l’aggiornamento ti perdi un posto che ha bisogno di un aggiornamento.

Funziona molto bene se lo capisci .

Ci sono anche 2 sapori di polimorfismo. Il primo è molto facile da capire in java-esque:

 interface A{ int foo(); } final class B implements A{ int foo(){ print("B"); } } final class C implements A{ int foo(){ print("C"); } } 

B e C condividono un’interfaccia comune. B e C in questo caso non possono essere estesi, quindi sei sempre sicuro di quale pippo () stai chiamando. Lo stesso vale per il C ++, basta rendere A :: foo pure virtuale.

In secondo luogo, il più complicato è il polimorfismo di runtime. Non sembra male in pseudo-codice.

 class A{ int foo(){print("A");} } class B extends A{ int foo(){print("B");} } class C extends B{ int foo(){print("C");} } ... class Z extends Y{ int foo(){print("Z"); } main(){ F* f = new Z(); A* a = f; a->foo(); f->foo(); } 

Ma è molto più complicato. Soprattutto se stai lavorando in C ++ dove alcune delle dichiarazioni foo possono essere virtuali, e parte dell’eredità potrebbe essere virtuale. Anche la risposta a questo:

 A* a = new Z; A a2 = *a; a->foo(); a2.foo(); 

potrebbe non essere quello che ti aspetti

Tieni semplicemente conto di ciò che fai e non sai se stai usando il polimorfismo di runtime. Non diventare troppo sicuro di sé, e se non sei sicuro di cosa farà qualcosa in fase di esecuzione, testalo.

Devo ribadire che la ricerca di tutte le statiche degli switch può essere un processo non banale in una base di codice matura. Se si dimentica qualcuna, è probabile che l’applicazione si arresti in modo anomalo a causa di un’istruzione case senza corrispondenza a meno che non si disponga del set predefinito.

Consulta anche il libro “Martin Fowlers” su “Refactoring”
L’uso di un interruttore al posto del polimorfismo è un odore di codice.

Dipende davvero dal tuo stile di programmazione. Anche se questo potrebbe essere corretto in Java o C #, non sono d’accordo sul fatto che la decisione automatica di utilizzare il polimorfismo sia corretta. Puoi dividere il tuo codice in tante piccole funzioni ed eseguire una ricerca di array con i puntatori di funzione (inizializzati in fase di compilazione), per esempio. In C ++, il polimorfismo e le classi sono spesso abusate – probabilmente il più grande errore di progettazione fatto da persone che provengono da linguaggi OOP forti in C ++ è che tutto va in una class – questo non è vero. Una class dovrebbe contenere solo il set minimo di cose che lo fanno funzionare nel suo complesso. Se è necessaria una sottoclass o un amico, così sia, ma non dovrebbero essere la norma. Qualsiasi altra operazione sulla class dovrebbe essere funzioni libere nello stesso spazio dei nomi; ADL consentirà di utilizzare queste funzioni senza ricerca.

Il C ++ non è un linguaggio OOP, non lo rende uno. È pessimo come programmare C in C ++.