Mi sembra che alla gente non piaccia molto goto
un'affermazione, quindi ho sentito il bisogno di raddrizzare un po '.
Credo che le "emozioni" delle persone alla goto
fine si riducano alla comprensione del codice e (idee sbagliate) sulle possibili implicazioni delle prestazioni. Prima di rispondere alla domanda, entrerò quindi in alcuni dettagli su come viene compilata.
Come tutti sappiamo, C # viene compilato in IL, che viene quindi compilato in assembler usando un compilatore SSA. Darò un po 'di spunti su come tutto questo funziona, e quindi proverò a rispondere alla domanda stessa.
Da C # a IL
Per prima cosa abbiamo bisogno di un pezzo di codice C #. Cominciamo semplice:
foreach (var item in array)
{
// ...
break;
// ...
}
Lo farò passo dopo passo per darti una buona idea di cosa succede sotto il cofano.
Prima traduzione: da foreach
al for
ciclo equivalente (Nota: sto usando un array qui, perché non voglio entrare nei dettagli di IDisposable - nel qual caso dovrei anche usare un IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Seconda traduzione: for
e break
è tradotto in un equivalente più semplice:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
E terza traduzione (questo è l'equivalente del codice IL): cambiamo break
e while
in un ramo:
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
Mentre il compilatore fa queste cose in un solo passaggio, ti dà un'idea del processo. Il codice IL che si evolve dal programma C # è la traduzione letterale dell'ultimo codice C #. Puoi vederlo qui: https://dotnetfiddle.net/QaiLRz (fai clic su 'visualizza IL')
Ora, una cosa che hai osservato qui è che durante il processo, il codice diventa più complesso. Il modo più semplice per osservarlo è il fatto che avevamo bisogno di sempre più codice per realizzare la stessa cosa. Si potrebbe anche sostenere che foreach
, for
, while
e break
sono in realtà corto mani goto
, che è in parte vero.
Da IL a Assembler
Il compilatore .NET JIT è un compilatore SSA. Non entrerò in tutti i dettagli del modulo SSA qui e su come creare un compilatore ottimizzante, è troppo, ma posso dare una comprensione di base di ciò che accadrà. Per una comprensione più profonda, è meglio iniziare a leggere sull'ottimizzazione dei compilatori (questo libro mi piace per una breve introduzione: http://ssabook.gforge.inria.fr/latest/book.pdf ) e LLVM (llvm.org) .
Ogni compilatore ottimizzato si basa sul fatto che il codice è semplice e segue schemi prevedibili . Nel caso dei loop FOR, utilizziamo la teoria dei grafi per analizzare i rami e quindi ottimizzare cose come i cicli nei nostri rami (ad esempio rami all'indietro).
Tuttavia, ora abbiamo filiali dirette per implementare i nostri loop. Come avrai intuito, questo è in realtà uno dei primi passi che la JIT sta per risolvere, in questo modo:
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
Come puoi vedere, ora abbiamo un ramo all'indietro, che è il nostro piccolo anello. L'unica cosa che è ancora brutta qui è il ramo con cui siamo finiti a causa della nostra break
dichiarazione. In alcuni casi, possiamo spostarlo allo stesso modo, ma in altri è lì per rimanere.
Quindi perché il compilatore fa questo? Bene, se possiamo srotolare il loop, potremmo essere in grado di vettorializzarlo. Potremmo anche essere in grado di provare che sono state aggiunte solo costanti, il che significa che tutto il nostro circuito potrebbe svanire nel nulla. Riassumendo: rendendo prevedibili gli schemi (rendendo prevedibili i rami), possiamo provare che certe condizioni restano nel nostro ciclo, il che significa che possiamo fare magie durante l'ottimizzazione JIT.
Tuttavia, i rami tendono a spezzare quei bei modelli prevedibili, il che è qualcosa di ottimizzatore quindi una specie di antipatia. Break, continue, goto - tutti hanno intenzione di rompere questi schemi prevedibili - e quindi non sono davvero "carini".
A questo punto dovresti anche capire che un semplice foreach
è più prevedibile di un mucchio di goto
affermazioni che vanno dappertutto. In termini di (1) leggibilità e (2) dal punto di vista dell'ottimizzatore, è sia la soluzione migliore.
Un'altra cosa degna di nota è che è molto importante per l'ottimizzazione dei compilatori di assegnare i registri alle variabili (un processo chiamato allocazione dei registri ). Come forse saprai, c'è solo un numero finito di registri nella tua CPU e sono di gran lunga i pezzi di memoria più veloci nel tuo hardware. Le variabili utilizzate nel codice che si trova nel ciclo più interno, hanno maggiori probabilità di ottenere un registro assegnato, mentre le variabili al di fuori del ciclo sono meno importanti (perché questo codice è probabilmente colpito meno).
Aiuto, troppa complessità ... cosa devo fare?
La linea di fondo è che dovresti sempre usare i costrutti linguistici che hai a tua disposizione, che di solito (implicitamente) costruiranno modelli prevedibili per il tuo compilatore. Cercate di evitare strani rami, se possibile (in particolare: break
, continue
, goto
o return
in mezzo al nulla).
La buona notizia qui è che questi schemi prevedibili sono sia facili da leggere (per gli umani) sia facili da individuare (per i compilatori).
Uno di questi schemi si chiama SESE, che sta per Single Entry Single Exit.
E ora arriviamo alla vera domanda.
Immagina di avere qualcosa del genere:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
Il modo più semplice per rendere questo un modello prevedibile è semplicemente eliminare if
completamente:
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
In altri casi puoi anche dividere il metodo in 2 metodi:
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
Variabili temporanee? Buono, cattivo o brutto?
Potresti anche decidere di restituire un booleano all'interno del loop (ma preferisco personalmente il modulo SESE perché è così che il compilatore lo vedrà e penso che sia più pulito da leggere).
Alcune persone pensano che sia più pulito usare una variabile temporanea e propongono una soluzione come questa:
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
Personalmente sono contrario a questo approccio. Guarda di nuovo su come viene compilato il codice. Ora pensa a cosa farà con questi schemi piacevoli e prevedibili. Prendi la foto?
Bene, fammi precisare. Quello che accadrà è che:
- Il compilatore scriverà tutto come rami.
- Come fase di ottimizzazione, il compilatore eseguirà l'analisi del flusso di dati nel tentativo di rimuovere la strana
more
variabile che viene utilizzata solo nel flusso di controllo.
- In caso di successo, la variabile
more
verrà eliminata dal programma e restano solo i rami. Questi rami saranno ottimizzati, quindi otterrai un solo ramo dal ciclo interno.
- In caso di esito negativo, la variabile
more
viene sicuramente utilizzata nel ciclo più interno, quindi se il compilatore non la ottimizza, ha un'alta probabilità di essere allocata in un registro (che consuma preziosa memoria del registro).
Quindi, per riassumere: l'ottimizzatore nel tuo compilatore avrà un sacco di problemi per capire che more
viene utilizzato solo per il flusso di controllo e, nel migliore dei casi, lo tradurrà in un singolo ramo al di fuori dell'esterno per ciclo continuo.
In altre parole, lo scenario migliore è che finirà con l'equivalente di questo:
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
La mia opinione personale su questo è abbastanza semplice: se questo è ciò che intendevamo da sempre, rendiamo il mondo più facile sia per il compilatore che per la leggibilità, e scriviamo subito.
tl; dr:
Linea di fondo:
- Usa una semplice condizione nel tuo ciclo for se possibile. Attenersi il più possibile ai costrutti linguistici di alto livello che avete a vostra disposizione.
- Se tutto fallisce e rimani con uno
goto
o bool more
, preferisci il primo.