|
Appunti informatica |
|
Visite: 2040 | Gradito: | [ Grande appunti ] |
Leggi anche appunti:Operatori aritmeticiOperatori aritmetici Gli operatori aritmetici del C sono i simboli di addizione Operatori logici su bitOperatori logici su bit Gli operatori logici su bit consentono di porre in relazione I device driverI device driver Sempre più difficile: dopo avere affrontato i TSR (pag. 169) |
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() (vedere pag. ). 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: approfondiremo a dovere l'argomento a pag.
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)
Nel programma di esempio abbiamo una chiamata alla funzione di libreria printf() . 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 . 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)
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)
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)
Quella dell'esempio è una normale definizione di funzione. Riprendiamo i concetti già accennati a pagina con maggiore dettaglio: 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: vedere pag.
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
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
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 (pag. ) 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 (pag.
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 (pag. ), 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 *setstring(char *string,char ch,int n)
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)
long square(void)
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 al posto del primo , 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 %dn',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 in caso di successo, oppure qualora si verifichi un errore. Il codice di errore restituito dal sistema operativo è disponibile nella variabile globale errno , dichiarata in ERRNO.H . 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 : è 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
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 . 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. Per un esempio notevole di funzione di libreria dichiarata pascal vedere pag. ; 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 . 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 . 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 (' ', vedere pag. ). 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.
AZIONI DA INTRAPRENDERE |
Carattere incontrato |
|
Stato elaborativo |
Barra '/' |
Altro carattere |
Elaborazione normale |
Non visualizza il carattere |
Visualizza il carattere |
Attesa carattere successivo |
Non visualizza il carattere |
Visualizza '/' e il carattere |
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)
printf('n');
}
Ecco fatto. La funzione di libreria gets() legge una riga dallo standard input 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
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
char *sayLetterInc(char *ptr) // visualizza il carattere e restituisce il
char *hideLetterNextLine(char *ptr) // non visualizza il carattere e
char *sayBarLetterInc(char *ptr) // visualizza il carattere preceduto da una
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.
FUNZIONI DA CHIAMARE |
Carattere incontrato |
|
Stato elaborativo |
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) = ,
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)
// dichiarato ed inizializzato
// in una funzione
};
char line[MAXLIN], *ptr;
int letterType;
while(gets(line))
ptr = (*funcs[nextStatus][letterType])(ptr);
}
printf('n');
}
char *hideLetterInc(char *ptr) // non visualizza il carattere e restituisce
char *sayLetterInc(char *ptr) // visualizza il carattere e restituisce il
char *hideLetterNextLine(char *ptr) // non visualizza il carattere e
char *sayBarLetterInc(char *ptr) // visualizza il carattere preceduto da una
Il contenuto del ciclo for() è sorprendentemente semplice . 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
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 : 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 a pagina 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 pag. e pag.
Abbiamo già accennato (pag. ) 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, e . 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)
La funzione fattoriale() restituisce se il parametro ricevuto è minore di (cioè vale o ), mentre in caso contrario il valore restituito è il prodotto di n per il fattoriale di n‑1, 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 , 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! = %ldn',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)
} 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)) 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 (vedere pag. ) 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)
Per un ulteriore esempio di utilizzo della funzione di libreria findfirst() si veda pag.
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 , contenere il numero delle variabili locali (soprattutto se 'ingombranti'), o richiedere al compilatore la generazione di uno stack di maggiori dimensioni . 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 (vedere pag. ). Anche l'allocazione dinamica può influenzare lo spazio disponibile nello stack: uno sguardo all'organizzazione di stack e heap nei diversi modelli di memoria (pag. ) 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 : è 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 . 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) : è 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
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 . 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)
Se il programma eseguibile si chiama PRINTARG.EXE, si trova nella directory C:PROVEEXEC 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:PROVEEXECPRINTARG.EXE ha ricevuto 7 argomenti:
Pippo
Pluto
&
Paperino
Nonna Papera
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 e sono referenziati come stringhe: per poterli utilizzare come interi è necessario convertire le stringhe in numeri, mediante le apposite funzioni di libreria
Come ogni altra funzione, inoltre, main() può restituire un valore tramite l'istruzione return (vedere pag. ); 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)
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 . 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 a , 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à, (la rappresentazione binaria di è, infatti, ), mentre
return(257);
restituisce in binario è
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 (pag. ). Circa il PSP vedere pag.
E dov'è la chiamata a main()? Non c'è proprio! E' il compilatore che provvede a generare il codice eseguibile necessario a chiamarla automaticamente alla partenza del programma. Ciò non vieta, tuttavia, di chiamare main(), se necessario, dall'interno di qualche altra funzione o persino dall'interno di se stessa.
In realtà è possibile definire anche funzioni che non verranno utilizzate da quel programma. Il caso tipico è quello dei programmi TSR (Terminate and Stay Resident), il cui scopo è caricare in memoria e rendere residente un insieme di routine che verranno chiamate da altri programmi o da eventi di sistema. Di TSR si parla e straparla a pag. e seguenti.
L'aggettivo attuali è di uso comune, ma deriva da una pessima traduzione dell'inglese actual, che significa vero, reale.
Vi sono però anche ragioni tecniche, e non solo formali, che rendono opportuni tali controlli da parte del compilatore: esse sono legate soprattutto alla gestione dello stack, l'area di memoria attraverso la quale i parametri sono resi disponibili alla funzione.
Molte funzioni C utilizzano questo sistema per gestire situazioni di errore in operazioni basate su chiamate al DOS.
Esistono funzioni di libreria specializzate nella gestione di parametri in numero variabile, che possono essere richiamate dalle funzioni definite dal programmatore per conoscere quanti parametri attuali sono stati passati, e così via. Si tratta del gruppo di funzioni va_start() va_arg() va_end()
La parola chiave è stata scelta per analogia con il linguaggio Pascal, in cui il passaggio dei parametri avviene sempre da sinistra a destra, cioè 'in avanti'.
Per la chiamata di una funzione 'normale', il compilatore genera delle istruzioni PUSH per i parametri da passare, una istruzione CALL e le istruzioni POP necessarie a rimuovere dallo stack i parametri passati. La funzione si chiude con una RET. Se la medesima funzione è dichiarata pascal, il compilatore genera ancora le PUSH e la CALL, ma non le POP, in quanto la funzione, essendo sempre fisso il numero di parametri, può provvedere da sé alla pulizia dello stack terminando con una RET n, dove n esprime il numero di byte da eliminare dallo stack.
Ma allora il nome di una funzione è puntatore alla funzione stessa! Ricordate il caso degli array? Chi ha poca memoria può sbirciare a pagina
E' il caso dei programmi che incorporano funzioni per la gestione degli interrupt di sistema. Essi devono memorizzare l'indirizzo degli interrupt ai quali sostituiscono le proprie routine, al fine di poterli utilizzare in caso di necessità e per riattivarli al termine della propria esecuzione. Non sembra però il caso di approfondire ulteriormente l'argomento, almeno per ora. Ma lo si farà a pag. e seguenti.
Lo standard input (stdin) è un 'file' particolare, che il DOS identifica normalmente con la tastiera. Esso può però essere rediretto ad un file qualunque mediante il simbolo '<'. Supponendo di chiamare il nostro programma NOCOMENT, è sufficiente lanciare il comando
nocoment < pippo.c
per visualizzarne il contenuto privo di commenti. Vedere pag.
La gets() sostituisce l'a capo ('n')di fine riga letto dal file con un null terminator. Ciò rende necessario andare a capo esplicitamente.
E avrebbe potuto esserlo ancora di più, utilizzando una if.else in luogo della switch. Quest'ultima, però, rende più agevoli eventuali modifiche future al codice, qualora, ed esempio, si dovessero implementare elaborazioni diverse in corrispondenza di altri caratteri.
Un suggerimento circa le stringhe? Eccolo: il carattere virgolette ('), gestito con una terza colonna nella tabella, potrebbe determinare un terzo stato elaborativo (la terza riga della tabella), che determina la normale visualizzazione di tutto ciò che si incontra e l'avanzamento del puntatore di una posizione soltanto. Anche le barre vengono visualizzate come se nulla fosse. Quando si incontra nuovamente il carattere virgolette si ritorna nello stato 'Normale'.
Cioè oltre il fatidico limite dei 64 Kb che iniziano all'indirizzo contenuto in CS. La logica è del tutto analoga a quella descritta circa i puntatori 'a dati' (pag.
L'allocazione di variabili locali e il passaggio dei parametri avvengono modificando i valori di alcuni regsitri della CPU dedicati proprio alla gestione dello stack. Se il programma utilizza più spazio di quanto il compilatore ha assegnato allo stack, è molto probabile che vengano sovrascritti i dati memorizzati ai suoi 'confini'.
Il metodo per richiedere più stack del default varia da compilatore a compilatore. Alcune implementazioni del C definiscono una variabile globale intera senza segno, _stklen, il cui valore può essere impostato al numero di byte richiesti come area di stack per il programma. Il valore di default è spesso 4 kb. Si noti che la funzione fattoriale() non pone problemi di stack: ogni istanza richiede unicamente 4 byte (per il long passato come parametro) oltre ai 2 o 4 byte necessari per l'indirizzo di ritorno della chiamata near o, rispettivamente, far. E' anche possibile richiedere al compilatore di inserire automaticamente una routine di controllo, in entrata ad ogni funzione, il cui scopo è verificare se nello stack vi sia spazio sufficiente per tutte le variabili locali ed interrompere il programma in caso contrario. Tale opzione risulta utile in fase di sviluppo; è tuttavia opportuno non utilizzarla nel compilare la versione di programma per il rilascio finale, in quanto essa ne diminuisce leggermente l'efficienza.
Lo sono sicuramente le funzioni definite nel medesimo sorgente, dal momento che ogni modulo .OBJ non può superare i 64Kb.
Il modulo di startup altro non è che un file .OBJ, collegato dal linker in testa al (o ai) file .OBJ generati a partire dal sorgente (o sorgenti) del programma, avente lo scopo di svolgere le operazioni preliminari all'invocazione di main(), tra le quali vi è il controllo della versione di DOS, la chiamata a _setargv__() e _setenvp__() (vedere pag. ), etc.; lo startup module relativo al modello di memoria tiny (pag. e seguenti) include la direttiva assembler ORG 100, che permette di ottenere un file .COM dall'operazione di linking. Il sorgente dello startup module, scritto solitamente in assembler, è generalmente fornito insieme al compilatore, per consentirne personalizzazioni. Un esempio di startup module (adatto però ai device driver) è presentato a pag.
L'ordine in cui sono elencati è quello in cui main() li referenzia. E' ovvio che essi sono copiati sullo stack in ordine inverso, come normalmente avviene nelle chiamate a funzione. I nomi ad essi attribuiti in tabella sono quelli convenzionalmente utilizzati dai programmatori C. Nulla vieta, se non il buon senso, si utilizzare nomi differenti nei propri programmi.
La funzione atoi(), ad esempio, richiede quale parametro una stringa e restituisce come int il valore numerico che essa esprime.
Attenzione: sostenere che dopo avere eseguito l'ultima istruzione di main() il programma termina è diverso dal dire che il programma termina dopo avere eseguito l'ultima istruzione di main(), perché un programma può terminare anche in altri modi, e al di fuori di main(). Molto utilizzata è, allo scopo, la funzione di libreria exit(), che richiede quale parametro un intero e lo utilizza per 'restituirlo' come se venisse eseguita una normale return in main(), e ciò anche se questa è definita void
Appunti su: |
|