10 Livello 8
10.1 Le funzioni
Le funzioni sono sottoprogrammi, ovvero blocchi di codice riutilizzabili concepiti per eseguire una specifica funzionalità. Rappresentano il primo passo fondamentale verso la modularità del software. Nel linguaggio C è possibile sia utilizzare le funzioni standard messe a disposizione dalle librerie di sistema, sia implementare funzioni personalizzate.
Una funzione dovrebbe comportarsi come una black box, deve esporre precise funzionalità all’esterno, nascondendo al contempo i dettagli algoritmici della propria implementazione. L’interazione con una funzione prevede, nella maggior parte dei casi, il passaggio di parametri in ingresso e la restituzione di un valore come risultato al termine dell’elaborazione.
Per integrare correttamente una funzione all’interno di un programma, è necessario articolare il codice in tre fasi distinte: la dichiarazione (o prototipo), la chiamata (che può essere effettuata nel main o in un altro sottoprogramma) e la definizione. Si analizzi questo esempio elementare:
#include <stdio.h>
int somma(int, int); // Dichiarazione della funzione (prototipo)
int main(void) {
int a = 1, b = 1, s;
s = somma(a, b); // Chiamata della funzione
printf("\nLa somma è: %d\n", s);
return 0;
}
int somma(int c, int d) { // Definizione della funzione
int s;
s = c + d;
return s;
}La dichiarazione int somma(int, int); comunica al compilatore l’interfaccia della funzione prima del suo utilizzo effettivo, ne stabilisce il nome (somma), specifica il tipo del valore restituito (int) e definisce il numero e il tipo dei parametri accettati (int, int). Per motivi di chiarezza descrittiva è consentito inserire dei nomi per i parametri anche nel prototipo, ma questi verranno ignorati dal compilatore.
La chiamata s = somma(a, b); eseguita nel main devia il flusso di esecuzione verso il blocco di codice della funzione. In questo contesto, le variabili a e b prendono il nome di parametri attuali.
La definizione, che inizia con l’intestazione int somma(int c, int d) ed è seguita dal blocco di istruzioni racchiuso tra parentesi graffe, contiene l’insieme delle operazioni da svolgere. Le variabili c e d sono denominate parametri formali e si configurano come variabili locali, ovvero non sono visibili né accessibili all’esterno della funzione stessa.
Il linguaggio C adotta esclusivamente il meccanismo di passaggio dei parametri per valore, di conseguenza, la funzione opera sempre su una copia dei parametri attuali. All’atto della chiamata, i parametri formali vengono inizializzati con i valori dei corrispondenti parametri attuali, impedendo alla funzione di alterare il contenuto delle variabili originali. Chiamata e definizione devono concordare per numero, tipo e ordine dei parametri.
10.1.1 Passaggio tramite puntatori
Qualora si richieda che una funzione modifichi il valore di una variabile definita nel modulo chiamante, è necessario implementare un passaggio utilizzando i puntatori. Si consideri l’esempio classico dello scambio (swap) tra due variabili:
#include <stdio.h>
void scambia(int *, int *); // Il prototipo accetta due indirizzi
int main(void) {
int x = 0, y = 1;
printf("\nPrima della chiamata: x = %d e y = %d", x, y);
scambia(&x, &y); // Chiamata: si passano gli indirizzi delle variabili
printf("\nDopo la chiamata: x = %d e y = %d\n", x, y);
return 0;
}
void scambia(int *x, int *y) {
int tmp;
tmp = *x; // Salva il valore contenuto all'indirizzo puntato da x
*x = *y; // Assegna all'indirizzo puntato da x il valore presente in y
*y = tmp; // Completa lo scambio
}Come si può osservare, il ricorso ai puntatori consente alla funzione scambia di accedere indirettamente alle celle di memoria del main e di modificarne i valori originari. Si noti che tale meccanismo prescinde dagli identificatori utilizzati, non sussiste alcuna relazione tra le variabili x e y del main e i puntatori locali x e y definiti nell’argomento della funzione. Si provi per esempio a stampare gli indirizzi di memoria delle variabili x e y nel main e delle variabili x e y in scambia.
Si raccomanda caldamente di evitare l’impiego di variabili globali come canale implicito per il trasferimento di dati tra funzioni. Tale approccio compromette la modularità del software, ostacola le procedure di debugging e aumenta esponenzialmente il rischio di comportamenti anomali.
Un concetto cardine nella gestione delle funzioni è l’ambito di visibilità (scope) delle variabili, definito come la porzione di codice in cui una determinata variabile è dichiarata, riconosciuta e utilizzata.
10.1.2 Interazione tra funzioni e array
È frequente la necessità di sviluppare funzioni destinate a operare su strutture dati lineari (array). Se il passaggio di un singolo elemento dell’array (v[i]) avviene secondo le regole standard delle variabili, il trasferimento dell’intera struttura dati richiede un approccio differente. Nel linguaggio C non è consentito passare un array in blocco per valore, poiché ciò richiederebbe la duplicazione di ogni singolo elemento in memoria, determinando un overhead computazionale inaccettabile.
Per superare questo vincolo si passa alla funzione il nome dell’array (v). Tale operazione equivale a trasmettere l’indirizzo di memoria del suo primo elemento (\(v = \&v[0]\)). Una volta che la funzione ha acquisito l’indirizzo iniziale dell’array, può accedere a tutti gli elementi successivi applicando l’aritmetica dei puntatori. Poiché la funzione riceve esclusivamente un indirizzo, non ha modo di determinarne la dimensione, è pertanto indispensabile passare la dimensione dell’array come parametro aggiuntivo.
Il seguente esempio illustra l’implementazione di una funzione preposta alla ricerca del valore minimo all’interno di un vettore di interi:
#include <stdio.h>
#define DIM 10
int calcola_minimo(int *, int);
int main(void) {
int n, v[DIM], i, min;
do {
printf("Dimensione dell'array: \n");
scanf("%d", &n);
} while(n < 1 || n > DIM);
printf("Inserisci i %d elementi.\n", n);
for(i = 0; i < n; i++) {
printf("Elemento con indice %d: ", i);
scanf("%d", &v[i]);
}
min = calcola_minimo(v, n); // Chiamata: v trasmette l'indirizzo base
printf("\nIl valore minimo è: %d\n", min);
return 0;
}
int calcola_minimo(int *v, int n) {
int i, min = *v; // Inizializzazione con il primo elemento dell'array
for(i = 0; i < n; i++) {
if(*(v + i) < min)
min = *(v + i); // Accesso via aritmetica dei puntatori
}
return min;
}Come mostrato, la funzione calcola_minimo accetta come parametri attuali l’indirizzo dell’array v e la sua dimensione n. Il parametro formale int *v è un puntatore locale che riceve l’indirizzo dell’array definito nel main. I due identificatori v appartengono a spazi di visibilità separati.
Si analizzi questo ulteriore esempio focalizzato sul calcolo della media, volto a chiarire la natura modificabile del parametro formale:
#include <stdio.h>
#define DIM 10
float calcola_media(int *, int);
int main(void) {
int n, v[DIM], i;
float media;
do {
printf("Dimensione dell'array: \n");
scanf("%d", &n);
} while(n < 1 || n > DIM);
printf("Inserisci i %d elementi.\n", n);
for(i = 0; i < n; i++) {
printf("Elemento con indice %d: ", i);
scanf("%d", &v[i]);
}
media = calcola_media(v, n);
printf("\nLa media è pari a: %.1f\n", media);
return 0;
}
float calcola_media(int *v, int n) {
int i;
float somma = 0;
for(i = 0; i < n; i++, v++) {
somma += *v; // Dereferenziazione e incremento del puntatore locale
}
return somma / n;
}In questo scenario, l’espressione di incremento v++ inserita nel ciclo della funzione calcola_media viene eseguita correttamente e senza generare anomalie. Questo è possibile poiché il parametro formale v all’interno della funzione è una variabile puntatore locale (una copia dell’indirizzo originario) e non risente del vincolo di costanza che caratterizza invece l’identificatore dell’array nel main, il quale rimane immutato al termine delle elaborazioni.
Scrivere un programma in C che consenta, tramite l’uso di specifiche funzioni di caricare n elementi in un array e di stampare a video i valori inseriti.
#include <stdio.h>
#define N 10
void carica_array(int, int *);
void stampa_array(int, int *);
int main(void) {
int v[N],n;
do{
printf("\nDimensione array: ");
scanf("%d",&n);
}while(n<1 || n>N);
printf("\nIndirizzo di v[0] nel main: %p ",&v[0]);
carica_array(n,&v[0]); // Equivalente a carica_array(n,v);
stampa_array(n,v);
return 0;
}
void carica_array(int n, int *x){
int i;
printf("\nx e' inizializzata con l'indirizzo di v[0]: %p ", x);
printf("\nLa variabile x e' locale e si trova in %p",&x);
for(i=0;i<n;i++){
printf("\nInserisci elemento: ");
scanf("%d",x+i);
// tramite x carico i valori nell'array v dichiarato nel main
}
}
void stampa_array(int n, int *v){
int i;
printf("\nArray: ");
for(i=0;i<n;i++){
printf("%d ",*(v+i));
}
}10.2 L’importanza del tipo nei puntatori
In questo contesto e alla luce di quanto visto finora, ci si può chiedere: se un puntatore serve esclusivamente a memorizzare un indirizzo, perché il C impone di dichiarare un tipo specifico anziché utilizzare un tipo generico per tutti i puntatori? La risposta risiede nel fatto che il puntatore non si limita a indicare il punto di partenza nella RAM, ma fornisce al compilatore le coordinate dimensionali per due operazioni fondamentali:
- La dereferenziazione: l’indirizzo salvato nel puntatore indica solo da quale byte iniziare a leggere. È il tipo del puntatore a dire al compilatore quanti byte consecutivi devono essere letti. Un puntatore
int *istruirà la CPU a leggere 4 byte contigui, mentre unchar *dirà di leggerne soltanto 1. - L’aritmetica dei puntatori: quando si esegue un’operazione di incremento come
p + 1, il compilatore calcola l’offset in base alla dimensione del tipo puntato. Unint *traslerà l’indirizzo in avanti di 4 byte, mentre unchar *lo sposterà di un singolo byte.
Per toccare con mano questo meccanismo, analizziamo il seguente frammento di codice in cui lo stesso indirizzo di memoria viene manipolato attraverso due lenti di ingrandimento diverse, un puntatore a intero e un puntatore a carattere.
#include <stdio.h>
int main(void) {
int n = 1025;
int *ip;
char *cp;
ip = &n;
printf("\nIntero -> Indirizzo: %p, Valore: %d", ip, *ip);
printf("\nIntero+1-> Indirizzo: %p, Valore: %d", ip + 1, *(ip + 1));
cp = (char *)ip; // cast
printf("\nChar -> Indirizzo: %p, Valore: %d", cp, *cp);
printf("\nChar+1 -> Indirizzo: %p, Valore: %d\n", cp + 1, *(cp + 1));
return 0;
}Il numero intero 1025 corrisponde alla somma \(1024 + 1\). In formato binario a 32 bit (4 byte), questa cifra è rappresentata in memoria in questo modo:
00000000 00000000 00000100 00000001
Sulla quasi totalità delle architetture moderne (che utilizzano la convenzione Little-Endian), il byte meno significativo (quello all’estrema destra) viene salvato all’indirizzo di memoria più basso. I 4 byte che compongono il numero 1025 saranno quindi disposti in RAM così:
- Byte 0 (indirizzo base):
00000001(Valore decimale: 1) - Byte 1 (indirizzo + 1):
00000100(Valore decimale: 4) - Byte 2 (indirizzo + 2):
00000000(Valore decimale: 0) - Byte 3 (indirizzo + 3):
00000000(Valore decimale: 0)
Quando stampiamo *ip, il compilatore sa che è un int *: parte dall’indirizzo base e legge tutti e 4 i byte insieme, ricostruendo il numero 1025.
Quando eseguiamo un cast e assegnamo l’indirizzo a cp (che è un char *), cambiamo le regole di lettura. Stampando *cp, il compilatore legge solo il primo byte all’indirizzo base, restituendo il valore decimale 1. Se eseguiamo l’aritmetica *(cp + 1), il puntatore si sposta in avanti di un solo byte, e legge esclusivamente il secondo byte, restituendo il valore decimale 4.