La funzione è l'unità elaborativa fondamentale dei programmi C. Dal punto di vista tecnico essa è un blocco di codice a sé stante, isolato dal resto del programma, in grado di eseguire un particolare compito. Essa riceve dati e fornisce un risultato: ciò che avviene al suo interno è sconosciuto alla rimanente parte del programma, con la quale non vi è mai alcuna interazione.
Ogni programma C si articola per funzioni: esso è, in altre parole, un insieme di funzioni. Tuttavia, nonostante l'importanza che le funzioni hanno all'interno di un qualunque programma C, l'unica regola relativa al loro numero e al loro nome è che deve essere presente almeno una funzione ed almeno una delle funzioni deve chiamarsi main(). L'esecuzione del programma inizia proprio con la prima istruzione contenuta nella funzione main(); questa può chiamare altre funzioni, che a loro volta ne possono chiamare altre ancora. L'unico limite è rappresentato dalla quantità di memoria disponibile.
Tutte le funzioni sono reciprocamente indipendenti e si collocano al medesimo livello gerarchico, nel senso che non vi sono funzioni più importanti di altre o dotate, in qualche modo, di diritti di precedenza: la sola eccezione a questa regola è rappresentata proprio da main(), in quanto essa deve obbligatoriamente esistere ed è sempre chiamata per prima.
Quando una funzione ne chiama un'altra, il controllo dell'esecuzione passa a quest'ultima che, al termine del proprio codice, o in corrispondenza dell'istruzione return lo restituisce alla chiamante. Ogni funzione può chiamare anche se stessa, secondo una tecnica detta ricorsione.
In generale, è utile suddividere l'algoritmo in parti bene definite, e codificare ciascuna di esse mediante una funzione dedicata; ciò può rivelarsi particolarmente opportuno soprattutto per quelle parti di elaborazione che devono essere ripetute più volte, magari su dati differenti. La ripetitività non è però l'unico criterio che conduce ad individuare porzioni di codice atte ad essere racchiuse in funzioni: l'importante, come si è accennato, è isolare compiti logicamente indipendenti dal resto del programma; è infatti usuale, in C, definire funzioni che nel corso dell'esecuzione vegono chiamate una volta sola.
Vediamo più da vicino una chiamata a funzione:
#include <stdio.h>
void main(void);
void main(void)
{
printf("Esempio di chiamata.\n");
}
Nel programma di esempio abbiamo una chiamata alla funzione di libreria printf()[1]. Ogni compilatore C è accompagnato da uno o più file, detti librerie, contenenti funzioni già compilate e pronte all'uso, che è possibile chiamare dall'interno dei programmi: printf() è una di queste. In un programma è comunque possibile definire, cioè scrivere, un numero illimitato di funzioni, che potranno essere chiamate da funzioni dello stesso programma[2]. L'elemento che caratterizza una chiamata a funzione è la presenza delle parentesi tonde aperta e chiusa alla destra del suo nome. Per il compilatore C, un nome seguito da una coppia di parentesi tonde è sempre una chiamata a funzione. Tra le parentesi vengono indicati i dati su cui la funzione lavora: è evidente che se la funzione chiamata non necessita ricevere dati dalla chiamante, tra le parentesi non viene specificato alcun parametro:
#include <stdio.h>
#include <conio.h>
void main(void);
void main(void)
{
char ch;
printf("Premere un tasto:\n");
ch = getch();
printf("E' stato premuto %c\n",ch);
}
Nell'esempio è utilizzata la funzione getch(), che sospende l'esecuzione del programma ed attende la pressione di un tasto: come si vede essa è chiamata senza specificare alcun parametro tra le parentesi.
Inoltre getch() restituisce il codice ASCII del tasto premuto alla funzione chiamante: tale valore è memorizzato in ch mediante una normale operazione di assegnamento. In generale, una funzione può restituire un valore alla chiamante; in tal caso la chiamata a funzione è trattata come una qualsiasi espressione che restituisca un valore di un certo tipo: nell'esempio appena visto, infatti, la chiamata a getch() potrebbe essere passata direttamente a printf() come parametro.
#include <stdio.h>
#include <conio.h>
void main(void);
void main(void)
{
printf("Premere un tasto:\n");
printf("E' stato premuto %c\n",getch());
}
Dal momento che in C la valutazione di espressioni nidificate avviene sempre dall'interno verso l'esterno, in questo caso dapprima è chiamata getch() e il valore da essa restituito è poi passato a printf(), che viene perciò chiamata solo al ritorno da getch().
Dal punto di vista elaborativo la chiamata ad una funzione è il trasferimento dell'esecuzione al blocco di codice che la costituisce. Della funzione chiamante, la funzione chiamata conosce esclusivamente i parametri che quella le passa; a sua volta, la funzione chiamante conosce, della funzione chiamata, esclusivamente il tipo di parametri che essa si aspetta e riceve, se previsto, un valore (uno ed uno solo) di ritorno. Tale valore può essere considerato il risultato di un'espressione e come tale, lo si è visto, passato ad un altra funzione o memorizzato in una variabile, ma può anche essere ignorato: printf() restituisce il numero di caratteri visualizzati, ma negli esempi precedenti tale valore è stato ignorato (semplicemente non utilizzandolo in alcun modo) poiché non risultava utile nell'elaborazione effettuata.
Sotto l'aspetto formale, dunque, è lecito attendersi che ogni funzione richieda un certo numero di parametri, di tipo noto, e restituisca o no un valore, anch'esso di tipo conosciuto a priori. In effetti le cose stanno proprio così: numero e tipo di parametri e tipo del valore di ritorno sono stabiliti nella definizione della funzione.
La definizione di una funzione coincide, in pratica, con il codice che la costituisce. Ogni funzione, per poter essere utilizzata, deve essere definita: in termini un po' brutali potremmo dire che essa deve esistere, nello stesso sorgente in cui è chiamata oppure altrove (ad esempio in un altro sorgente o in una libreria, sotto forma di codice oggetto). Quando il compilatore incontra una chiamata a funzione non ha infatti alcuna necessità di conoscerne il corpo elaborativo: tutto ciò che gli serve sapere sono le regole di interfacciamento tra funzione chiamata e funzione chiamante, per essere in grado di verificare la correttezza formale della chiamata. Dette "regole" altro non sono che tipo e numero dei parametri richiesti dalla funzione chiamata e il tipo del valore restituito. Essi devono perciò essere specificati con precisione nella dichiarazione di ogni funzione. Vediamo:
#include <stdio.h>
#include <conio.h>
int conferma(char *domanda, char si, char no)
{
char risposta;
do {
printf("%s?",domanda);
risposta = getch();
while(risposta != si && risposta != no);
if(risposta == si)
return(1);
return(0);
}
Quella dell'esempio è una normale definizione di funzione. La definizione si apre con la dichiarazione del tipo di dato restituito dalla funzione. Se la funzione non restituisce nulla, il tipo specificato deve essere void.
Immediatamente dopo è specificato il nome della funzione: ogni chiamata deve rispettare scrupolosamente il modo in cui il nome è scritto qui, anche per quanto riguarda l'eventuale presenza di caratteri maiuscoli. La lunghezza massima del nome di una funzione varia da compilatore a compilatore; in genere è almeno pari a 32 caratteri. Il nome deve iniziare con un carattere alfabetico o con un underscore ("_") e può contenere caratteri, underscore e numeri (insomma, le regole sono analoghe a quelle già discusse circa i nomi delle variabili).
Il nome è seguito dalle parentesi tonde aperta e chiusa, tra le quali devono essere elencati i parametri che la funzione riceve dalla chiamante. Per ogni parametro deve essere indicato il tipo ed il nome con cui è referenziato all'interno della funzione: se i parametri sono più di uno occorre separarli con virgole; se la funzione non riceve alcun parametro, tra le parentesi deve essere scritta la parola chiave void. Questo è l'elenco dei cosiddetti parametri formali; le variabili, costanti o espressioni passate alla funzione nelle chiamate sono invece indicate come parametri attuali[3].
Si noti che dopo la parentesi tonda chiusa non vi è alcun punto e virgola (";"): essa è seguita (nella riga sottostante per maggiore leggibilità) da una graffa aperta, la quale indica il punto di partenza del codice eseguibile che compone la funzione stessa. Questo è concluso dalla graffa chiusa, ed è solitamente indicato come corpo della funzione.
Il corpo di una funzione è una normale sequenza di dichiarazioni di variabili, di istruzioni, di chiamate a funzione: l'unica cosa che esso non può contenere è un'altra definizione di funzione: proprio perché tutte le funzioni hanno pari livello gerarchico, non possono essere nidifcate, cioè definite l'una all'interno di un'altra.
L'esecuzione della funzione termina quando è incontrata l'ultima istruzione presente nel corpo oppure l'istruzione return: in entrambi i casi l'esecuzione ritorna alla funzione chiamante. Occorre però soffermarsi brevemente sull'istruzione return.
Se la funzione non è dichiarata void è obbligatorio utilizzare la return per uscire dalla funzione (anche quando ciò avvenga al termine del corpo), in quanto essa rappresenta l'unico strumento che consente di restituire un valore alla funzione chiamante. Detto valore deve essere indicato, opzionalmente tra parentesi tonde, alla destra della return e può essere una costante, una variabile o, in generale, un'espressione (anche una chiamata a funzione). E' ovvio che il tipo del valore specificato deve essere il medesimo restituito dalla funzione.
Se invece la funzione è dichiarata void, e quindi non restituisce alcun valore, l'uso dell'istruzione return è necessario solo se l'uscita deve avvenire (ad esempio in dipendenza dal verificarsi di certe condizioni) prima della fine del corpo (tuttavia non è vietato che l'utima istruzione della funzione sia proprio una return). A destra della return non deve essere specificato alcun valore, bensì direttamente il solito punto e virgola.
Perché una funzione possa essere chiamata, il compilatore deve conoscerne, come si è accennato, le regole di chiamata (parametri e valore restituito): è necessario, perciò, che essa sia definita prima della riga di codice che la richiama. In alternativa, può essere inserito nel sorgente il solo prototipo della funzione stessa: con tale termine si indica la prima riga della definizione, chiusa però dal punto e virgola. Nel caso dell'esempio, il prototipo di conferma() è il seguente:
int conferma(char *domanda, char si, char no);
Si vede facilmente che esso è sufficiente al compilatore per verificare che le chiamate a conferma() siano eseguite correttamente[4].
I prototipi sono inoltre l'unico strumento disponibile per consentire al compilatore di "fare conoscenza" con le funzioni di libreria richiamate nei sorgenti: infatti, essendo disponibili sotto forma di codice oggetto precompilato, esse non vengono mai definite. Le due direttive #include in testa al codice dell'esempio presentato, che determinano l'inclusione nel sorgente dei file STDIO.H e CONIO.H, hanno proprio la finalità di rendere disponibili al compilatore i prototipi delle funzioni di libreria printf() e getch().
E' forse più difficile elencare ed enunciare in modo chiaro e completo tutte le regole relative alla definizione delle funzioni e alla dichiarazione dei prototipi, di quanto lo sia seguirle nella pratica reale di programmazione. Innanzitutto non bisogna dimenticare che definire una funzione significa "scriverla" e che scrivere funzioni significa, a sua volta, scrivere un programma C: l'abitudine alle regole descritte si acquisisce in poco tempo. Inoltre, come al solito, il compilatore è piuttosto elastico e non si cura più di tanto di certi particolari: ad esempio, se una funzione restituisce un int, la dichiarazione del tipo restituito può essere omessa. Ancora: l'elenco dei parametri formali può ridursi all'elenco dei soli tipi, a patto di dichiarare i parametri stessi prima della graffa aperta, quasi come se fossero variabili qualunque. Infine, molti compilatori si fidano ciecamente del programmatore e non si turbano affatto se incontrano una chiamata ad una funzione del tutto sconosciuta, cioè non (ancora) definita né prototipizzata. Le regole descritte, però, sono quelle che meglio garantiscono una buona leggibilità del codice ed il massimo livello di controllo sintattico in fase di compilazione. Esse sono, tra l'altro, quasi tutte obbligatorie nella programmazione in C++, linguaggio che, pur derivando in maniera immediata dal C, è caratterizzato dallo strong type checking, cioè da regole di rigoroso controllo sulla coerenza dei tipi di dato.
Abbiamo detto che le funzioni di un programma sono tutte indipendenti tra loro e che ogni funzione non conosce ciò che accade nelle altre. In effetti le sole caratteristiche di una funzione note al resto del programma sono proprio i parametri richiesti ed il valore restituito; essi sono, altresì, l'unico modo possibile per uno scambio di dati tra funzioni.
E' però estremamente importante ricordare che una funzione non può mai modificare i parametri attuali che le sono passati, in quanto ciò che essa riceve è in realtà una copia dei medesimi. In altre parole, il passaggio dei parametri alle funzioni avviene per valore e non per riferimento. Il nome di una variabile identifica un'area di memoria: ebbene, quando si passa ad una funzione una variabile, non viene passato il riferimento a questa, cioè il suo indirizzo, bensì il suo valore, cioè una copia della variabile stessa. La funzione chiamata, perciò, non accede all'area di memoria associata alla variabile, ma a quella associata alla copia: essa può dunque modificare a piacere i parametri ricevuti senza il rischio di mescolare le carte in tavola alla funzione chiamante. Le copie dei parametri attuali sono, inoltre, locali alla funzione medesima e si comportano pertanto come qualsiasi variabile automatica.
L'impossibilità, per ciascuna funzione, di accedere a dati non locali ne accentua l'indipendenza da ogni altra parte del programma. Una eccezione è rappresentata dalle variabili globali, visibili per tutta la durata del programma e accessibili in qualsiasi funzione.
Vi è poi una seconda eccezione: i puntatori. A dire il vero essi sono un'eccezione solo in apparenza, ma di fatto consentono comportamenti contrari alla regola, appena enunciata, di inaccessibilità a dati non locali. Quando un puntatore è parametro formale di una funzione, il parametro attuale corrispondente rappresenta l'indirizzo di un'area di memoria: coerentemente con quanto affermato, alla funzione chiamata è passata una copia del puntatore, salvaguardando il parametro attuale, ma tramite l'indirizzo contenuto nel puntatore la funzione può accedere all'area di memoria "originale", in quanto, è bene sottolinearlo, solo il puntatore viene duplicato, e non l'area di RAM referenziata. E' proprio tramite questa apparente incongruenza che le funzioni possono modificare le stringhe di cui ricevano, quale parametro, l'indirizzo (o meglio, il puntatore).
#include <stdio.h>
#define MAX_STR 20 // max. lung. della stringa incluso il NULL finale
void main(void);
char *setstring(char *string,char ch,int n);
void main(void)
{
char string[MAX_STR];
printf("[%s]\n",setstring(string,'X',MAX_STR));
}
char *setstring(char *string,char ch,int n)
{
string[--n] = NULL;
while(n)
string[--n] = ch;
return(string);
}
Nel programma di esempio è definita la funzione setstring(), che richiede tre parametri formali: nell'ordine, un puntatore a carattere, un carattere ed un intero. La prima istruzione di setstring() decrementa l'intero e poi lo utilizza come offset rispetto all'indirizzo contenuto nel puntatore per inserire un NULL in quella posizione. Il ciclo while percorre a ritroso lo spazio assegnato al puntatore copiando, ad ogni iterazione, ch in un byte dopo avere decrementato n. Quando n è zero, tutto lo spazio allocato al puntatore è stato percorso e la funzione termina restituendo il medesimo indirizzo ricevuto come parametro. Ciò consente a main() di passarla come parametro a printf(), che visualizza, tra parentesi quadre, la stringa inizializzata da setstring(). Si nota facilmente che questa ha modificato il contenuto dell'area di memoria allocata in main().
Un'altra caratteristica interessante della gestione dei parametri attuali in C è il fatto che essi sono passati alla funzione chiamata a partire dall'ultimo, cioè da destra a sinistra. Tale comportamento, nella maggior parte delle situazioni, è trasparente per il programmatore, ma possono verificarsi casi in cui è facile essere tratti in inganno:
#include <stdio.h>
void main(void);
long square(void);
long number = 8;
void main(void)
{
extern long number;
printf("%ld squared = %ld\n",number,square());
}
long square(void)
{
number *= number;
return(number);
}
Il codice riportato non è certo un esempio di buona programmazione, ma evidenzia con efficacia che printf() riceve i parametri in ordine inverso a quello in cui sono elencati nella chiamata. Eseguendo il programma, infatti l'output ottenuto è
64 squared = 64
laddove ci si aspetterebbe un 8 al posto del primo 64, ma se si tiene conto della modalità di passaggio dei parametri, i conti tornano (beh... almeno dal punto di vista tecnico!). Il primo parametro che printf() riceve è il valore restituito da square(). Questa agisce direttamente sulla variabile globale number, sostituendone il valore con il risultato dell'elevamento al quadrato, e la restituisce. Successivamente printf() riceve la copia della stessa variabile, che però è già stata modificata da square(). L'esempio evidenzia, tra l'altro, la pericolosità intrinseca nelle variabili definite a livello globale. Vediamo ora un altro caso, più realistico.
#include <stdio.h>
#include <io.h>
#include <errno.h>
....
int h1, h2;
....
printf("dup2() restituisce %d; errore DOS %d\n",dup2(h1,h2),errno);
La funzione dup2(), il cui prototitpo è in IO.H, effettua un'operazione di redirezione di file (non interessa, ai fini dell'esempio, entrare in dettaglio) e restituisce 0 in caso di successo, oppure -1 qualora si verifichi un errore. Il codice di errore restituito dal sistema operativo è disponibile nella variabile globale errno, dichiarata in ERRNO.H[5]. Lo scopo della printf() è, evidentemente, quello di visualizzare il valore restituito da dup2() e il codice di errore DOS corrispondente allo stato dell'operazione, ma il risultato ottenuto è invece che, accanto al valore di ritorno di dup2() sia visualizzato il valore che errno conteneva prima della chiamata alla dup2() stessa: infatti, essendo i parametri passati a printf() a partire dall'ultimo, la copia di errno è generata prima che si realizzi effettivamente la chiamata a dup2().
Questa strana tecnica di passaggio "a ritroso" dei parametri ha uno scopo estremamente importante: consentire la definizione di funzioni in grado di accettare un numero variabile di parametri.
Abbiamo sottomano un esempio pratico: la funzione di libreria printf(). Ai più attenti non dovrebbe essere sfuggito che, negli esempi sin qui presentati, essa riceve talvolta un solo parametro (la stringa di formato), mentre in altri casi le sono passati, oltre a detta stringa (sempre presente), altri parametri (i dati da visualizzare) di differente tipo.
Il carattere introduttivo di queste note rende inutile un approfondimento eccessivo dell'argomento[6]: è però interessante sottolineare che, in generale, quando una funzione accetta un numero variabile di parametri, è dichiarata con uno o più parametri formali "fissi" (i primi della lista), almeno uno dei quali contiene le informazioni che servono alla funzione per stabilire quanti parametri attuali le siano effettivamente passati ed a quale tipo appartengano. Nel caso di printf() il parametro fisso è la stringa di formato (o meglio, il puntatore alla stringa); questa contiene, se nella chiamata sono passati altri parametri, un indicatore di formato per ogni parametro addizionale (i vari "%d", "%s", e così via). Analizzando la stringa, printf() può scoprire quanti altri parametri ha ricevuto dalla funzione chiamante, e il loro tipo.
D'accordo, ma per fare questo era proprio necessario implementare il passaggio a ritroso dei parametri? La risposta è sì, ma per capirlo occorre scendere un poco in dettagli di carattere tecnico. Il passaggio dei parametri avviene attraverso lo stack, un'area di memoria gestita in base al principo LIFO (Last In, First Out; cioè: l'ultimo che entra è il primo ad uscire): ciò significa che l'ultimo dato scritto nello stack è sempre il primo ad esserne estratto. Tornando alla nostra printf(), a questo punto è chiaro che preparandone una chiamata, il compilatore copia nello stack in ultima posizione proprio il puntatore alla stringa di formato, ma questo è anche il primo dato a cui il codice di printf() può accedere. In altre parole, la funzione conosce con certezza la posizione nello stack del primo parametro attuale, in quanto esso vi è stato copiato per ultimo: analizzandolo può sapere quanti altri, in sequenza, ne deve estrarre dallo stack.
Ecco il prototipo standard di printf():
int printf(const char *format, ...);
Come si vede, è utilizzata l'ellissi ("...", tre punti) per indicare che da quel parametro in poi il numero ed il tipo dei parametri formali non è noto a priori. In questi casi, il compilatore, nell'analizzare la congruenza tra parametri formali ed attuali nelle chiamate, è costretto ad accettare quel che "passa" il convento (...è il caso di dirlo).
In C è comunque possibile definire funzioni per le quali il passaggio dei parametri è effettuato "in avanti", cioè dal primo all'ultimo, nel medesimo ordine della dichiarazione: è sufficiente anteporre al nome della funzione la parola chiave pascal[7].
char *pascal funz_1(char *s1,char *s2); // funz. che restituisce un ptr a char
void pascal funz_2(int a); // funzione void
int far pascal funz_3(void); // funz. far che restit. un int
char far * far pascal funz_4(char c,int a); // funz. far che restit. un far ptr
L'esempio riporta alcuni prototipi di funzioni dichiarate pascal: l'analogia con i "normali" prototipi di funzioni è evidente, dal momento che l'unica differenza è proprio rappresentata dalla presenza della nuova parola chiave. Come si vede, anche le funzioni che non prendono parametri possono essere dichiarate pascal; tuttavia una funzione pascal non può mai essere dichiarata con un numero variabile di parametri. A questo limite si contrappone il vantaggio di una sequenza di istruzioni assembler di chiamata un po' più efficiente[8]. In pratica, tutte le funzioni con un numero fisso di parametri possono essere tranquillamente dichiarate pascal, sebbene ciò, è ovvio, non sia del tutto coerente con la filosofia del linguaggio C. Un esempio notevole di funzione di libreria dichiarata pascal è la funzione di libreria __Ioerror(); si osservi inoltre che in ambiente Microsoft Windows quasi tutte le funzioni sono dichiarate pascal.
Per complicare le cose, aggiungiamo che molti compilatori accettano una opzione di command line per generare chiamate pascal come default (per il compilatore Borland essa è p):
bcc -p pippo.c
Con il comando dell'esempio, tutte le funzioni dichiarate in PIPPO.C e nei file .H da esso inclusi sono chiamate in modalità pascal, eccetto main() (che è sempre chiamata in modalità C) e le funzioni dichiarate cdecl. Quest'ultima parola chiave ha scopo esattamente opposto a quello di pascal, imponendo che la funzione sia chiamata in modalità C (cioè col passaggio in ordine inverso dei parametri) anche se la compilazione avviene con l'opzione di modalità pascal per default.
char *cdecl funz_1(char *s1,char *s2); // funz. che restituisce un ptr a char
void cdecl funz_2(int a); // funzione void
int far cdecl funz_3(void); // funz. far che restit. un int
char far * far cdecl funz_4(char c,...); // funz. far che restit. un far ptr
L'esempio riprende i prototipi esaminati poco fa, introducendo però una modifica all'ultimo di essi: la funzione funz_4() accetta un numero variabile di parametri. E' opportuno dichiarare esplicitamente cdecl tutte le funzioni con numero di parametri variabile, onde consentirne l'utilizzo anche in programmi compilati in modalità pascal.
Credevate di esservene liberati? Ebbene no! Rieccoci a parlare di puntatori... Sin qui li abbiamo presentati come variabili un po' particolari, che contengono l'indirizzo di un dato piuttosto che un dato vero e proprio. E' giunto il momento di rivedere tale concetto, di ampliarlo, in quanto possono essere dichiarati puntatori destinati a contenere l'indirizzo di una funzione.
Un puntatore a funzione è dunque un puntatore che non contiene l'indirizzo di un intero, o di un carattere, o di un qualsiasi altro tipo di dato, bensì l'indirizzo del primo byte del codice di una funzione. Vediamone la dichiarazione:
int (*funcPtr)(char *string);
Nell'esempio funcPtr è un puntatore ad una funzione che restituisce un int e accetta quale parametro un puntatore a char. La sintassi può apparire complessa, ma un esame più approfondito rivela la sostanziale analogia con i puntatori che già conosciamo. Innanzitutto, l'asterisco che precede il nome funcPtr ne rivela inequivocabilmente la natura di puntatore. Anche la parola chiave int ha un ruolo noto: indica che l'indirezione del puntatore restituisce un intero. Trattandosi di un puntatore a funzione, funcPtr è seguito dalle parentesi tonde contenenti la lista dei parametri della funzione. Sono proprio queste parentesi a indicare che funcPtr è puntatore a funzione. Restano da spiegare le parentesi che racchiudono *funcPtr: esse sono indispensabili per distinguere la dichiarazione di un puntatore a funzione da un prototipo di funzione. Se riscriviamo la dichiarazione dell'esempio omettendo la prima coppia di parentesi, otteniamo
int *funcPtr(char *string);
cioè il prototipo di una funzione che restituisce un puntatore ad intero e prende come parametro un puntatore a carattere.
Poco fa si è detto che l'indirezione di funcPtr restituisce un intero. Che significato ha l'indirezione di un puntatore a funzione? Quando si ha a che fare con puntatori a "dati", il concetto è piuttosto semplice: l'indirezione rappresenta il dato che si trova all'indirizzo contenuto nel puntatore stesso. Ma all'indirizzo contenuto in un puntatore a funzione si trova una parte del programma, cioè vero e proprio codice eseguibile: allora ha senso parlare di indirezione di un puntatore a funzione solo con riferimento al dato restituito dalla funzione che esso indirizza. Ma perché una funzione possa restituire qualcosa deve essere eseguita: e proprio qui sta il bello, dal momento che l'indirezione di un puntatore a funzione rappresenta una chiamata alla funzione indirizzata. Vediamo funcPtr all'opera:
#include <string.h>
...
int iVar;
char *cBuffer;
....
funcPtr = strlen;
....
iVar = (*funcPtr)(cBuffer);
....
Nell'esempio, a funcPtr è assegnato l'indirizzo della funzione di libreria strlen(), il cui prototipo si trova in STRING.H, che accetta quale parametro un puntatore a stringa e ne restituisce la lunghezza (sotto forma di intero). Se ne traggono alcune interessanti indicazioni: per assegnare ad un puntatore a funzione l'indirizzo di una funzione basta assegnargli il nome di quest'ultima. Si noti che il simbolo strlen non è seguito dalle parentesi, poiché in questo caso non intendiamo chiamare strlen() e assegnare a funcPtr il valore che essa restituisce, bensì assegnare a funcPtr l'indirizzo a cui strlen() si trova[9]. Inoltre, il tipo di dato restituito dalla funzione e la lista dei parametri devono corrispondere a quelli dichiarati col puntatore: tale condizione, in questo caso, è soddisfatta.
Infine, nell'esempio compare anche la famigerata indirezione del puntatore: come si vede, al parametro formale della dichiarazione è stato sostituito il parametro attuale (come in qualsiasi chiamata a funzione) e al posto dell'indicatore del tipo restituito troviamo, da destra a sinistra, l'operatore di assegnamento e la variabile che memorizza quel valore.
Va sottolineato che l'indirezione è perfettamente equivalente alla chiamata alla funzione indirizzata dal puntatore: in questo caso a
iVar = strlen(cBuffer);
Allora perché complicarsi la vita con i puntatori? I motivi sono molteplici. A volte è indispensabile conoscere gli indirizzi di alcune routine per poterle gestire correttamente[10]. In altri casi l'utilizzo di puntatori a funzione consente di scrivere codice più efficiente: si consdieri l'esempio che segue.
if(a > b)
for(i = 0; i < 1000; i++)
funz_A(i);
else
for(i = 0; i < 1000; i++)
funz_B(i);
Il frammento di codice può essere razionalizzato mediante l'uso di un puntatore a funzione, evitando di scrivere due cicli for quasi identici:
void (*fptr)(int i);
....
if(a > b)
fptr = funz_A;
else
fptr = funz_B;
for(i = 0; i < 1000; i++)
(*fptr)(i);
Più in generale, l'uso dei puntatori a funzione si rivela di grande utilità quando, nello sviluppare l'algoritmo, non si può determinare a priori quale funzione deve essere chiamata in una certa situazione, ma è possibile farlo solo al momento dell'esecuzione, dall'esame dei dati elaborati. Un esempio può essere costituito dalla cosiddetta programmazione per flussi guidati da tabelle, nella quale i dati in input consentono di individuare un elemento di una tabella contenente i puntatori alle funzioni richiamabili in quel contesto.
Per studiare nel concreto una applicazione del concetto appena espresso si può pensare ad una programma in grado di visualizzare un sorgente C eliminando tutti i commenti introdotti dalla doppia barra "//". In pratica si tratta di passare alla riga di codice successiva quando si incontra tale sequenza di caratteri: analizzando il testo carattere per carattere, bisogna visualizzare tutti i caratteri letti fino a che si incontra una barra. In questo caso, per decidere che cosa fare, occorre esaminare il carattere successivo: se è anch'esso una barra si passa alla riga successiva e si riprendono a visualizzare i caratteri; se non lo è, invece, deve essere visualizzato, ma preceduto da una barra, e l'elaborazione prosegue visualizzando i caratteri incontrati.
I possibili stati del flusso elaborativo, dunque, sono due: elaborazione normale, che prevede la visualizzazione del carattere, e attesa, indotto dall'individuazione di una barra. La situazione complessiva delle azioni da intraprendere può essere riassunta in una tabella, ogni casella della quale rappresenta le azioni da intraprendere quando si verifichi una data combinazione tra stato elaborativo attuale e carattere incontrato.
Barra "/" | Altro carattere | |
Elaborazione normale | Non visualizza il carattere
Legge il carattere successivo Passa in stato "Attesa" | Visualizza il carattere Legge il carattere successivo Resta in stato "Normale" |
Attesa carattere successivo | Non visualizza il carattere Legge la riga successiva Passa in stato "Normale" | Visualizza "/" e il carattere Legge il carattere successivo Passa in stato "Normale" |
Circa il trattamento del carattere, le possibili situazioni sono tre: visualizzazione, non visualizzazione, e visualizzazione del carattere stesso preceduto da una barra. La scansione del file può proseguire in due modi diversi: carattere successivo o riga successiva. Infine, si può avere il passaggio dallo stato normale a quello di attesa, il viceversa, o il permanere nello stato normale. Si tratta di una situazione un po' intricata, ma facilmente trasformabile in algoritmo utilizzando proprio i puntatori a funzione.
Quello che ci occorre è, in primo luogo, un ciclo di controllo del flusso elaborativo: il guscio esterno del programma consiste nella lettura del file riga per riga e nell'analisi della riga letta carattere per carattere.
#include <stdio.h>
#define MAXLIN 256
void main(void);
void main(void)
{
char line[MAXLIN], *ptr;
while(gets(line)) {
for(ptr = line; *ptr; ) {
....
}
printf("\n");
}
}
Ecco fatto. La funzione di libreria gets() legge una riga dallo standard input[11] e la memorizza nell'array di caratteri il cui indirizzo le è passato quale parametro. Dal momento che essa restituisce NULL se non vi è nulla da leggere, il ciclo while() è iterato sino alla lettura dell'ultima riga del file. Il ciclo for() scandisce la riga carattere per carattere e procede sino a quando è incontrato il NULL che chiude la riga. E' compito del codice all'interno del ciclo incrementare opportunamente ptr. All'uscita dal ciclo for() si va a capo[12].
A questo punto entrano trionfalmente in scena i puntatori a funzione. Per elaborare correttamente una singola riga ci occorrono quattro diverse funzioni, ciascuna in grado di manipolare un dato carattere come descritto in una delle quattro caselle della nostra tabella. Vediamole:
#include <stdio.h>
#include <string.h>
#define NORMAL 0
#define WAIT 1
char *hideLetterInc(char *ptr) // non visualizza il carattere e restituisce
{ // il puntatore incrementato (tabella[0][0])
extern int nextStatus;
nextStatus = WAIT;
return(ptr+1);
}
char *sayLetterInc(char *ptr) // visualizza il carattere e restituisce il
{ // puntatore incrementato (tabella[0][1])
extern int nextStatus;
nextStatus = NORMAL;
printf("%c",*ptr);
return(ptr+1);
}
char *hideLetterNextLine(char *ptr) // non visualizza il carattere e
{ // restituisce l'indirizzo del NULL
extern int nextStatus; // terminator (tabella[1][0])
nextStatus = NORMAL;
return(ptr+(strlen(ptr));
}
char *sayBarLetterInc(char *ptr) // visualizza il carattere preceduto da una
{ // barra e restituisce il puntatore
extern int nextStatus; // incrementato (tabella[1][1])
nextStatus = NORMAL;
printf("/%c",*ptr);
return(ptr+1);
}
Come si vede, il codice delle funzioni è estremamente semplice. Tuttavia, ciascuna esaurisce il compito descritto in una singola cella della tabella, compresa la "decisione" circa lo stato ("normale" o "attesa") che vale per il successivo carattere da esaminare: non ci resta che creare una tabella analoga a quella presentata poco fa, ma contenente i puntatori alle funzioni.
Barra "/" | Altro carattere | |
Elaborazione normale | hideLetterInc() | sayLetterInc() |
Attesa carattere successivo | hideLetterNextLine() | sayBarLetterInc() |
Ed ecco la codifica C della tabella di puntatori a funzione:
char *hideLetterInc(char *ptr);
char *sayLetterInc(char *ptr);
char *hideLetterNextLine(char *ptr);
char *sayBarLetterInc(char *ptr);
char *(*funcs[2][2])(char *ptr) = {
{hideLetterInc, sayLetterInc},
{hideLetterNextLine, sayBarLetterInc}
};
Lo stato di elaborazione è, per default, "Normale" e viene individuato dalle funzioni ad ogni carattere trattato, il quale è la seconda coordinata necessaria per individuare il puntatore a funzione opportuno all'interno della tabella. Ora siamo finalmente in grado di presentare il listato completo del programma.
#include <stdio.h> // per printf() e gets()
#include <string.h> // per strlen()
#define MAXLIN 256
#define NORMAL 0
#define WAIT 1
#define BAR 0
#define NON_BAR 1
void main(void);
char *hideLetterInc(char *ptr);
char *sayLetterInc(char *ptr);
char *hideLetterNextLine(char *ptr);
char *sayBarLetterInc(char *ptr);
extern int nextStatus = NORMAL;
void main(void)
{
static char *(*funcs[2][2])(char *ptr) = { // e' static perche' e'
{hideLetterInc, sayLetterInc}, // dichiarato ed inizializzato
{hideLetterNextLine, sayBarLetterInc} // in una funzione
};
char line[MAXLIN], *ptr;
int letterType;
while(gets(line)) {
for(ptr = line; *ptr; ) {
switch(*ptr) {
case '/':
letterType = BAR;
break;
default:
letterType = NON_BAR;
}
ptr = (*funcs[nextStatus][letterType])(ptr);
}
printf("\n");
}
}
char *hideLetterInc(char *ptr) // non visualizza il carattere e restituisce
{ // il puntatore incrementato (tabella[0][0])
extern int nextStatus;
nextStatus = WAIT;
return(ptr+1);
}
char *sayLetterInc(char *ptr) // visualizza il carattere e restituisce il
{ // puntatore incrementato (tabella[0][1])
extern int nextStatus;
nextStatus = NORMAL;
printf("%c",*ptr);
return(ptr+1);
}
char *hideLetterNextLine(char *ptr) // non visualizza il carattere e
{ // restituisce l'indirizzo del NULL
extern int nextStatus; // terminator (tabella[1][0])
nextStatus = NORMAL;
return(ptr+(strlen(ptr)));
}
char *sayBarLetterInc(char *ptr) // visualizza il carattere preceduto da una
{ // barra e restituisce il puntatore
extern int nextStatus; // incrementato (tabella[1][1])
nextStatus = NORMAL;
printf("/%c",*ptr);
return(ptr+1);
}
Il contenuto del ciclo for() è sorprendentemente semplice[13]. Ma il cuore di tutto il programma è la riga
ptr = (*funcs[nextStatus][letterType])(ptr);
in cui possiamo ammirare il risultato di tutti i nostri sforzi elucubrativi: una sola chiamata a funzione, realizzata attraverso un puntatore, a sua volta individuato nella tabella tramite le "coordinate" nextStatus e letterType, evita una serie di if nidificate e, di conseguenza, una codifica dell'algoritmo sicuramente meno essenziale ed efficiente.
L'esempio evidenzia inoltre quale sia la sintassi della dichiarazione e dell'utilizzo di un array di puntatori a funzione.
Forse può apparire non del tutto chiaro come sia forzata la lettura della riga successiva quando è individuato un commento: il test del ciclo for() determina l'uscita dal medesimo quando l'indirezione di ptr è un byte nullo, e questa è proprio la situazione indotta dalla funzione hideLetterNextLine(), che restituisce un puntatore al null terminator della stringa contenuta in line.
Va ancora sottolineato che nextStatus è dichiarata come variabile globale per... pigrizia: dichiararla all'interno di main() avrebbe reso necessario passarne l'indirizzo alle funzioni richiamate mediante il puntatore, perché queste possano modificarne il valore. Nulla di difficile, ma non era il caso di complicare troppo l'esempio.
Infine, è meglio non montarsi la testa: quello presentato è un programma tutt'altro che privo di limiti. Infatti non è in grado di riconoscere una coppia di barre inserita all'interno di una stringa, e la considera erroneamente l'inizio di un commento; inoltre visualizza comunque tutti gli spazi compresi tra l'ultimo carattere valido di una riga e l'inizio del commento. L'ingrato compito di modificare il sorgente tenendo conto di queste ulteriori finezze è lasciato, come nei migliori testi, alla buona volontà del lettore[14].
Tanto per complicare un po' le cose, anche i puntatori a funzione possono essere near o far. Per chiarire che cosa ciò significhi, occorre ancora una volta addentrarsi in alcuni dettagli tecnici. I processori Intel seguono il flusso elaborativo, istruzione per istruzione, mediante due registri, detti CS e IP (Code Segment e Instruction Pointer): i due nomi ne svelano di per sé le rispettive funzioni. Il primo fissa un'origine ad un certo indirizzo, mentre il secondo esprime l'offset, a partire da quell'indirizzo, della prossima istruzione da eseguire. Se il primo byte di una funzione dista dall'origine meno di 65535 byte è sufficiente, per indirizzarla, un puntatore near, cioè a 16 bit, associato ad IP. In programmi molto grandi è normale che una funzione si trovi in un segmento di memoria diverso da quello corrente[15]: il suo indirizzo deve perciò essere espresso con un valore a 32 bit (un puntatore far, la cui word più significativa è associata a CS e quella meno significativa ad IP).
Bisogna sottolineare che le funzioni stesse possono essere dichiarate near o far. Naturalmente, dichiarare far una funzione non significa forzare il compilatore a creare un programma enorme per poterla posizionare "lontano": esso genera semplicemente un differente algoritmo di chiamata. Tutte le funzioni far sono chiamate salvando sullo stack sia CS che IP (l'indirizzo di rientro dalla funzione), indipendentemente dal fatto che il contenuto di CS debba essere effettivamente modificato. Nelle chiamate di tipo near, invece, viene salvato (e modificato) solo IP. In uscita dalla funzione i valori di CS ed IP sono estratti dallo stack e ripristinati, così da poter riprendere l'esecuzione dall'istruzione successiva alla chiamata a funzione. E' evidente che una chiamata di tipo far può eseguire qualunque funzione, ovunque essa si trovi, mentre una chiamata near può eseguire solo quelle che si trovano effettivamente all'interno del segmento definito da CS. Spesso si dichiara far una funzione proprio per renderla indipendente dalle dimensioni del programma, o meglio dal modello di memoria scelto per compilare il programma. L'argomento è sviluppato con particolare riferimento alle chiamate intersegmento; per ora è sufficiente precisare che proprio dal modello di memoria dipende il tipo di chiamata che il compilatore genera per una funzione non dichiarata near o far in modo esplicito. In altre parole, una definizione come
int funzione(char *buf)
{
....
}
origina una funzione near o far a seconda del modello di memoria scelto. Analoghe considerazioni valgono per i puntatori: è ancora una volta il modello di memoria a stabilire se un puntatore dichiarato come segue
int (*fptr)(char *buf);
è near o far. Qualora si intenda dichiarare esplicitamente una funzione o un puntatore far, la sintassi è ovvia:
int far funzione(char *buf)
{
....
}
per la funzione, e
int (far *fptr)(char *buf);
per il puntatore. Dichiarazioni near esplicite sono assolutamente analoghe a quelle appena presentate.
Infine, le funzioni possono essere definite static:
static int funzione(char *buf)
{
....
}
per renderle accessibili (cioè "richiamabili") solo all'interno del sorgente in cui sono definite. Non è però possibile, nella dichiarazione di un puntatore a funzione, indicare che questa è static: la riga
static int (*fptr)(char *buf);
dichiara un puntatore static a funzione. Ciò appare comprensibile se si considera che, riferita ad una funzione, la parola chiave static ne modifica unicamente la visibilità, e non il tipo di dato restituito (vedere anche quanto detto circa puntatori static e variabili static ed external).
Abbiamo già accennato che la ricorsione è realizzata da una funzione che richiama se stessa: si tratta di una tecnica di programmazione che può fornire soluzioni eleganti ed efficienti a problemi che, talvolta, possono essere affrontati anche mediante la semplice iterazione. Ogni funzione, main() compresa, può richiamare se stessa, ma è evidente che deve essere strutturata in maniera opportuna: non esistono, peraltro, strumenti appositi; occorre progettare attentamente l'algortimo.
Un esempio di problema risolvibile sia iterativamente che ricorsivamente è il calcolo del fattoriale di un numero. Il fattoriale di un numero intero positivo n (simbolo n!) è espresso come una serie di moltiplicazioni ripetute a partire da
n * (n - 1)
Il risultato di ogni moltiplicazione è quindi moltiplicato per un fattore di una unità inferiore rispetto a quello del moltiplicatore dell'operazione precedente. La formula di calcolo del fattoriale di n è pertanto:
n! = n * (n - 1) * (n - 2) * ... * 2 * 1
Inoltre, per definizione, 1! = 1 e 0! = 1. Raggruppando tutti i fattori che, nella formula precedente, precedono n, si osserva che
(n - 1)! = (n - 1) * (n - 2) * ... * 2 * 1
e pertanto il fattoriale di un numero intero può essere anche espresso come il prodotto del medesimo per il fattoriale dell'intero che lo precede:
n! = n * (n - 1)!
I due esempi che seguono implementano il calcolo del fattoriale con due approcci radicalmente differenti: delle due definizioni, o meglio "rappresentazioni" di n! date poco sopra, la soluzione iterativa è fondata sulla prima, mentre la ricorsione traduce in concreto la seconda.
Un ciclo in grado di calcolare il fattoriale di un intero è il seguente:
int n;
long nfatt;
....
for(nfatt = 1L; n > 1; n--)
nfatt *= n;
Al termine delle iterazioni nfatt vale n!.
Vediamo ora la soluzione ricorsiva:
long fattoriale(long n)
{
return((n < 2) ? 1 : n * fattoriale(n - 1));
}
La funzione fattoriale() restituisce 1 se il parametro ricevuto è minore di 2 (cioè vale 0 o 1), mentre in caso contrario il valore restituito è il prodotto di n per il fattoriale di n1, cioè n!: si noti che fattoriale() calcola il valore da restituire chiamando se stessa e "passandosi" quale parametro il parametro appena ricevuto, ma diminuito di uno.
Il termine "passandosi" è una semplificazione: in realtà fattoriale() non passa il parametro a se stessa, ma ad una ulteriore istanza di sé. Che significa? Nell'esecuzione del programma ogni chiamata a fattoriale() utilizza in memoria, per i dati[16], una differente area di lavoro, in quanto anche questo meccanismo utilizza lo stack per operare. Se una funzione definisce variabili locali ed effettua una ricorsione, la nuova istanza alloca le proprie variabili locali, senza conoscere l'esistenza di quelle dell'istanza ricorrente. E' evidente che se una istanza di una ricorsione potesse accedere a tutte le variabili, incluse quelle locali, definite in ogni altra istanza, la funzione non avrebbe un proprio spazio "riservato" in cui operare: ogni modifica a quasiasi variabile si rifletterebbe in tutte le istanze, e ciascuna di esse potrebbe quindi scompaginare il valore delle altre.
A volte, però, può essere utile che una istanza conosca qualcosa delle altre: ad esempio un contatore, che consenta di sapere in qualunque istanza quanto in profondità si sia spinta la ricorsione. Tale esigenza è soddisfatta dalle variabili static, in quanto esse sono locali alla funzione in cui sono definite, ma comuni a tutte le sue istanze. L'affermazione risulta del tutto comprensibile se si tiene conto che le variabili statiche sono accessibili solo alla funzione in cui sono definite, ma esistono e conservano il loro valore per tutta la durata del programma. Quando una funzione è chiamata per la prima volta ed assegna un valore ad una variabile statica, questa mantiene il proprio valore anche in una seconda istanza (e in tutte le successive) della stessa funzione; mentre delle variabili automatic è generata una nuova copia in ogni istanza, una variabile static è unica in tutte le istanze e poiché essa esiste e mantiene il proprio valore anche in uscita dalla funzione, ogni istanza può conoscere non solo il valore che tale variabile aveva nell'istanza precedente, ma anche nell'istanza successiva (ovviamente dopo il termine di questa).
Le variabili globali, infine, sono accessibili a tutte le istanze ma, a differenza di quelle statiche, lo sono anche alle altre funzioni: in fondo non si tratta di una novità.
Si noti che la funzione fattoriale() deve essere chiamata una sola volta per ottenere il risultato ricercato:
printf("10! = %ld\n",fattoriale(10));
Non serve alcuna iterazione, perché la ricorsione implementata internamente dalla funzione è sufficiente al calcolo del risultato.
Vediamo un altro esempio: la funzione scanDirectory() ricerca un file nell'albero delle directory, percorrendo tutte le sottodirectory di quella specificata come punto di partenza:
/*******************************************************
SCANDIR.C - Barninga Z! - 1994
void cdecl scanDirectory(char *path,char *file);
char *path; path di partenza per la ricerca del file. Deve terminare con una
backslash ("\").
char *file; nome del file da ricercare in path ed in tutte le sue subdir. Puo'
contenere le wildcards "?" e "*".
Visualizza i pathnames (a partire dal punto indicato da path) dei files trovati.
Compilato con Borland C++ 3.1:
bcc -c -mx scandir.c
dove x specifica il modello di memoria e puo' essere: t, s, m, c, l, h.
********************************************************/
#include <stdio.h>
#include <string.h>
#include <dos.h>
#include <dir.h>
#define ALL_ATTR (FA_ARCH+FA_HIDDEN+FA_SYSTEM+FA_RDONLY) // cerca ogni tipo di file
void cdecl scanDirectory(char *path,char *file)
{
struct ffblk ff;
// Considera tutto quello che c'e' nella directory (*.*)
strcat(path,"*.*");
// Qui inizia il loop di scansione della directory in cerca di eventuali subdir.
// In pratica ricerca tutti i tipi di file compresi quelli il cui attributo indica
// che si tratta di una directory.
if(!findfirst(path,&ff,FA_DIREC+ALL_ATTR)) { // cerca le subdir
do {
// Se il file trovato e' proprio una directory, e non e' "." o ".." (cioe' la
// directory stessa o la directory di cui essa e' subdir) allora viene concatenato
// il nome della dir trovata al pathname di lavoro...
if((ff.ff_attrib & FA_DIREC) && (*ff.ff_name != '.')) {
strcpy(strrchr(path,'\\')+1,ff.ff_name);
strcat(path,"\\");
// ...e si effettua la ricorsione: scanDirectory() richiama se stessa passando alla
// nuova istanza il nuovo path in cui ricercare le directory. Cio' si spiega col
// fatto che per ogni directory l'elaborazione da effettuare e' sempre la stessa;
// l'unica differenza e' che ci si addentra di un livello nell'albero gerarchico del
// disco.
scanDirectory(path,file);
}
} while(!findnext(&ff)); // procede finche' trova files o subdirs
}
// Quando tutti gli elementi della directory sono stati scanditi ci troviamo nella
// subdir piu' "profonda" e si puo' cominciare a considerare i files: viene
// concatenato il template di file al path attuale e si inizia un secondo ciclo
// findfirst()/findnext().
strcpy(strrchr(path,'\\')+1,file);
if(!findfirst(path,&ff,ALL_ATTR)) { // cerca i files
do {
// Per ogni file trovato e' visualizzato il pathname a partire da quello di origine.
strcpy(strrchr(path,'\\')+1,ff.ff_name);
printf("%s\n",path);
} while(!findnext(&ff)); // procede finche' trova files
}
// Quando anche i files della directory sono stati analizati tutti, scanDirectory()
// elimina dalla stringa il nome dell'ultimo file trovato...
*(strrchr(path,'\\')) = NULL;
// ...e quello dell'ultima directory scandita: si "risale" cosi' di un livello
// nell'albero.
*(strrchr(path,'\\')+1) = NULL;
// A questo punto, se l'attuale istanza di scanDirectory() e' una ricorsione (siamo
// cioe' in una subdirectory) l'esecuzione del programma prosegue con la precedente
// istanza di scanDirectory(): sono cercate altre subdirectory, e, in assenza di
// queste, i files; se invece la presente istanza di scanDirectory() e' la prima
// invocata (non c'e' stata ricorsione o sono gia' state analizzate tutte le subdir
// nonche' la directory di partenza), allora il controllo e' restituito alla
// funzione chiamante originaria: il lavoro di ricerca e' terminato.
}
La funzione scanDirectory() riceve due parametri: il primo è una stringa che rappresenta il "punto di partenza", cioè la directory all'interno della quale ricercare il file; la ricerca è estesa a tutte le subdirectory in essa presenti. Il secondo parametro è una stringa esprimente il nome (e la estensione) del file da individuare e può contenere le wildcard "*" e "?", che sono risolte dal servizio DOS sottostante alle funzioni di libreria findfirst() e findnext(). Quando, nel "territorio di caccia", è individuato un file il cui nome soddisfa il template fornito dal secondo parametro, ne viene visualizzato il pathname completo (a partire dalla directory di origine). La scanDirectory() può essere sperimentata con l'aiuto di una semplice main() che riceva dalla riga di comando il path di partenza ed il template di file:
#include <stdio.h>
#include <string.h>
#include <dir.h>
void main(int argc,char ** argv);
void scanDirectory(char *path,char *file);
void main(int argc,char **argv)
{
char path[MAXPATH];
if(argc != 3)
printf("Specificare un path di partenza e un template di file\n");
else {
strcpy(path,argv[1]); // copia il path di partenza nel buffer...
strupr(path); // ...e lo converte tutto in maiuscole
if(path[strlen(path)-1] != '\\') // se non c'e' una backslash in fondo...
strcat(path,"\\"); // ...la aggiunge
scanDirectory(path,argv[2]);
}
}
Per inciso, la funzione di libreria findfirst() può essere utilizzata per finalità diverse dalla scansione di una directory.
Il punto debole dell'approccio ricorsivo alla definizione di un algoritmo consiste in un utilizzo dello stack più pesante rispetto alla soluzione iterativa: ogni variabile locale definita in una funzione ricorsiva è duplicata nello stack per ogni istanza attiva. E' perciò necessario, onde evitare disastrosi problemi in fase di esecuzione[17], contenere il numero delle variabili locali (soprattutto se "ingombranti"), o richiedere al compilatore la generazione di uno stack di maggiori dimensioni[18]. E' decisamente sconsigliabile definire array nelle funzioni ricorsive: ad essi può essere sostituito un puntatore, assai più parco in termini di stack, gestito mediante l'allocazione dinamica della memoria. Anche l'allocazione dinamica può influenzare lo spazio disponibile nello stack: uno sguardo all'organizzazione di stack e heap nei diversi modelli di memoria servirà a chiarire le idee.
Inoltre, per ragioni di efficienza, è a volte opportuno dichiarare esplicitamente near le funzioni ricorsive, infatti esse possono essere eseguite più e più volte (come tutte le funzioni all'interno di un ciclo, ma nel caso della ricorsione è la funzione che chiama se stessa): una chiamata near è più veloce e impegna meno stack di una chiamata far. Dichiarare esplicitamente near le funzioni ricorsive assicura che sia generata una chiamata near anche quando il modello di memoria utilizzato in compilazione preveda per default chiamate far. Lo svantaggio di questo approccio è che una funzione dichiarata near può essere chiamata esclusivamente da quelle funzioni il cui codice eseguibile sia compreso nel medesimo segmento[19]: è un aspetto da valutare attentamente in fase di scrittura del codice, dal momento che se non si è sicuri di poter soddisfare tale condizione occorre rinunciare alla dichiarazione near.
La funzione main() è presente in tutti i programmi C ed è sempre eseguita per prima, tuttavia non è necessario chiamarla dall'interno del programma[20]. La chiamata a main() è contenuta in un object file, fornito con il compilatore, che il linker collega automaticamente in testa al modulo oggetto prodotto dalla compilazione del sorgente. Si tratta dello startup module (o startup code)[21]: è questa, in realtà, la parte di codice eseguita per prima; lo startup module effettua alcune operazioni preliminari ed al termine di queste chiama main() dopo avere copiato sullo stack tre parametri, che essa può, opzionalmente, referenziare.
La tabella che segue elenca e descrive detti parametri, indicandone anche il nome convenzionalmente loro attribuito[22]:
PARAMETRI DI main()
Nome | Tipo | Descrizione |
argc | int | Numero degli argomenti della riga di comando, compreso il nome del programma. |
argv | char ** | Indirizzo dell'array di stringhe rappresentanti ciascuna un parametro della riga di comando. La prima stringa è il nome del programma completo di pathname se l'esecuzione avviene in una versione di DOS uguale o successiva alla 3.0, altrimenti contiene la stringa "C". L'ultimo elemento dell'array è un puntatore nullo. |
envp | char ** | Indirizzo dell'array di stringhe copiate dall'environment (variabili d'ambiente) che il DOS ha reso disponibile al programma. L'ultimo elemento dell'array è un puntatore nullo. |
La funzione main() può referenziare tutti i tre argomenti o solo alcuni di essi; tuttavia deve referenziare tutti i parametri che precedono l'ultimo nell'ordine in cui sono elencati nella tabella. Vediamo:
void main(void);
Quello appena presentato è il prototipo di una main() che non referenzia alcuno dei tre parametri. Perché main() li possa referenziare tutti, il prototipo deve essere:
void main(int argc,char **argv,char **envp);
Se, ad esempio, nel programma è necessario accedere solo alle stringhe dell'environment attraverso l'array envp, devono essere comunque dichiarati nel prototipo anche argc e argv.
La forma di main() più comunemente utilizzata è quella che referenzia argv al fine di accedere ai parametri della riga di comando[23]. Perché possa essere utilizzato argv deve essere referenziato anche argc (il quale, da solo, in genere non è di grande utilità):
void main(int argc,char **argv);
Ecco una semplice applicazione pratica:
#include <stdio.h>
void main(int argc,char **argv);
void main(int argc,char **argv)
{
register i;
printf("%s ha ricevuto %d argomenti:\n",argv[0],argc-1);
for(i = 1; argv[i]; i++)
printf("%d) %s\n",i,argv[i]);
}
Se il programma eseguibile si chiama PRINTARG.EXE, si trova nella directory C:\PROVE\EXEC e viene lanciato al prompt del DOS con la seguente riga di comando:
printarg Pippo Pluto & Paperino "Nonna Papera" 33 21
l'output prodotto è:
C:\PROVE\EXEC\PRINTARG.EXE ha ricevuto 7 argomenti:
Pippo
Pluto
&
Paperino
Nonna Papera
33
21
E' facile notare che viene isolata come parametro ogni sequenza di caratteri compresa tra spazi; le due parole Nonna Papera sono considerate un unico parametro in quanto racchiuse tra virgolette. Anche i numeri 33 e 21 sono referenziati come stringhe: per poterli utilizzare come interi è necessario convertire le stringhe in numeri, mediante le apposite funzioni di libreria[24].
Come ogni altra funzione, inoltre, main() può restituire un valore tramite l'istruzione return; in deroga, però, alla regola generale, per la quale è possibile la restituzione di un valore di qualsiasi tipo, main() può restituire unicamente un valore di tipo int.
Vediamo, con riferimento all'esempio precedente, quali sono i cambiamenti necessari perché main() possa restituire il numero di argomenti ricevuti dal programma:
#include <stdio.h>
int main(int argc,char **argv);
int main(int argc,char **argv)
{
register i;
printf("%s ha ricevuto %d argomenti:\n",argv[0],argc-1);
for(i = 1; argv[i]; i++)
printf("%d) %s\n",i,argv[i]);
return(argc-1);
}
E' stato sufficiente modificare la definizione ed il prototipo di main(), sostituendo il dichiaratore di tipo void con int ed inserire un'istruzione return, seguita dall'espressione che produce il valore da restituire.
E' cosa arcinota, ormai, che l'esecuzione di un programma C ha inizio con la prima istruzione di main(); è, del resto, facilmente intuibile che l'esecuzione del programma, dopo avere eseguito l'ultima istruzione di main(), ha termine[25]. Ma allora, quale significato ha la restituzione di un valore da parte di main(), dal momento che nessuna altra funzione del programma lo può conoscere? In quale modo lo si può utilizzare? La risposta è semplice: il valore viene restituito direttamente al DOS, che lo rende disponibile attraverso il registro ERRORLEVEL. L'utilizzo più comune è rappresentato dall'effettuazione di opportuni tests all'interno di programmi batch che sono così in grado di condizionare il flusso esecutivo in dipendenza dal valore restituito proprio da main(). Di seguito è presentato un esempio di programma batch utilizzante il valore restituito dalla seconda versione di PRINTARG:
@echo off
printarg %1 %2 %3 %4 %5 %6 %7 %8 %9
if errorlevel 2 goto Molti
if errorlevel 1 goto Uno
echo PRINTARG lanciato senza argomenti (ERRORLEVEL = 0)
goto Fine
:Molti
echo PRINTARG lanciato con 2 o piu' argomenti (ERRORLEVEL >= 2)
goto Fine
:Uno
echo PRINTARG lanciato con un solo argomento (ERRORLEVEL = 1)
:Fine
Occorre prestare attenzione ad un particolare: il valore restituito da main() è un int (16 bit) e poiché, per contro, il registro ERRORLEVEL dispone di soli 8 bit (equivale ad un unsigned char) ed il valore in esso contenuto può variare da 0 a 255, gli 8 bit più significativi del valore restituito da main() sono ignorati. Ciò significa, in altre parole, che l'istruzione
return(256);
in main() restituisce, in realtà, 0 (la rappresentazione binaria di 256 è, infatti, 0000000100000000), mentre
return(257);
restituisce 1 (257 in binario è 0000000100000001).
Va ancora precisato che le regole del C standard richiedono che main() sia sempre dichiarata int e precisano che una main() dichiarata void determina un undefined behaviour: non è cioè possibile a priori prevedere quale sarà il comportamento del programma. Del resto, numerosi esperimenti condotti non solo in ambiente DOS consentono di affermare che dichiarare main() con return type void non comporta alcun problema (ecco perché, per semplicità, detto tipo di dichiarazione ricorre più volte nel testo): ovviamente non è possibile utilizzare il valore restituito dal programma, perché questo è sicuramente indefinito (non si può cioè prevedere a priori quale valore contiene ERRORLEVEL in uscita dal programma). Qualora si intenda utilizzare il sorgente in ambienti o con compilatori diversi, può essere prudente dichiarare main() secondo le regole canoniche o verificare che il return type void non sia causa di problemi.
Se utilizzata con accortezza, la descritta tecnica di utilizzo dei parametri della riga di comando e di restituzione di valori al DOS consente di realizzare, con piccolo sforzo, procedure in grado di lavorare senza alcuna interazione con l'utilizzatore, cioè in modo completamente automatizzato.
Vale infine la pena di ricordare che dichiarare il parametro envp di main() non è l'unico modo per accedere alle stringhe dell'environment: allo scopo possono essere utilizzate le funzione di libreria getenv() e putenv(): la prima legge dall'environment il valore di una variabile, mentre la seconda lo modifica.
Chi ama le cose complicate può accedere all'environment
leggendone la parte segmento dell'indirizzo nel PSP
del programma, e costruendo un puntatore far con l'aiuto
della macro MK_FP(),
definita in DOS.H.
Non ci ho capito niente! Ricominciamo...