Qualsiasi programma può venire codificato in un linguaggio di programmazione
usando tre sole modalità di controllo del flusso elaborativo: l'esecuzione
sequenziale, l'esecuzione condizionale e i cicli.
L'esecuzione sequenziale è la più semplice delle tre e spesso
non viene pensata come una vera e propria modalità di controllo;
infatti è logico attendersi che, in assenza di ogni altra specifica,
la prossima istruzione ad essere eseguita sia quella che nella codifica
segue quella attuale.
Le altre due strutture di controllo richiedono invece qualche approfondimento.
Le istruzioni di controllo condizionale
Il linguaggio C dispone di due diversi strumenti per condizionare il flusso
di esecuzione dei programmi. Vale la pena di analizzarli compiutamente.
if...else
L'esecuzione condizionale nella forma più semplice è specificata
tramite la parola chiave if, la quale indica al compilatore che
l'istruzione seguente deve essere eseguita se la condizione, sempre specificata
tra parentesi, è vera. Se la condizione non è verificata,
allora l'istruzione non è eseguita e il flusso elaborativo salta
all'istruzione successiva. L'istruzione da eseguire al verificarsi della
condizione può essere una sola linea di codice, chiusa dal punto
e virgola, oppure un blocco di linee di codice, ciascuna conclusa dal punto
e virgola e tutte quante comprese tra parentesi graffe. Esempietto:
if(a == b)
printf("a è maggiore di b\n");
if(a == c) {
printf("a è maggiore di c\n");
a = c;
}
Nel codice riportato, se il valore contenuto in a è uguale
a quello contenuto in b viene visualizzata la stringa "a
è maggiore di b"; in caso contrario la chiamata a printf()
non è eseguita e l'elaborazione prosegue con la successiva istruzione,
che è ancora una if. Questa volta, se a è
uguale a c viene eseguito il blocco di istruzioni comprese tra
le parentesi graffe, altrimenti esso è saltato "a pié
pari" e il programma prosegue con la prima istruzione che segue la
graffa chiusa.
Come regola generale, una condizione viene espressa tramite uno degli operatori
logici del C ed è sempre racchiusa tra parentesi tonde.
La if è completata dalla parola chiave else, che
viene utilizzata quando si devono definire due possibilità alternative;
inoltre più strutture if...else possono essere annidate
qualora serva effettuare test su più "livelli" in cascata:
if(a == b)
printf("a è maggiore di b\n");
else {
printf("a è minore o uguale a b\n")
if(a < b)
printf("a è proprio minore di b\n");
else
printf("a è proprio uguale a b\n");
}
Quando è presente la else, se la condizione è vera
viene eseguito solo ciò che sta tra la if e la else;
in caso contrario è eseguito solo il codice che segue la else
stessa. L'esecuzione dei due blocchi di codice è, in altre parole,
alternativa.
E' estremamente importante ricordare che ogni else viene dal compilatore
riferita all'ultima if incontrata: quando si annidano costruzioni
if...else bisogna quindi fare attenzione alla costruzione logica
delle alternative. Cerchiamo di chiarire il concetto con un esempio.
Supponiamo di voler codificare in C il seguente algoritmo: se a
è uguale a b allora si controlla se a è
maggiore di c. Se anche questa condizione è vera, si visualizza
un messaggio. Se invece la prima delle due condizioni è falsa, cioè
a non è uguale a b, allora si assegna a c
il valore di b. Vediamo ora un'ipotesi di codifica:
if(a == b)
if(a > c)
printf("a è maggiore di c\n");
else
c = b;
I rientri dal margine sinistro delle diverse righe evidenziano che le intenzioni
sono buone: è immediato collegare, da un punto di vista visivo,
la else alla prima if. Peccato che il compilatore non
si interessi affatto alle indentazioni: esso collega la else alla
seconda if, cioè all'ultima if incontrata. Bisogna
correre ai ripari:
if(a == b)
if(a > c)
printf("a è maggiore di c\n");
else;
else
c = b;
Quella appena vista è una possibilità. Introducendo una else
"vuota" si raggiunge lo scopo, perché questa è
collegata all'ultima if incontrata, cioè la seconda. Quando
il compilatore incontra la seconda else, l'ultima if
non ancora "completa", risalendo a ritroso nel codice, è
la prima delle due. I conti tornano... ma c'è un modo più
elegante.
if(a == b) {
if(a > c)
printf("a è maggiore di c\n");
}
else
c = b;
In questo caso le parentesi graffe indicano chiaramente al compilatore
qual è la parte di codice che dipende direttamente dalla prima if
e non vi è il rischio che la else sia collegata alla seconda,
dal momento che questa è interamente compresa nel blocco tra le
graffe e quindi è sicuramente "completa".
Come si vede, salvo alcune particolarità, nulla diversifica la logica
della if del C da quella delle if (o equivalenti parole
chiave) disponibili in altri linguaggi di programmazione[1].
switch
La if gestisce ottimamente quelle situazioni in cui, a seguito
della valutazione di una condizione, si presentano due sole possibilità
alternative. Quando le alternative siano più di due, si è
costretti a utilizzare più istruzioni if nidificate, il
che può ingarbugliare non poco la struttura logica del codice e
menomarne la leggibilità.
Quando la condizione da valutare sia esprimibile mediante un'espressione
restituente un int o un char, il C rende disponibile
l'istruzione switch, che consente di valutare un numero qualsiasi
di alternative per il risultato di detta espressione. Diamo subito un'occhiata
ad un caso pratico:
#define EOF -1
#define LF 10
#define CR 13
#define BLANK ' '
....
char c;
long ln = 0L, cCount = 0L;
....
switch(c = fgetc(inFile)) {
case EOF:
return;
case LF:
if(++ln == MaxLineNum)
return;
case BLANK:
cCount++;
case NULL:
case CR:
break;
default:
*ptr++ = c;
}
Il frammento di codice riportato fa parte di una funzione che legge il
contenuto di un file carattere per carattere ed esegue azioni diverse a
seconda del carattere letto: in particolare, la funzione fgetc()
legge un carattere dal file associato al descrittore[2]inFile e lo restituisce. Tale carattere è memorizzato nella
variabile c, dichiarata di tipo char. L'operazione di
assegnamento è, in C, un'espressione che restituisce il valore assegnato,
pertanto il valore memorizzato nella variabile c è valutato
dalla switch, che esegue una delle possibili alternative definite.
Se si tratta del valore definito dalla costante manifesta EOF
la funzione termina; se si tratta del carattere definito come LF
viene valutato quante righe sono già state scandite per decidere
se terminare o no; se si tratta di un LF o di un BLANK
è incrementato un contatore; i caratteri definiti come CR
e NULL (il solito zero binario) vengono semplicemente ignorati;
qualsiasi altro carattere è copiato in un buffer il cui puntatore
è incrementato di conseguenza.
E' meglio scendere in maggiori dettagli. Per prima cosa va osservato che
l'espressione da valutare deve trovarsi tra parentesi tonde. Inoltre il
corpo della switch, cioè l'insieme delle alternative, è
racchiuso tra parentesi graffe. Ogni singola alternativa è definita
dalla parola chiave case, seguita da una costante (non sono ammesse
variabili o espressioni non costanti) intera (o char), a sua volta
seguita dai due punti (":"). Tutto ciò che segue
i due punti è il codice che viene eseguito qualora l'espressione
valutata assuma proprio il valore della costante tra la case e
i due punti, fino alla prima istruzione break incontrata (se incontrata!),
la quale determina l'uscita dalla switch, cioè un salto
alla prima istruzione che segue la graffa chiusa. La parola chiave default
seguita dai due punti introduce la sezione di codice da eseguire qualora
l'espressione non assuma nessuno dei valori specificati dalle diverse case.
Ma non finisce qui.
Tra le parentesi graffe deve essere specificata almeno una condizione:
significa che la switch potrebbe essere seguita anche da una sola
case o dalla default, e che quindi possono esistere delle
switch prive di default o di case. La default,
comunque, se presente è unica. Complicato? Più a parole che
nei fatti...
Torniamo all'esempio: cosa accade se c vale EOF? viene
eseguito tutto ciò che segue i due punti, cioè l'istruzione
return. Questa ci "catapulta" addirittura fuori dalla
funzione eseguita in quel momento, quindi della switch non siparla
proprio più...
Se invece c vale LF, l'esecuzione salta alla if
che segue immediatamente la seconda case. Se la condizione valutata
dalla if è vera... addio funzione; altrimenti l'esecuzione
prosegue con l'istruzione immediatamente successiva. E' molto importante
sottolineare che, a differenza di quanto si potrebbe pensare, la presenza
di altre case non arresta l'esecuzione e non produce l'uscita
dalla switch: viene quindi incrementata la variabile cCount.
Solo a questo punto l'istruzione break determina l'uscita dalla
switch.
L'incremento della cCount è invece la prima istruzione
eseguita se c vale BLANK, ed è anche... l'ultima
perché subito dopo si incontra la break. Se c
vale CR o NULL si incontra immediatamente la break,
e quindi si esce subito dalla switch. Da ciò si vede che
quando in una switch è necessario trattare due possibili
casi in modo identico è sufficiente accodare le due case.
Infine, se in c non vi è nessuno dei caratteri esaminati,
viene eseguito ciò che segue la default.
E' forse superfluo precisare che le break, se necessario, possono
essere più di una e possono dipendere da altre condizioni valutate
all'interno di una case, ad esempio mediante una if.
Inoltre una case può contenere un'intera switch,
nella quale ne può essere annidata una terza... tutto sta a non
perdere il filo logico dei controlli. Esempio veloce:
switch(a) {
case 0:
switch(b) {
case 25:
....
break;
case 30:
case 31:
....
case 40:
....
break;
default:
....
}
....
break;
case 1:
....
break;
case 2:
....
}
Se a è uguale a 0 viene eseguita la seconda switch,
al termine della quale si rientra nella prima (e sempre nella parte di
codice dipendente dalla case per 0). La prima switch,
inoltre, non ha la default: se a non vale 0,
né 1, né 2 l'esecuzione salta direttamente
alla prima istruzione che segue la graffa che la chiude.
I blocchi di istruzioni dipendenti da una case, negli esempi visti,
non sono mai compresi tra graffe. In effetti esse non sono necessarie (ma
lo sono, ripetiamolo, per aprire e chiudere la switch), però,
se presenti, non guastano. In una parola: sono facoltative.
goto
Il C supporta un'istruzione che ha il formato generale:
goto etichetta;
....
etichetta:
oppure:
etichetta:
....
goto etichetta;
L'etichetta può essere un qualsiasi nome (sì, anche
Pippo o PLUTO) ed è seguita dai due punti (":").
L'istruzione goto è detta "di salto incondizionato",
perché quando viene eseguita il controllo passa immediatamente alla
prima istruzione che segue i due punti che chiudono l'etichetta.
E' però possibile saltare ad una etichetta solo se si trova all'interno
della stessa funzione in cui si trova la goto; non sono consentiti
salti interfunzione.
Per favore, non usate mai la goto. Può rendere meno chiaro
il flusso elaborativo alla lettura del listato ed è comunque sempre[3]
possibile ottenere lo stesso risultato utilizzando un'altra struttura di
controllo tra quelle disponibili, anche se talvolta è meno comodo.
La giustificazione più usuale all'uso di goto in un programma
C è relativa alla possibilità di uscire immediatamente da
cicli annidati al verificarsi di una data condizione, ma anche in questi
casi è preferibile utilizzare metodi alternativi.
I cicli
Il linguaggio C dispone anche di istruzioni per il controllo dei cicli:
con esse è possibile forzare l'iterazione su blocchi di codice più
o meno ampi.
while
Mediante l'istruzione while è possibile definire un ciclo
ripetuto finché una data condizione risulta vera. Vediamo subito
un esempio:
while(a < b) {
printf("a = %d\n",a);
++a;
}
Le due righe comprese tra le graffe sono eseguite finché la variabile
a, incremento dopo incremento, diventa uguale a b. A
questo punto l'esecuzione prosegue con la prima istruzione che segue la
graffa chiusa.
Vale la pena di addentrarsi un poco nell'algoritmo, esaminando con maggiore
dettaglio ciò che accade. Come prima operazione viene valutato se
a è minore di b (la condizione deve essere espressa
tra parentesi tonde). Se essa risulta vera vengono eseguiti la printf()
e l'autoincremento di a, per ritornare poi al confronto tra a
e b. Se la condizione è vera il ciclo è ripetuto,
altrimenti si prosegue, come già accennato, con quanto segue la
parentesi graffa chiusa.
Se ne trae, innanzitutto, che se al primo test la condizione non è
vera, il ciclo non viene eseguito neppure una volta. Inoltre è indispensabile
che all'interno delle graffe accada qualcosa che determini le condizioni
necessarie per l'uscita dal ciclo: in questo caso i successivi incrementi
di a rendono falsa, prima o poi, la condizione da cui tutto il
ciclo while dipende.
Esiste però un altro metodo per abbandonare un ciclo al verificarsi
di una certa condizione: si tratta dell'istruzione break[4].
Esempio:
In questo caso a è incrementata e poi confrontata con il
valore 100: se uguale, il ciclo è interrotto, altrimenti
esso prosegue con il decremento di c. E' anche possibile escludere
dall'esecuzione una parte del ciclo e forzare il ritorno al test:
while(a < b) {
if(a++ < c)
continue;
printf("a = %d\n",a);
if(++a == 100)
break;
--c;
}
Nell'ultimo esempio presentato, a viene confrontata con c
ed incrementata. Se, prima dell'incremento essa è minore di c
il flusso elaborativo ritorna al test dell'istruzione while; la
responsabile del salto forzato è l'istruzione continue,
che consente di iniziare da capo una nuova iterazione. In caso contrario
viene chiamata printf() e, successivamente, viene effettuato il
nuovo test con eventuale uscita dal ciclo.
All'interno del ciclo per (a < b) ve n'è un secondo,
per (c < x). Già nella prima iterazione del ciclo "esterno",
se la condizione (c < x) è vera si entra in quello "interno",
che viene interamente elaborato (cioè c è incrementata
finché assume valore pari ad x) prima che venga eseguita
la successiva istruzione del ciclo esterno. In pratica, ad ogni iterazione
del ciclo esterno avviene una serie completa di iterazioni nel ciclo interno.
Va sottolineato che eventuali istruzioni break o continue
presenti nel ciclo interno sono relative esclusivamente a quest'ultimo:
una break produrrebbe l'uscita dal ciclo interno e una continue
il ritorno al test, sempre del ciclo interno.
Si può ancora notare, infine, che il ciclo per (c < x)
si compone di una sola istruzione: proprio per questo motivo è stato
possibile omettere le parentesi graffe.
do...while
I cicli di tipo do...while sono, come si può immaginare,
"parenti stretti" dei cicli di tipo while. Vediamone
subito uno:
Non a caso è stato riportato qui uno degli esempi utilizzati poco
sopra con riferimento all'istruzione while: in effetti i due cicli
sono identici in tutto e per tutto, tranne che per un particolare. Nei
cicli di tipo do...while il test sulla condizione è effettuato
al termine dell'iterazione, e non all'inizio: ciò ha due conseguenze
importanti.
In primo luogo un ciclo do...while è eseguito sempre almeno
una volta, infatti il flusso elaborativo deve percorrere tutto il blocco
di codice del ciclo prima di giungere a valutare per la prima volta la
condizione. Se questa è falsa il ciclo non viene ripetuto e l'elaborazione
prosegue con la prima istruzione che segue la while, ma resta
evidente che, comunque, il ciclo è già stato compiuto una
volta.
In secondo luogo l'istruzione continue non determina un salto
a ritroso, bensì in avanti. Essa infatti forza in ogni tipo di ciclo
un nuovo controllo della condizione; nei cicli while la condizione
è all'inizio del blocco di codice, e quindi per poterla raggiungere
da un punto intermedio di questo è necessario un salto all'indietro,
mentre nei cicli do...while il test è a fine codice, e
viene raggiunto, ovviamente, con un salto in avanti.
Per ogni altro aspetto del comportamento dei cicli do...while,
in particolare l'istruzione break, valgono le medesime considerazioni
effettuate circa quelli di tipo while.
for
Tra le istruzioni C di controllo dei ciclo, la for è sicuramente
la più versatile ed efficiente. La for è presente
in tutti (o quasi) i linguaggi, ma in nessuno ha la potenza di cui dispone
in C. Infatti, in generale, i cicli di tipo while e derivati sono
utilizzati nelle situazioni in cui non è possibile conoscere a priori
il numero esatto di iterazioni, mentre la for, grazie alla sua
logica "punto di partenza; limite; passo d'incremento",
si presta proprio ai casi in cui si può determinare in partenza
il numero di cicli da compiere.
Nella for del C è ancora valida la logica a tre coordinate,
ma, a differenza della quasi totalità dei linguaggi di programmazione,
esse sono reciprocamente svincolate e non necessarie. Ciò significa
che, se in Basic[5] la for agisce
su un'unica variabile, che viene inizializzata e incrementata (o decrementata)
sino al raggiungimento di un limite prestabilito, in C essa può
manipolare, ad esempio, tre diverse variabili (o meglio, tre espressioni
di diverso tipo); inoltre nessuna delle tre espressioni deve necessariamente
essere specificata: è perfettamente lecita una for priva
di condizioni di iterazione.
A questo punto, tanto vale esaurire le banalità formali, per concentrarsi
poi sulle possibili modalità di definizione delle tre condizioni
che pilotano il ciclo. Sia subito detto, dunque, che anche la for
vuole che le condizioni siano specificate tra parentesi tonde e che se
il blocco di codice del ciclo comprende più di una istruzione sono
necessarie le solite graffe, aperta e chiusa. Anche nei cicli for
possiamo utilizzare le istruzioni break e continue: la
prima per "saltar fuori" dal ciclo; la seconda per tornare "a
bomba" alla valutazione del test. Anche i cicli for possono
essere annidati, e va tenuto presente che il ciclo più interno compie
una serie completa di iterazioni ad ogni iterazione di quello che immediatamente
lo contiene.
E vediamo, finalmente, qualche ciclo for dal vivo: nella sua forma
banale, quasi "Basicistica", può assumere il seguente
aspetto:
for(i = 1; i < k; i++) {
....
}
Nulla di particolare. Prima di effettuare la prima iterazione, la variabile
i è inizializzata a 1. Se essa risulta minore
della variabile k il ciclo è eseguito una prima volta.
Al termine di ogni iterazione essa è incrementata e successivamente
confrontata con la k; se risulta minore di quest'ultima il ciclo
è ripetuto.
Vale la pena di evidenziare che le tre coordinate logiche stanno tutte
quante all'interno delle parentesi tonde e sono separate tra loro dal punto
e virgola (";"); solo la sequenza (;;) deve
obbligatoriamente essere presente in un ciclo for.
In effetti possiamo avere una for come la seguente:
for( ; ; ) {
....
}
Qual è il suo significato? Nulla è inizializzato. Non viene
effettuato alcun test. Non viene modificato nulla. Il segreto consiste
nel fatto che l'assenza di test equivale a condizione sempre verificata:
la for dell'esempio definisce quindi un'iterazione infinita. Il
programma rimane intrappolato nel ciclo finché si verifica una condizione
che gli consenta di abbandonarlo in altro modo, ad esempio con l'aiuto
di una break.
Ma si può fare di meglio...
for(i = 0; string[i]; )
++i;
Il ciclo dell'esempio calcola la lunghezza della stringa (terminatore nullo
escluso). Infatti i è inizializzata a 0 e viene
valutato se il carattere ad offset 0 in string è nullo;
se non lo è viene eseguita l'unica istruzione del ciclo, che consiste
nell'incrementare i. A questo punto è valutato se è
nullo il byte ad offset 1 in string, e così iterando finché
string[i] non è proprio il NULL finale. L'esempio
appena presentato è del tutto equivalente a
for(i = 0; string[i]; i++);
Il punto e virgola che segue la parentesi tonda indica che non vi sono
istruzioni nel ciclo. Le sole cose da fare sono, perciò, la valutazione
della condizione e l'incremento di i finché, come nel caso
precedente, string[i] non punta al NULL che chiude la
stringa. Se poi volessimo includere nel calcolo anche il NULL,
ecco come fare:
for(i = 0; string[i++]; );
Sissignori, tutto qui. Anche questo ciclo non contiene alcuna istruzione;
tuttavia, in questo caso, l'incremento di i fa parte della condizione
e (trattandosi di un postincremento) viene
effettuato dopo la valutazione, quindi anche (per l'ultima volta) quando
string[i] punta al NULL. E che dire della prossima?
for( ; *string++; ) {
....
}
Nulla di particolare, in fondo: viene verificato se *string è
un byte non nullo e string è incrementato. Se la verifica
dà esito positivo viene eseguito il codice del ciclo. Viene poi
nuovamente effettuata la verifica, seguita a ruota dall'incremento, e così
via. Quanti si sono accorti che questo ciclo for è assolutamente
equivalente a un ciclo while? Eccolo:
while(*string++) {
....
}
In effetti si potrebbe dire che l'istruzione while, in C, è
assolutamente inutile, in quanto può essere sempre sostituita dalla
for, la quale, anzi, consente generalmente di ottenere una codifica
più compatta ed efficiente dell'algoritmo. La maggiore compattezza
deriva dalla possibilità di utilizzare contestualmente alla condizione,
se necessario, anche un'istruzione di inizializzazione ed una di variazione.
La maggiore efficienza invece dipende dal comportamento tecnico del compilatore,
il quale, se possibile, gestisce automaticamente i contatori dei cicli
for come variabili register.
Gli esempi potrebbero continuare all'infinito, ma quelli presentati dovrebbero
essere sufficienti per evidenziare, almeno a grandi linee, le caratteristiche
salienti dei cicli definiti mediante l'istruzione for. E' forse
il caso di sottolineare ancora una volta che il contenuto delle parentesi
tonde dipende fortemente dal ciclo che si vuole eseguire e dall'assetto
elaborativo che gli si vuole dare, ma l'uso dei due punto e virgola è
obbligatorio. Il primo e l'ultimo parametro non devono essere necessariamente
inizializzare ed incrementare (o decrementare) il contatore (o il medesimo
contatore), così come il parametro intermedio non deve per forza
essere una condizione da valutare. Ciascuno di questi parametri può
essere una qualunque istruzione C o può venire omesso. Il compilatore,
però, interpreta sempre il parametro di mezzo come una condizione
da verificare, indipendentemente da ciò che è in realtà:
detto parametro è quindi sempre valutato come vero
o falso[6], e da esso dipendono l'ingresso nel ciclo e le successive
iterazioni.
OK, andiamo avanti a leggere il libro...