for vs. while
Ieri, durante uno scambio di pareri via Messenger con un amico programmatore, il discorso è caduto sulla scelta della sintassi da utilizzare per le iterazioni.
In realtà è partito tutto da un costrutto che chissà quante volte abbiamo scritto: un ciclo infinito.
Il punto non è comunque questo.
Parlando di cicli infiniti, infatti, è iniziata una discussione su quale espressione sintattica sia da preferirsi. Entrambi programmiamo in C#, ma (almeno dal punto di vista della sintassi) lo stesso discorso vale anche per C e Java, e in modo analogo per gli altri linguaggi che, seppur con keyword differenti, esprimono lo stesso concetto.
Il mio amico sosteneva che scrivere:
for (;;) { /* ... */ }
fosse più espressivo che
while (true) { /* ... */ }
mentre io sostenevo il contrario.
Rimane, è chiaro, una questione esclusivamente soggettiva. Di abitudine, magari, ma comunque soggettiva. I sostenitori del for possono dire: "Mi sa più di forever, per sempre", i sostenitori del while possono dire: "Mi dà l'idea di finchè nessuno mi interrompe".
Ma si parla sempre di preferenze personali: alla fine, lo sappiamo, i linguaggi di programmazione "ad alto livello" cercano di mappare le istruzioni più vicine alla macchina in qualcosa che la mente umana comprende con più facilità. E magari la mente di qualcuno ha più immediatezza a leggere un espressione rispetto a quella di un altro, e viceversa.
Quindi, allargando l'orizzonte, utilizzare for o while per un ciclo è la stessa cosa ?
Beh, qualche premessina è d'obbligo. (Ok, sarà il consueto post un po' lungo, forza e coraggio).
Punto primo. Mentre quello che ho scritto prima vale per ogni linguaggio di programmazione, d'ora in poi parlo di C# e .NET.
Punto secondo. Visto che qui ci si mette di mezzo il compilatore, sarà scontato dirlo, ma ho usato csc.exe. E una macchina Pentium.
Punto terzo. Leggetelo alla fine, è scritto in fondo al post.
Ok, cominciamo.
Ho scritto questo utilissimo programmino (sono due, in effetti, uno per tipo di ciclo). Nonostante la possibilità di ricavare dei soldi dalla sua vendita :-), vi scrivo il sorgente, fatto da ben, e dico ben, 1 o 2 righe (a parte la dichiarazione della classe e compagnia bella, il tutto sta dentro il Main):
Versione for:
for (i = 0; i < 1000000000; i++);
Versione while:
Int32 i = 0;
while (i++ < 1000000000);
Compilato il tutto, il mio test verte ora su due aspetti:
- Valutare quali differenze ci sono a livello di IL (e questo dipende ovviamente da cosa fa il compilatore)
- Valutare le prestazioni (velocità di esecuzione)
E i risultati sono i seguenti. Spero che conosciate un po' di IL, che comunuque qui è presente proprio con due o tre istruzioni, comunque il codice generato da csc è questo (è il codice del metodo Main, privato di signature e STAThreadAttribute). Sotto ci ho messo due righe di spiegazione, che ovviamente chi mastica quel minimo di IL può allegramente saltare (anzi no, magari un'occhiata conviene che ce la dia lo stesso perchè non vorrei aver scritto cavolate !).
Versione for:
.maxstack 2
.locals init ([0] int32 i)
IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br.s IL_0008
IL_0004: ldloc.0
IL_0005: ldc.i4.1
IL_0006: add
IL_0007: stloc.0
IL_0008: ldloc.0
IL_0009: ldc.i4 0x3b9aca00
IL_000e: blt.s IL_0004
IL_0010: ret
... che in parole vuol dire:
- Carico il valore costante 0 nella variabile locale di indice 0. (da IL_0000 a IL_0002). Skippo alla label IL_0008, che quindi qua segue nella descrizione.
- Carico sull'evaluation stack il valore della variabile locale di indice 0 (che in partenza era 0).
- Carico sullo stack il valore 0x..., cioè 1 miliardo.
- Se il valore caricato sullo stack, che è quello contenuto nella mia variabile locale, è < di 1000000000, goto IL_0004, che viene prima ma qui segue.
- Le istruzioni dalla IL_0004 alla IL_0007 non fanno altro che incrementare il valore della variabile locale: se la caricano sullo stack, si caricano anche la costante 1, li aggiungono e il risultato viene prelevato dallo stack e salvato nella variabile locale stessa.
- Tutto questo, ovviamente, finchè il contatore locale, la nostra variabile, cioè, non raggiunge il ragguardevole valore di 1000000000. A quel punto il metodo esce (lo può fare senza andare incontro ad eccezioni a runtime, lo stack infatti è vuoto).
Versione while:
.maxstack 3
.locals init ([0] int32 i)
IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br.s IL_0004
IL_0004: ldloc.0
IL_0005: dup
IL_0006: ldc.i4.1
IL_0007: add
IL_0008: stloc.0
IL_0009: ldc.i4 0x3b9aca00
IL_000e: blt.s IL_0004
IL_0010: ret
... che in parole vuol dire:
- Carico il valore costante 0 nella variabile locale di indice 0. (da IL_0000 a IL_0002).
- Carico sull'evaluation stack il valore della variabile locale di indice 0 (che in partenza era 0)
- Duplico il valore al top dello stack. Ora alla cima dello stack ci sono due valori uguali, che in partenza sono entrambi 0.
- Carico il valore costante 1 sullo stack. Lo stack ora ha 3 elementi.
- Eseguo l'operazione add, che prende i due valori al top dello stack e li sostituisce con la somma. Ora lo stack ha 2 elementi.
- Salvo il valore al top dello stack nella variabile locale di indice 0. Ora lo stack ha un elemento.
- Carico la costante 0x...., cioè un miliardo, sullo stack. Ora lo stack ha 2 elementi: alla cima c'è un miliardo, sotto c'è quello che avevo caricato al punto 2, che per la prima interazione è 0.
- Confronto l'un miliardo con quel valore. Lo stack è alla fine vuoto, e se il valore è < 1000000000, torno al punto 2, ma prima leggetevi il punto 9.
- Quando torno su, nella variabile locale di indice 0 c'è il suo valore precedente aumentato di 1. Quindi ripeto il tutto per 1000000000 di volte finchè la condizione al punto 8 non è false. Lo stack a quel punto, come visto, è vuoto e il metodo non fa altro che uscire (ret)
Che palle, direte voi !
Ma c'è bisogno di tutta sta sbrodolata di commenti ?
Alla fine cos'è che cambia ?
Beh, innanzitutto cambia il codice generato. Che magari non gliene frega niente a nessuno, ma credo sia una cosa interessante per farsi un po' di ossa con IL.
Poi cambiano le prestazioni.
Ho eseguito questi due metodi per 100 volte ciascuno, e la media, devo dire abbastanza precisa perchè la distribuzione è molto concentrata, è abbastanza diversa.
Sulla mia macchina, ho ottenuto quanto segue:
for: 661 ticks avg
while: 858 ticks avg
Quindi una differenza ... rilevabile.
Che poi non vi sia mai capitato di eseguire un ciclo per 1 miliardo di volte, non lo metto in dubbio.
Che i cicli (in genere !!!) non siano vuoti, anche questo è vero.
Che le differenze siano assorbite dall'implementazione all'interno del ciclo, vero anche qui.
Che i miei test siano poco accurati, beh, ok, lo ammetto. Ho usato metodi molto rudimentali (Environment.TickCount), niente profiler, niente perfcounters, non ho seguito le raccomandazioni per i test (leggetevi Maximizing .NET Performance). Ma credo che abbiano rilevanza lo stesso.
Non mi interessa dire, a questo punto, "il for è meglio del while", "usate for", o cose del genere. Probabilmente, in casi comuni in cui le iterazioni sono meno e il peso dell'algoritmo sta nei conti interni al ciclo, le differenze sono realmente trascurabili.
Quindi sì, usate quello che volete perchè niente è meglio che un programmatore che si riconosce nella sintassi che ha usato.
Però quello stesso programmatore deve sapere che, almeno in questo caso, non è una pura questione sintattica.
Siamo quasi alla fine !!! Evviva, ce l'avete fatta anche questa volta.
Mi rimane solo il punto 3, che vi avevo detto avrei discusso alla fine.
Quindi ciao, arrivederci alla prossima e leggete il punto 3 qua sotto.
Punto 3.
Mi sarebbe piaciuto fare considerazioni sull'output finale dell'esecuzione, cioè sul codice x86 generato dal Jitter. Alla fine il codice IL è differente, lo è sicuramente anche quello x86 (se no, dato che non contano in questo caso tempi di compilazione del metodo perchè questo avviene prima dell'esecuzione dei conti, non ci sarebbero ste differenze). Ma, nota dolente, non ci capisco una beneamata di assembler (a parte quelle due cosette tipo mov, inc, pop ... mi fermo qui, capite bene che è un po' poco !).
Quindi, se qualche lettore molto volenteroso ha voglia di esaminare l'x86 generato, e capisce le differenze, sarei curioso di sapere qualcosa.
Scrivetemi !
Grazie e a ri ciao !
UPDATE: Ah, dimenticavo. Le sintassi while e for per un ciclo infinito (che poi non è altro che un ciclo senza statement di controllo) producono proprio esattamente lo stesso IL. Ma proprio lo stesso, non cambia una virgola. Quindi lì sì che è una questione di gusti e solo di quelli ....