Efficienza del ritorno prematuro in una funzione


97

Questa è una situazione che incontro frequentemente come programmatore inesperto e mi chiedo in particolare per un mio progetto ambizioso e ad alta velocità che sto cercando di ottimizzare. Per i principali linguaggi C-like (C, objC, C ++, Java, C #, ecc.) E per i loro soliti compilatori, queste due funzioni verranno eseguite in modo altrettanto efficiente? C'è qualche differenza nel codice compilato?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

In sostanza, c'è sempre un rendimento diretto bonus / penalità quando breaking o returning presto? Come è coinvolto lo stackframe? Esistono casi speciali ottimizzati? Ci sono fattori (come l'inlining o la dimensione di "Do stuff") che potrebbero influenzare questo in modo significativo?

Sono sempre un sostenitore di una migliore leggibilità rispetto a piccole ottimizzazioni (vedo foo1 molto con la convalida dei parametri), ma questo si presenta così spesso che mi piacerebbe mettere da parte tutte le preoccupazioni una volta per tutte.

E sono consapevole delle insidie ​​dell'ottimizzazione prematura ... ugh, questi sono ricordi dolorosi.

EDIT: Ho accettato una risposta, ma la risposta di EJP spiega in modo abbastanza sintetico perché l'uso di a returnè praticamente trascurabile (in assembly, returncrea un "ramo" alla fine della funzione, che è estremamente veloce. Il ramo altera il registro del PC e può anche influenzare la cache e la pipeline, il che è piuttosto minuscolo.) In questo caso in particolare, non fa letteralmente differenza perché sia ​​il if/elseche il returncreano lo stesso ramo alla fine della funzione.


22
Non credo che questo genere di cose avrà un impatto notevole sulle prestazioni. Basta scrivere un piccolo test e vedere te stesso. Imo, la prima variante è migliore poiché non si ottengono annidamenti non necessari che migliorano la leggibilità
SirVaulterScoff

10
@SirVaulterScott, a meno che i due casi non siano in qualche modo simmetrici, nel qual caso vorresti far risaltare la simmetria mettendoli allo stesso livello di rientro.
luqui

3
SirVaulterScoff: +1 per ridurre l'annidamento non necessario
fjdumont

11
Leggibilità >>> Micro ottimizzazioni. Fallo in qualsiasi modo abbia più senso per il wetware che lo manterrà. A livello di codice macchina, queste due strutture sono identiche quando inserite anche in un compilatore abbastanza stupido. Un compilatore ottimizzato cancellerà qualsiasi parvenza di vantaggio di velocità tra i due.
SplinterReality

12
Non ottimizzare il tuo progetto "ad alta intensità di velocità" preoccupandoti di cose come questa. Profila la tua app per scoprire dove è effettivamente lenta, se in realtà è troppo lenta quando hai finito di farla funzionare. Quasi certamente non puoi indovinare cosa lo sta effettivamente rallentando.
Blueshift

Risposte:


92

Non c'è alcuna differenza:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

Significa che non c'è alcuna differenza nel codice generato anche senza l'ottimizzazione in due compilatori


59
O meglio: esiste almeno una versione di un certo compilatore che genera lo stesso codice per le due versioni.
UncleZeiv

11
@UncleZeiv - la maggior parte, se non tutti, i compilatori tradurrà la sorgente in un modello di grafico del flusso di esecuzione. È difficile immaginare un'implementazione sana che fornisca grafici di flusso significativamente diversi per questi due esempi. L'unica differenza che potresti vedere è che le due diverse cose da fare vengono scambiate e anche questo potrebbe essere annullato in molte implementazioni per ottimizzare la previsione dei rami o per qualche altro problema in cui la piattaforma determina l'ordinamento preferito.
Steve314

6
@ Steve314, certo, stavo solo pignolando :)
UncleZeiv

@UncleZeiv: testato anche su clang e stesso risultato
Dani

Non capisco. Sembra chiaro che something()verrà sempre eseguito. Nella domanda originale, OP ha Do stuffe Do diffferent stuffdipende dalla bandiera. Non sono sicuro che il codice generato sarà lo stesso.
Luc M

65

La risposta breve è: nessuna differenza. Fatti un favore e smettila di preoccuparti di questo. Il compilatore di ottimizzazione è quasi sempre più intelligente di te.

Concentrati su leggibilità e manutenibilità.

Se vuoi vedere cosa succede, creali con le ottimizzazioni e guarda l'output dell'assembler.


8
@Philip: E fai un favore anche a tutti gli altri e smettila di preoccuparti di questo. Il codice che scrivi sarà letto e mantenuto anche da altri (e anche se dovessi scrivere che non sarà mai letto da altri, svilupperai comunque abitudini che influenzeranno altro codice che scrivi che sarà letto da altri). Scrivi sempre il codice per essere il più facile da capire possibile.
hlovdal

8
Gli ottimizzatori non sono più intelligenti di te !!! Sono solo più veloci nel decidere dove l'impatto non ha molta importanza. Dove è davvero importante, sicuramente, con una certa esperienza, ottimizzerai meglio del compilatore.
johannes

10
@johannes Lasciami essere in disaccordo. Il compilatore non cambierà il tuo algoritmo con uno migliore, ma fa un lavoro straordinario nel riordinare le istruzioni per ottenere la massima efficienza della pipeline e altre cose non così banali per i loop (fissione, fusione, ecc.) Che anche un programmatore esperto non può decidere cosa è meglio a priori a meno che non abbia una conoscenza approfondita dell'architettura della CPU.
Fortran

3
@johannes - per questa domanda, puoi presumere che lo sia. Inoltre, in generale, potresti occasionalmente essere in grado di ottimizzare meglio del compilatore in alcuni casi speciali ma ciò richiede un bel po 'di conoscenza specialistica in questi giorni - il caso normale è che l'ottimizzatore applica la maggior parte delle ottimizzazioni a cui puoi pensare e lo fa sistematicamente, non solo in alcuni casi speciali. A questa domanda, il compilatore probabilmente costruirà esattamente lo stesso diagramma di flusso di esecuzione per entrambe le forme. La scelta di un algoritmo migliore è un lavoro umano, ma l'ottimizzazione a livello di codice è quasi sempre una perdita di tempo.
Steve314

4
Sono d'accordo e non sono d'accordo con questo. Ci sono casi in cui il compilatore non può sapere che qualcosa è equivalente a qualcos'altro. Sapevi che spesso è molto più veloce da fare x = <some number>di if(<would've changed>) x = <some number>quanto i rami senza semi possano davvero ferire. D'altra parte, a meno che questo non sia all'interno del ciclo principale di un'operazione estremamente intensiva, non me ne preoccuperei nemmeno.
user606723

28

Risposte interessanti: anche se sono d'accordo con tutte (finora), ci sono possibili connotazioni a questa domanda che sono state finora completamente ignorate.

Se il semplice esempio precedente viene esteso con l'allocazione delle risorse e quindi il controllo degli errori con una potenziale liberazione delle risorse risultante, il quadro potrebbe cambiare.

Considera l' approccio ingenuo che i principianti potrebbero adottare:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

Quanto sopra rappresenterebbe una versione estrema dello stile di ritorno prematuramente. Si noti come il codice diventa molto ripetitivo e non manutenibile nel tempo quando la sua complessità aumenta. Al giorno d'oggi le persone potrebbero utilizzare la gestione delle eccezioni per catturarli.

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip ha suggerito, dopo aver esaminato l'esempio goto di seguito, di utilizzare un interruttore / custodia break-less all'interno del blocco catch sopra. Si potrebbe cambiare (tipo di (e)) e poi cadere attraverso le free_resourcex()chiamate, ma questo non è banale e necessita di considerazione progettuale . E ricorda che un interruttore / custodia senza interruzioni è esattamente come il goto con le etichette a margherita di seguito ...

Come ha sottolineato Mark B, in C ++ è considerato un buon stile seguire il principio di Resource Aquisition is Initialization , RAII in breve. L'essenza del concetto è usare l'istanza di oggetti per acquisire risorse. Le risorse vengono quindi liberate automaticamente non appena gli oggetti escono dall'ambito e vengono chiamati i loro distruttori. Per le risorse interdipendenti è necessario prestare particolare attenzione per garantire il corretto ordine di deallocazione e per progettare i tipi di oggetti in modo tale che i dati richiesti siano disponibili per tutti i distruttori.

O nei giorni precedenti l'eccezione potrebbe fare:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Ma questo esempio semplicissimo ha diversi inconvenienti: può essere utilizzato solo se le risorse allocate non dipendono l'una dall'altra (ad esempio non può essere utilizzato per allocare memoria, quindi aprire un filehandle, quindi leggere i dati dall'handle nella memoria ) e non fornisce codici di errore individuali e distinguibili come valori di ritorno.

Per mantenere il codice veloce (!), Compatto e facilmente leggibile ed estensibile, Linus Torvalds ha applicato uno stile diverso per il codice del kernel che si occupa delle risorse, anche usando il famigerato goto in un modo che ha assolutamente senso :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

L'essenza della discussione sulle mailing list del kernel è che la maggior parte delle funzionalità del linguaggio che sono "preferite" rispetto all'istruzione goto sono gotos implicite, come enormi if / else simili ad albero, gestori di eccezioni, istruzioni loop / break / continue, ecc. E i goto nell'esempio precedente sono considerati ok, poiché saltano solo per una piccola distanza, hanno etichette chiare e liberano il codice da altri disordine per tenere traccia delle condizioni di errore. Questa domanda è stata discussa anche qui su stackoverflow .

Tuttavia ciò che manca nell'ultimo esempio è un bel modo per restituire un codice di errore. Stavo pensando di aggiungere un result_code++dopo ogni free_resource_x()chiamata e restituire quel codice, ma questo compensa alcuni dei guadagni di velocità dello stile di codifica sopra. Ed è difficile restituire 0 in caso di successo. Forse sono solo privo di fantasia ;-)

Quindi, sì, penso che ci sia una grande differenza nella questione della codifica dei rendimenti prematuri o meno. Ma penso anche che sia evidente solo nel codice più complicato che è più difficile o impossibile da ristrutturare e ottimizzare per il compilatore. Che di solito è il caso una volta che l'allocazione delle risorse entra in gioco.


1
Wow, davvero interessante. Posso sicuramente apprezzare l'incontenibilità dell'approccio ingenuo. Come migliorerebbe la gestione delle eccezioni in quel caso particolare? Come un catchcontenente switchun'istruzione senza interruzioni sul codice di errore?
Philip Guin

@Philip Aggiunto esempio di gestione delle eccezioni di base. Nota che solo il goto ha una possibilità di fall-through. L'interruttore proposto (tipo di (e)) sarebbe di aiuto, ma non è banale e necessita di considerazione di progettazione . E ricorda che un interruttore / custodia senza interruzioni è esattamente come il goto con etichette a margherita ;-)
cfi

+1 questa è la risposta corretta per C / C ++ (o qualsiasi linguaggio che richiede la liberazione manuale della memoria). Personalmente, non mi piace la versione con più etichette. Nella mia azienda precedente, era sempre "goto fin" (era un'azienda francese). In fin avremmo de-allocato qualsiasi memoria, e questo era l'unico uso di goto che avrebbe superato la revisione del codice.
Kip

1
Nota che in C ++ non faresti nessuno di questi approcci, ma useresti RAII per assicurarti che le risorse siano ripulite correttamente.
Mark B

12

Anche se questa non è una gran risposta, un compilatore di produzione sarà molto più bravo di te nell'ottimizzazione. Preferirei la leggibilità e la manutenibilità rispetto a questo tipo di ottimizzazioni.


9

Per essere precisi, returnverrà compilato in un ramo fino alla fine del metodo, dove ci sarà RETun'istruzione o qualunque essa sia. Se lo lasci fuori, la fine del blocco prima di elseverrà compilata in un ramo fino alla fine del elseblocco. Quindi puoi vedere in questo caso specifico che non fa alcuna differenza.


Gotcha. In realtà penso che questo risponda alla mia domanda abbastanza succintamente; Immagino sia letteralmente solo un'aggiunta al registro, il che è piuttosto trascurabile (a meno che non si stia facendo programmazione di sistema, e anche in questo caso ...) Darò a questo una menzione d'onore.
Philip Guin

@Philip cosa aggiunta al registro? Non ci sono affatto istruzioni extra nel percorso.
Marchese di Lorne

Ebbene, entrambi avrebbero aggiunte al registro. Questo è tutto un ramo di assemblaggio, non è vero? Un'aggiunta al contatore del programma? Potrei sbagliarmi qui.
Philip Guin

1
@Philip No, un ramo di assemblaggio è un ramo di assemblaggio. Ovviamente influisce sul PC, ma potrebbe essere ricaricandolo completamente e ha anche effetti collaterali nel processore rispetto alla pipeline, alle cache, ecc.
Marchese di Lorne

4

Se vuoi davvero sapere se c'è una differenza nel codice compilato per il tuo particolare compilatore e sistema, dovrai compilare e guardare l'assembly tu stesso.

Tuttavia, nel grande schema delle cose è quasi certo che il compilatore può ottimizzare meglio della tua regolazione fine, e anche se non può è molto improbabile che abbia davvero importanza per le prestazioni del tuo programma.

Invece, scrivi il codice nel modo più chiaro affinché gli umani lo leggano e lo mantengano, e lascia che il compilatore faccia ciò che sa fare meglio: generare il miglior assembly possibile dal tuo codice sorgente.


4

Nel tuo esempio, il rendimento è evidente. Cosa succede alla persona che esegue il debug quando il ritorno è una o due pagine sopra / sotto dove // ​​si verificano cose diverse? Molto più difficile da trovare / vedere quando c'è più codice.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Ovviamente, una funzione non dovrebbe essere lunga più di una (o anche due) pagine. Ma l'aspetto del debug non è stato ancora trattato in nessuna delle altre risposte. Punto preso!
cfi

3

Sono assolutamente d'accordo con blueshift: leggibilità e manutenibilità prima di tutto !. Ma se sei davvero preoccupato (o vuoi semplicemente imparare cosa sta facendo il tuo compilatore, che è sicuramente una buona idea a lungo termine), dovresti cercare te stesso.

Ciò significherà usare un decompilatore o guardare l'output del compilatore di basso livello (es. Linguaggio di assemblaggio). In C # o in qualsiasi linguaggio .Net, gli strumenti qui documentati ti forniranno ciò di cui hai bisogno.

Ma come lei stesso ha osservato, questa è probabilmente un'ottimizzazione prematura.


1

From Clean Code: A Handbook of Agile Software Craftsmanship

Gli argomenti della bandiera sono brutti. Passare un valore booleano a una funzione è una pratica davvero terribile. Immediatamente complica la firma del metodo, proclamando ad alta voce che questa funzione fa più di una cosa. Fa una cosa se la bandiera è vera e un'altra se la bandiera è falsa!

foo(true);

nel codice costringerà il lettore a navigare nella funzione e perdere tempo a leggere foo (flag booleano)

Una base di codice meglio strutturata ti darà migliori opportunità di ottimizzare il codice.


Lo sto solo usando come esempio. Quello che viene passato alla funzione potrebbe essere un int, double, una classe, lo chiami, non è proprio al centro del problema.
Philip Guin

La domanda che hai posto riguarda l'esecuzione di un interruttore all'interno della tua funzione, nella maggior parte dei casi, è un odore di codice. Può essere ottenuto in molti modi e il lettore non deve leggere l'intera funzione, diciamo cosa significa foo (28)?
Yuan

0

Una scuola di pensiero (non riesco a ricordare la testa d'uovo che l'ha proposta al momento) è che tutte le funzioni dovrebbero avere un solo punto di ritorno da un punto di vista strutturale per rendere il codice più facile da leggere ed eseguire il debug. Questo, suppongo, serve più per programmare il dibattito religioso.

Un motivo tecnico per cui potresti voler controllare quando e come esce una funzione che infrange questa regola è quando stai codificando applicazioni in tempo reale e vuoi assicurarti che tutti i percorsi di controllo attraverso la funzione richiedano lo stesso numero di cicli di clock per essere completati.


Uh, pensavo avesse a che fare con la pulizia (specialmente quando si codifica in C).
Thomas Eding

no, non importa dove lasci un metodo fintanto che ritorni lo stack viene spinto indietro (questo è tutto ciò che viene "ripulito").
MartyTPS

-4

Sono contento che tu abbia sollevato questa domanda. Dovresti sempre utilizzare i rami per un ritorno anticipato. Perché fermarsi qui? Unisci tutte le tue funzioni in una se puoi (almeno il più possibile). Questo è fattibile se non c'è ricorsione. Alla fine, avrai una funzione principale enorme, ma questo è ciò di cui hai bisogno / vuoi per questo genere di cose. Successivamente, rinomina i tuoi identificatori in modo che siano il più brevi possibile. In questo modo, quando il codice viene eseguito, viene impiegato meno tempo a leggere i nomi. Avanti fare ...


3
Posso dire che stai scherzando, ma la cosa spaventosa è che alcune persone potrebbero prendere sul serio il tuo consiglio!
Daniel Pryden

D'accordo con Daniel. Per quanto ami il cinismo, non dovrebbe essere usato nella documentazione tecnica, nei white paper e nei siti di domande e risposte come SO.
cfi

1
-1 per una risposta cinica, non necessariamente riconoscibile dai principianti.
Johan Bezem
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.