Barninga Z
a- a+

Le funzioni: definizione, parametri e valori restituiti

Definizione, parametri e valori restituiti

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 
#include 

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 

#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]
" ,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 

void main(void);
long square(void);

long number = 8;

void main(void)
{
    extern long number;

    printf("%ld squared = %ld
" ,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 
#include 
#include 

....
    int h1, h2;
    ....
    printf("dup2() restituisce %d; 
errore DOS %d
" ,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.

 



Ti potrebbe interessare anche

commenta la notizia

C'è 1 commento
Staff
Ti interessano altri articoli su questo argomento?
Chiedi alla nostra Redazione!