Perché utilizzare un approccio OO invece di una gigantesca istruzione "switch"?

58

Lavoro in un negozio di .Net, C # e ho un collega che continua a insistere sul fatto che dovremmo usare le dichiarazioni di Switch giganti nel nostro codice con molti "Cases" piuttosto che con approcci più orientati agli oggetti. La sua argomentazione risale in modo consistente al fatto che un'istruzione Switch compila in una "tabella di salto della cpu" ed è quindi l'opzione più veloce (anche se in altre cose al nostro team viene detto che non ci interessa la velocità).

Onestamente non ho una discussione contro questo ... perché non so di cosa diavolo stia parlando.
Ha ragione?
Sta solo parlando dal suo culo?
Sto solo cercando di imparare qui.

    
posta James P. Wright 25.05.2011 - 17:15
fonte

17 risposte

48

Probabilmente è un vecchio hacker C e sì, sta parlando dal suo culo. .Net non è C ++; il compilatore .Net continua a migliorare e gli hack più intelligenti sono controproducenti, se non oggi nella prossima versione .Net. Piccole funzioni sono preferibili perché .Net JIT-s ogni funzione una volta prima che venga utilizzata. Quindi, se alcuni casi non vengono mai colpiti durante un LifeCycle di un programma, quindi non viene sostenuto alcun costo nella compilazione di JIT. In ogni caso, se la velocità non è un problema, non ci dovrebbero essere ottimizzazioni. Scrivi per programmatore prima, per il secondo compilatore. Il tuo collega non sarà facilmente convinto, quindi proverei empiricamente che un codice meglio organizzato è in realtà più veloce. Prenderò uno dei suoi peggiori esempi, li riscriverò in un modo migliore e quindi assicurarmi che il tuo codice sia più veloce. Cherry-pick se è necessario. Quindi eseguilo qualche milione di volte, profilo e mostralo. Questo dovrebbe insegnargli bene.

Modifica

Bill Wagner ha scritto:

Articolo 11: Comprendere l'attrazione delle piccole funzioni (Seconda edizione efficace C #) Ricorda che tradurre il tuo codice C # in codice eseguibile dalla macchina è un processo in due fasi. Il compilatore C # genera IL che viene consegnato negli assembly. Il compilatore JIT genera codice macchina per ogni metodo (o gruppo di metodi, quando è coinvolto l'inlining), se necessario. Piccole funzioni rendono molto più facile per il compilatore JIT ammortizzare tale costo. Anche le piccole funzioni sono più propense a essere candidate all'inallineamento. Non è solo piccolezza: il flusso di controllo più semplice conta altrettanto. Un minor numero di rami di controllo all'interno delle funzioni rende più semplice per il compilatore JIT la registrazione delle variabili. Non è solo una buona pratica scrivere un codice più chiaro; è come si crea codice più efficiente in fase di runtime.

EDIT2:

Quindi ... apparentemente un'istruzione switch è più veloce e migliore di una serie di istruzioni if / else, perché un confronto è logaritmico e un altro è lineare. link

Bene, il mio approccio preferito alla sostituzione di un'enorme istruzione switch è con un dizionario (o talvolta anche un array se sto accendendo enum o piccoli int) che sta mappando i valori alle funzioni che vengono chiamate in risposta a loro. Fare così costringe a rimuovere un sacco di brutti stati di spaghetti condivisi, ma questa è una buona cosa. Una dichiarazione di switch di grandi dimensioni è solitamente un incubo di manutenzione. Quindi ... con gli array e i dizionari, la ricerca richiederà un tempo costante, e ci sarà poca memoria extra sprecata.

Non sono ancora convinto che l'istruzione switch sia migliore.

    
risposta data 25.05.2011 - 17:24
fonte
38

A meno che il tuo collega non possa fornire prove, che questa alterazione fornisca un effettivo beneficio misurabile sulla scala dell'intera applicazione, è inferiore al tuo approccio (es. polimorfismo), che in realtà fornisce tale vantaggio: manutenibilità.

La microottimizzazione dovrebbe essere fatta, i colli di bottiglia dopo sono bloccati. L'ottimizzazione prematura è la radice di tutti i mali .

La velocità è quantificabile. Ci sono poche informazioni utili in "l'approccio A è più veloce dell'approccio B". La domanda è " Quanto più veloce? ".

    
risposta data 25.05.2011 - 17:33
fonte
27

A chi importa se è più veloce?

A meno che tu non stia scrivendo software in tempo reale è improbabile che la quantità minima di accelerazione che potresti ottenere dal fare qualcosa in modo completamente folle farà molta differenza per il tuo cliente. Non andrei nemmeno a lottare con questo sul fronte della velocità, questo ragazzo chiaramente non ascolterà alcuna discussione sull'argomento.

La manutenibilità, tuttavia, è l'obiettivo del gioco, e una dichiarazione di switch gigante non è nemmeno leggermente mantenibile, come spieghi i diversi percorsi attraverso il codice a un nuovo tipo? La documentazione dovrà essere lunga quanto il codice stesso!

Inoltre, hai ottenuto la totale incapacità di test unitario (troppi percorsi possibili, per non parlare della probabile mancanza di interfacce, ecc.), il che rende il tuo codice ancora meno gestibile.

[Sul lato interessato: il JITter si comporta meglio con i metodi più piccoli, quindi le istruzioni switch giganti (e i loro metodi intrinsecamente grandi) danneggeranno la velocità in grandi assiemi, IIRC.]

    
risposta data 25.05.2011 - 17:22
fonte
14

Allontanati dall'istruzione switch ...

Questo tipo di istruzione switch dovrebbe essere evitata come una piaga perché viola il Open Closed Principle . Costringe il team ad apportare modifiche al codice esistente quando è necessario aggiungere nuove funzionalità, anziché aggiungere semplicemente un nuovo codice.

    
risposta data 25.05.2011 - 17:24
fonte
8

Sono sopravvissuto all'incubo noto come la massiccia macchina a stati finiti manipolata da enormi dichiarazioni di switch. Ancor peggio, nel mio caso, l'FSM ha analizzato tre DLL C ++ ed è stato abbastanza semplice che il codice sia stato scritto da qualcuno esperto in C.

Le metriche di cui ti devi preoccupare sono:

  • Velocità di apportare modifiche
  • Velocità di individuazione del problema quando si verifica

Mi è stato assegnato il compito di aggiungere una nuova funzionalità a quel set di DLL ed è stato in grado di convincere il management che mi ci sarebbe voluto altrettanto tempo per riscrivere le 3 DLL come una DLL orientata agli oggetti come sarebbe per me per scovare la patch e preparare la soluzione a quello che c'era già. La riscrittura è stata un enorme successo, poiché non solo supportava la nuova funzionalità, ma era molto più facile da estendere. In effetti, un'attività che normalmente impiegherebbe una settimana per assicurarti di non rompere nulla finirebbe per richiedere alcune ore.

Quindi per quanto riguarda i tempi di esecuzione? Non c'è stato alcun aumento o diminuzione della velocità. Per essere onesti, le nostre prestazioni sono state limitate dai driver di sistema, quindi se la soluzione orientata agli oggetti fosse effettivamente più lenta non lo sapremmo.

Cosa c'è di sbagliato con enormi istruzioni switch per un linguaggio OO?

  • Il flusso di controllo del programma viene portato via dall'oggetto a cui appartiene e posizionato all'esterno dell'oggetto
  • Molti punti di controllo esterno si traducono in molti punti che è necessario rivedere
  • Non è chiaro dove viene memorizzato lo stato, in particolare se lo switch si trova all'interno di un ciclo
  • Il confronto più veloce non è affatto un confronto (puoi evitare la necessità di molti confronti con un buon design orientato agli oggetti)
  • È più efficiente iterare attraverso gli oggetti e chiamare sempre lo stesso metodo su tutti gli oggetti piuttosto che modificare il codice in base al tipo di oggetto o enum che codifica il tipo.
risposta data 25.05.2011 - 20:32
fonte
8

Non compro l'argomento della prestazione; tutto dipende dalla manutenibilità del codice.

BUT: a volte , una gigantesca istruzione switch è più facile da mantenere (meno codice) di un gruppo di piccole classi che sostituiscono le funzioni virtuali di una classe base astratta. Ad esempio, se dovessi implementare un emulatore di CPU, non implementeresti la funzionalità di ogni istruzione in una classe separata - lo inseriresti in uno swtich gigante nell'opcode, possibilmente chiamando le funzioni di supporto per istruzioni più complesse. / p>

Regola empirica: se lo switch è in qualche modo eseguito su TYPE, probabilmente dovresti usare l'ereditarietà e le funzioni virtuali. Se l'interruttore viene eseguito su un VALORE di un tipo fisso (ad esempio, l'opcode dell'istruzione, come sopra), è OK lasciarlo così com'è.

    
risposta data 26.05.2011 - 09:36
fonte
5

Non puoi convincermi che:

void action1()
{}

void action2()
{}

void action3()
{}

void action4()
{}

void doAction(int action)
{
    switch(action)
    {
        case 1: action1();break;
        case 2: action2();break;
        case 3: action3();break;
        case 4: action4();break;
    }
}

È significativamente più veloce di:

struct IAction
{
    virtual ~IAction() {}
    virtual void action() = 0;
}

struct Action1: public IAction
{
    virtual void action()    { }
}

struct Action2: public IAction
{
    virtual void action()    { }
}

struct Action3: public IAction
{
    virtual void action()    { }
}

struct Action4: public IAction
{
    virtual void action()    { }
}

void doAction(IAction& actionObject)
{
    actionObject.action();
}

Inoltre, la versione OO è appena più gestibile.

    
risposta data 25.05.2011 - 20:09
fonte
4

È corretto che il codice macchina risultante probabilmente sia più efficiente. Il compilatore essenziale trasforma un'istruzione switch in una serie di test e rami, che saranno relativamente poche istruzioni. C'è un'alta probabilità che il codice risultante da approcci più astratti richieda più istruzioni.

TUTTAVIA : è quasi certamente il caso che la tua particolare applicazione non debba preoccuparsi di questo tipo di micro-ottimizzazione, o non useresti .net in primo luogo. A prescindere dalle applicazioni embedded molto limitate o dal lavoro intensivo della CPU, dovresti sempre consentire al compilatore di gestire l'ottimizzazione. Concentrati sulla scrittura di codice pulito e manutenibile. Questo è quasi sempre di gran valore rispetto ad alcuni decimi di un nano-secondo in tempo di esecuzione.

    
risposta data 25.05.2011 - 17:25
fonte
3

Uno dei motivi principali per utilizzare le classi invece delle istruzioni switch è che le istruzioni switch tendono a generare un file enorme con molta logica. Questo è sia un incubo di manutenzione sia un problema con la gestione delle fonti dato che devi controllare e modificare quel file enorme invece di un file di classi più piccole

    
risposta data 25.05.2011 - 17:28
fonte
3

un'istruzione switch nel codice OOP è una strong indicazione delle classi mancanti

prova in entrambi i modi ed esegui alcuni semplici test di velocità; è probabile che la differenza non sia significativa. Se sono e il codice è time-critical , mantieni l'istruzione switch

    
risposta data 25.05.2011 - 17:34
fonte
3

Normalmente odio la parola "ottimizzazione prematura", ma questo la spaventa. Vale la pena notare che Knuth ha usato questa famosa citazione nel contesto della spinta per usare le istruzioni goto per accelerare il codice nelle aree critiche . Questa è la chiave: percorsi critici .

Stava suggerendo di usare goto per accelerare il codice ma mettere in guardia contro quei programmatori che vorrebbero fare questo tipo di cose sulla base di intuizioni e superstizioni per codice che non è nemmeno critico.

Favorire il più possibile le dichiarazioni switch uniformemente in un codebase (indipendentemente dal fatto che sia gestito o meno un carico pesante) è il classico esempio di ciò che Knuth chiama "penny-wise and pound- programmatore "folle" che passa tutto il giorno a lottare per mantenere il proprio codice "ottimizzato" che si è trasformato in un incubo di debugging come risultato del tentativo di risparmiare penny oltre sterline. Tale codice è raramente mantenibile e tanto meno efficiente anche in primo luogo.

Is he right?

È corretto dal punto di vista dell'efficienza di base. Nessun compilatore a mia conoscenza può ottimizzare il codice polimorfico che coinvolge oggetti e dispatch dinamico meglio di un'istruzione switch. Non finirai mai con una LUT o una tabella di salto a codice inte- rato da codice polimorfo, poiché tale codice tende a fungere da barriera di ottimizzazione per il compilatore (non saprà quale funzione chiamare fino al momento in cui la spedizione dinamica si verifica).

È più utile non pensare a questo costo in termini di tabelle di salto, ma più in termini di barriera di ottimizzazione. Per il polimorfismo, chiamare Base.method() non consente al compilatore di sapere quale funzione verrà effettivamente chiamata se method è virtuale, non sigillata e può essere ignorata. Dal momento che non sa quale funzione verrà effettivamente chiamata in anticipo, non può ottimizzare la chiamata di funzione e utilizzare più informazioni nel prendere decisioni di ottimizzazione, poiché non sa in realtà quale funzione verrà chiamata a il momento in cui il codice è in fase di compilazione.

Gli ottimizzatori sono al loro meglio quando possono effettuare il peer di una chiamata di funzione e fare ottimizzazioni che appiattiscono completamente il chiamante e il chiamante, o almeno ottimizzano il chiamante per funzionare in modo più efficiente con il chiamato. Non possono farlo se non sanno quale funzione verrà effettivamente chiamata in anticipo.

Is he just talking out his ass?

L'uso di questo costo, che spesso equivale a pochi centesimi, per giustificare la trasformazione di questo in uno standard di codifica applicato uniformemente è generalmente molto sciocco, specialmente per i luoghi che hanno bisogno di estensibilità. Questa è la cosa principale che vuoi osservare con veri e propri ottimizzatori prematuri: vogliono trasformare i problemi di prestazioni minori in standard di codifica applicati uniformemente in una base di codice senza alcun riguardo per la manutenzione di qualsiasi tipo.

Mi rendo un po 'offensivo della frase "vecchio hacker C" usata nella risposta accettata, dato che io sono uno di quelli. Non tutti quelli che sono stati codificati per decenni a partire da hardware molto limitato si sono trasformati in un ottimizzatore prematuro. Eppure ho incontrato e lavorato anche con quelli. Ma questi tipi non misurano mai cose come la predizione errata o la mancanza di cache, pensano di conoscere meglio e basano le loro nozioni di inefficienza in una base di codice di produzione complessa basata su superstizioni che non valgono oggi e che a volte non sono mai vere. Le persone che hanno realmente lavorato in campi critici per le prestazioni spesso capiscono che l'ottimizzazione efficace è una priorità effettiva, e tentare di generalizzare uno standard di codifica che riduce la manutenibilità per risparmiare penny è una priorità molto inefficace.

I penny sono importanti quando si dispone di una funzione economica che non fa molto lavoro, che è chiamata un miliardo di volte in un ciclo molto stretto e critico per le prestazioni. In tal caso, finiremo per risparmiare 10 milioni di dollari. Non vale la pena di rasarsi i penny quando si ha una funzione chiamata due volte per cui il corpo da solo costa migliaia di dollari. Non è saggio passare il tempo a litigare per pochi centesimi durante l'acquisto di un'auto. Vale la pena contrattare per pochi centesimi se si acquista un milione di lattine di soda da un produttore. La chiave per un'efficace ottimizzazione è comprendere questi costi nel loro contesto appropriato. Qualcuno che cerca di risparmiare centesimi su ogni singolo acquisto e suggerisce che tutti gli altri provano a contrattare per pochi centesimi indipendentemente da ciò che stanno acquistando, non è un ottimizzatore esperto.

    
risposta data 05.01.2016 - 12:43
fonte
2

Sembra che il tuo collega sia molto preoccupato per le prestazioni. Potrebbe essere che in alcuni casi una struttura di case / switch di grandi dimensioni sia più veloce, ma si spera che si possa fare un esperimento eseguendo i test di timing sulla versione OO e sulla versione switch / case. Immagino che la versione OO abbia meno codice ed è più facile da seguire, capire e mantenere. Vorrei discutere per prima cosa sulla versione OO (in quanto la manutenzione / leggibilità dovrebbe essere inizialmente più importante) e considerare solo la versione switch / case solo se la versione OO presenta seri problemi di prestazioni e si può dimostrare che un interruttore / caso farà un miglioramento significativo.

    
risposta data 25.05.2011 - 17:23
fonte
2

Un vantaggio di manutenibilità del polimorfismo che nessuno ha menzionato è che sarete in grado di strutturare il vostro codice molto più facilmente usando l'ereditarietà se state sempre attivando lo stesso elenco di casi, ma a volte molti casi vengono gestiti allo stesso modo e a volte non sono

Eg. se passi tra Dog , Cat e Elephant , a volte Dog e Cat hanno lo stesso caso, puoi renderli entrambi ereditari da una classe astratta DomesticAnimal e mettere tali funzioni nel classe astratta.

Inoltre, sono rimasto sorpreso dal fatto che diverse persone usassero un parser come esempio di dove non si userebbe il polimorfismo. Per un parser ad albero questo è sicuramente l'approccio sbagliato, ma se hai qualcosa come assembly, in cui ogni riga è in qualche modo indipendente, e inizia con un opcode che indica come interpretare il resto della linea, utilizzerei totalmente il polimorfismo e una fabbrica. Ogni classe può implementare funzioni come ExtractConstants o ExtractSymbols . Ho usato questo approccio per un interprete BASIC giocattolo.

    
risposta data 25.07.2013 - 08:37
fonte
0

"Dovremmo dimenticare le piccole efficienze, diciamo circa il 97% delle volte: l'ottimizzazione prematura è la radice di tutti i mali"

Donald Knuth

    
risposta data 25.05.2011 - 21:04
fonte
0

Anche se questo non era male per la manutenibilità, non credo che sarebbe meglio per le prestazioni. Una chiamata di funzione virtuale è semplicemente un'indirizzamento extra (lo stesso del caso migliore per un'istruzione switch), quindi anche in C ++ le prestazioni dovrebbero essere approssimativamente uguali. In C #, dove tutte le chiamate di funzione sono virtuali, l'istruzione switch dovrebbe essere peggiore, poiché in entrambe le versioni è presente lo stesso overhead di chiamata di funzione virtuale.

    
risposta data 28.08.2012 - 16:09
fonte
0

Il tuo collega non sta parlando dal suo didietro, per quanto riguarda il commento riguardante i jump tables. Tuttavia, l'utilizzo di questo per giustificare la scrittura di codice errato è dove si sbaglia.

Il compilatore C # converte le istruzioni switch con pochi casi in una serie di if / else, quindi non è più veloce dell'utilizzo if / else. Il compilatore converte le istruzioni switch più grandi in un dizionario (la tabella di salto a cui il tuo collega si riferisce). Leggi questa risposta a una domanda di overflow dello stack sull'argomento per ulteriori dettagli .

Un'istruzione switch di grandi dimensioni è difficile da leggere e gestire. Un dizionario di "casi" e funzioni è molto più facile da leggere. Poiché questo è il modo in cui l'interruttore viene attivato, tu e il tuo collega vi consigliamo di utilizzare direttamente i dizionari.

    
risposta data 05.01.2016 - 13:33
fonte
0

Non sta necessariamente parlando dal suo culo. Almeno in C e C ++% le istruzioni diswitch possono essere ottimizzate per saltare tabelle mentre non ho mai visto accadere con una distribuzione dinamica in una funzione che ha solo accesso a un puntatore di base. Perlomeno quest'ultimo richiede un ottimizzatore molto più intelligente che guarda molto più codice circostante per capire esattamente quale sottotipo viene usato da una chiamata di funzione virtuale attraverso un puntatore / riferimento di base.

Inoltre, il dispatch dinamico spesso funge da "barriera di ottimizzazione", il che significa che spesso il compilatore non sarà in grado di incorporare il codice e allocare in modo ottimale i registri per ridurre al minimo lo spargimento dello stack e tutte quelle cose di fantasia, dal momento che non può capire quale funzione virtuale verrà chiamata attraverso il puntatore di base per incorporarla e fare tutta la sua magia di ottimizzazione. Non sono sicuro che tu voglia che l'ottimizzatore sia così intelligente e cerchi di ottimizzare le chiamate di funzione indirette, poiché ciò potrebbe potenzialmente far sì che molti rami di codice debbano essere generati separatamente in un determinato stack di chiamate (una funzione che chiama foo->f() dovrebbe generare un codice macchina completamente diverso da uno che chiama bar->f() attraverso un puntatore di base, e la funzione che chiama quella funzione dovrebbe quindi generare due o più versioni di codice, e così via - la quantità di codice macchina essere generato sarebbe esplosivo - forse non così male con una traccia JIT che genera il codice al volo mentre sta tracciando attraverso percorsi di esecuzione a caldo).

Tuttavia, come hanno echeggiato molte risposte, questo è un cattivo motivo per favorire un carico di barca di dichiarazioni switch anche se è più veloce a mano a mano da un importo marginale. Inoltre, quando si tratta di micro-efficienza, le cose come la ramificazione e l'inlining di solito hanno una priorità piuttosto bassa rispetto a cose come i pattern di accesso alla memoria.

Detto questo, sono saltato qui con una risposta insolita. Voglio fare un caso per la manutenibilità delle dichiarazioni switch su una soluzione polimorfica quando, e solo quando, sai per certo che ci sarà solo un posto in cui è necessario eseguire switch .

Un primo esempio è un gestore di eventi centrale. In questo caso in genere non ci sono molti eventi di gestione dei luoghi, solo uno (perché è "centrale"). In questi casi, non si beneficia dell'estensibilità fornita da una soluzione polimorfica. Una soluzione polimorfica è vantaggiosa quando ci sono molte posizioni che farebbero l'istruzione switch analogica. Se si è sicuri che ce ne sarà una sola, un'istruzione switch con 15 casi può essere molto più semplice della progettazione di una classe base ereditata da 15 sottotipi con funzioni sovrascritte e una fabbrica per istanziarli, solo per poi essere utilizzata in una funzione nell'intero sistema. In questi casi, aggiungere un nuovo sottotipo è molto più noioso dell'aggiunta di un'istruzione case a una funzione. Se non altro, direi la manutenibilità, non la performance, delle dichiarazioni switch in questo caso particolare in cui non si beneficia dell'estensibilità di sorta.

    
risposta data 10.12.2017 - 02:16
fonte

Leggi altre domande sui tag