Capitolo 7

Puntatori

I puntatori sono senza dubbio una delle caratteristiche più importanti ed essenziali del linguaggio C. La corretta e adeguata comprensione del loro utilizzo è imprescindibile per padroneggiare proficuamente il linguaggio.

Essi forniscono sia un potente meccanismo per scrivere programmi veloci, efficienti e compatti sia un utile strumento attraverso il quale fornire un accesso diretto alla memoria, allocare e deallocare dinamicamente la memoria, costruire complesse strutture di dati (liste collegate, pile, code, alberi, grafi e così via), passare alle funzioni dei riferimenti a tipi di dato derivati e complessi (per esempio i tipi struttura) evitando l’overhead del copia dei rispettivi membri, modificare direttamente all’interno di una funzione chiamata una variabile passata come argomento dalla rispettiva funzione chiamante, fornire una “sintassi” alternativa di accesso e manipolazione degli elementi di un array e così via per altre soluzioni a problemi algoritmici o computazionali di vario tipo.

Dal punto di vista semantico, per dirla in modo semplice, un puntatore altro non è che una variabile specializzata a contenere un indirizzo di memoria di un’altra variabile.

Ricordiamo, infatti, che ogni variabile di un programma occupa una determinata quantità di memoria a seconda del suo tipo (per esempio, in un sistema a 32 bit una variabile di tipo int può occupare 4 byte) ed è localizzabile precisamente attraverso un indirizzo che è esso stesso un valore numerico (per esempio, la predetta variabile di tipo int potrebbe occupare gli indirizzi di memoria, byte per byte, 0x0109f9f4, 0x0109f9f5, 0x0109f9f6 e 0x0109f9f7 e l’indirizzo 0x0109f9f4 sarebbe il suo indirizzo a partire dal quale localizzarla).

NOTA

Il Capitolo 1, al paragrafo “La memoria centrale”, contiene un’analisi dettagliata su come è organizzata e rappresentata la memoria di un elaboratore.

7_1.jpg

Figura 7.1 Rappresentazione in memoria di una variabile di tipo int contenente il valore 10.

In modo più rigoroso e formale, un puntatore è definibile come un tipo derivabile da un tipo funzione oppure da un qualsiasi tipo oggetto (per esempio una variabile) denominato tipo referenziato. In pratica rappresenta un oggetto contenente un valore che è un riferimento, un puntamento, verso un altro oggetto che è, per l’appunto, il tipo referenziato. Possiamo dunque asserire che un puntatore derivato da un tipo referenziato T è definibile come un puntatore a T.

Ritornando alla nostra variabile di tipo int di 32 bit contenente il valore 10, potremmo definire un puntatore verso di essa, ossia potremmo costruire un tipo derivato (un puntatore a un int) che è in grado di contenere l’indirizzo di memoria dove è localizzabile la predetta variabile e, per il tramite di esso, compiere su quest’ultima delle operazioni di lettura e/o di scrittura.

La Figura 7.2 mostra il consueto modo, usato in letteratura, per indicare che un oggetto puntatore punta a un determinato oggetto: in pratica, la variabile puntatore ha come contenuto una freccia che indica il puntamento verso l’oggetto referenziato (evidentemente la medesima freccia può essere sostituita con l’indirizzo in memoria della variabile puntata).

7_2.jpg

Figura 7.2 Rappresentazione grafica di un puntatore.

NOTA

Un puntatore, essendo esso stesso un oggetto, ha un proprio indirizzo in memoria che è differente dall’indirizzo in memoria in esso contenuto che appartiene all’oggetto referenziato.

Sintassi di base dei puntatori

Un puntatore, come detto, è una variabile che contiene come valore un indirizzo di memoria appartenente a un oggetto di un determinato tipo. Per dichiararla come tale bisogna usare una sintassi particolare che prevede il consueto specificatore di tipo e identificatore, ma con in più il carattere asterisco (*) posto tra lo specificatore e l’identificatore (Sintassi 7.1).

Sintassi 7.1 Dichiarazione di un puntatore.

data_type *ptr_identifier;

Così lo Snippet 7.1 dichiara la variabile data come un puntatore a un tipo int, ossia stabilisce che data potrà contenere un riferimento verso qualsiasi oggetto di tipo intero (potrà contenerne l’indirizzo di memoria).

Snippet 7.1 Dichiarazione di un puntatore a un int.

int *data;

Se si esegue la dichiarazione di cui lo Snippet 7.1, il compilatore predisporrà dello spazio in memoria dove sarà allocato un puntatore a un int che, però, inizialmente potrà essere non inizializzato con un valore congruo o corretto, cioè potrà contenere un “qualsiasi” indirizzo di memoria, in generale, non validamente referenziabile (Figura 7.3).

7_3.jpg

Figura 7.3 Esecuzione della statement di dichiarazione del puntatore data.

In linea generale la quantità di spazio di allocazione per un puntatore è dipendente dal sistema in uso; potrà essere, per esempio, di 4 byte su un sistema a 32 bit oppure di 8 byte su un sistema di 64 bit.

SUGGERIMENTO

Se desideriamo scoprire quanto spazio di memoria il compilatore allocherà per un puntatore sul sistema in uso, possiamo utilizzare l’operatore sizeof. Per esempio, sull’attuale sistema di compilazione, l’istruzione sizeof data ritornerà come valore 4, ossia il compilatore impegnerà 4 byte di memoria (32 bit) per memorizzare un determinato indirizzo di memoria.

Dopo la dichiarazione di un puntatore il passo successivo è quello di assegnargli come valore un indirizzo di memoria di un altro oggetto compatibile (Sintassi 7.2).

Sintassi 7.2 Assegnamento di un indirizzo di un oggetto a un puntatore.

ptr_identifier = &object;

In pratica è sufficiente adoperare l’operatore di indirizzamento (o indirizzo di) espresso mediante il simbolo e commerciale (&) sull’oggetto desiderato.

Lo Snippet 7.2 dichiara una variabile di tipo int denominata value e poi assegna l’indirizzo di memoria dove è localizzata alla variabile di tipo puntatore a int denominata data.

Snippet 7.2 Assegnamento di un indirizzo di una variabile int a un puntatore a un int.

int value = 10;
int *data = &value; // data conterrà l'indirizzo di value

7_4.jpg

Figura 7.4 Rappresentazione del puntatore data dopo l’assegnamento dell’indirizzo di value.

Dalla Figura 7.4 si evince come dopo l’assegnamento dell’indirizzo di memoria di value il puntatore data punti alla variabile value medesima perché, ripetiamo, tale puntatore contiene come valore quell’indirizzo.

Infine, il seguente operatore, detto di indirezione o deriferimento, espresso mediante il simbolo asterisco * e prefisso all’identificatore di un puntatore, consente di accedere al contenuto di un oggetto riferito da un puntatore (Sintassi 7.3).

Sintassi 7.3 Accesso al contenuto di un oggetto riferito da un puntatore.

*ptr_identifier;

Ritornando al precedente esempio, lo Snippet 7.3 assegna alla variabile tmp il contenuto della variabile value riferita dal puntatore data.

Snippet 7.3 Utilizzo dell’operatore di deriferimento con un puntatore.

int value = 10;
int *data = &value; // data conterrà l'indirizzo di value
int tmp = *data; // tmp = 10

In buona sostanza l’istruzione *data può essere espressa letteralmente nel seguente modo: “accedi al contenuto dell’oggetto puntato da data e non al contenuto di data stesso”.

Da questo punto di vista, quindi, *data può essere considerato un alias di value, ossia qualsiasi manipolazione effettuata per il tramite di esso si ripercuoterà su value stessa (Snippet 7.4 e Figura 7.5).

Snippet 7.4 Manipolazione tramite un puntatore dell’oggetto puntato.

int value = 10;
int *data = &value;

*data = 100; // value = 100

7_5.jpg

Figura 7.5 Contenuto di value prima e dopo l’operazione di deriferimento del puntatore data.

ATTENZIONE

Non applicare mai l’operatore di deriferimento a un puntatore non inizializzato con un corretto indirizzo di memoria, altrimenti si potrà avere un comportamento non definito: crash del programma (l’indirizzo di memoria memorizzato nel puntatore è al di fuori dell’address space valido del programma), stampa di valori garbage o insensati (l’indirizzo di memoria memorizzato nel puntatore mostra quello che in quel momento è presente a quell’indirizzo quantunque valido) e così via.

Riepilogando, per utilizzare correttamente un puntatore, bisogna compiere le seguenti fondamentali operazioni.

  1. Utilizzo dell’operatore di indirezione * durante la fase di dichiarazione di un puntatore; per esempio int *ptr_data.
  2. Assegnamento di un indirizzo di memoria valido, tramite l’operatore di indirizzamento &, di un oggetto dello stesso tipo di quello indicato durante la fase di dichiarazione di un puntatore; per esempio ptr_data = &value (con value di tipo int).
  3. Utilizzo dell’operatore di indirezione * durante la fase di manipolazione dell’oggetto riferito da un puntatore; per esempio *ptr_data = 100.

DETTAGLIO

Perché al punto 2 si è precisato che l’indirizzo di memoria assegnato a un puntatore deve essere di un oggetto dello stesso tipo da esso espresso in fase di dichiarazione? Perché un puntatore, indipendentemente dall’oggetto puntato, conterrà sempre e solo un indirizzo di memoria; pertanto, indicando durante la sua dichiarazione qual è il tipo di oggetto localizzato a quell’indirizzo di memoria, si otterrà che, in fase di accesso, il contenuto di tale locazione sarà interpretato correttamente. Per esempio, compilando e mandando in esecuzione il Listato 7.1 avremo sia il messaggio: warning: assignment from incompatible pointer type, sia un output dove il valore della variabile di tipo float non sarà stato interpretato correttamente.

Listato 7.1 IncompatibleTypes.c (IncompatibleTypes).

/* IncompatibleTypes.c :: Tipi incompatibili con i puntatori :: */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int i_number = 100;
float f_number = 100.44f;

int *ptr_i_number = &i_number; // OK tipi compatibili
printf("Valore di i_number: %d\n", *ptr_i_number);

ptr_i_number = &f_number; // ATTENZIONE tipi non compatibili
printf("Valore di f_number: %f\n", *ptr_i_number);

return (EXIT_SUCCESS);
}

Output 7.1 Dal Listato 7.1 IncompatibleTypes.c.

Valore di i_number: 100
Valore di f_number: 0.000000

Puntatori come parametri di funzioni

Quando si definisce una funzione è possibile dichiarare dei parametri che sono di tipo puntatore, cioè delle variabili che sono in grado di contenere degli indirizzi di memoria dei corrispettivi argomenti (Sintassi 7.4 e 7.5).

Sintassi 7.4 Prototipo di funzione con un parametro di tipo puntatore.

return_type function_identifier(data_type *ptr_identifier); // con identificatore
return_type function_identifier(data_type *); // senza identificatore

Sintassi 7.5 Definizione di una funzione con un parametro di tipo puntatore.

return_type function_identifier(data_type *ptr_identifier) { ... }

Questa modalità di progettazione delle funzioni consente di “aggirare” il problema del passaggio per valore degli argomenti poiché i parametri sono in grado, indirettamente, di modificarne i valori.

Per il passaggio dei puntatori come argomenti di una funzione è possibile usare la seguente modalità (Sintassi 7.6) dove, cioè, si passa direttamente un oggetto che è già di per sé un tipo puntatore oppure si antepone l’operatore di indirizzamento & a un determinato oggetto.

Sintassi 7.6 Invocazione di una funzione passando come argomento un puntatore.

function_identifier(ptr_identifier); // ptr_identifier è un puntatore
function_identifier(&identifier);// identifier è un qualsiasi oggetto

NOTA

In C gli argomenti sono passati, sempre, per valore anche se sono dei puntatori. Il fatto che un parametro sia un puntatore e che consenta di modificare il relativo argomento non implica che esista, nativamente, la modalità di passaggio di un argomento “per riferimento” (come in C++) o “per indirizzo”. Infatti, quando si passa a una funzione l’indirizzo del suo argomento è più corretto dire che si sta passando “un suo riferimento” e non che l’argomento è passato “per riferimento”.

Listato 7.2 PointersAndPassByValue.c (PointersAndPassByValue).

/* PointersAndPassByValue.c :: Pass by value e puntatori :: */
#include <stdio.h>
#include <stdlib.h>

void foo(int *p);

int main(void)
{
int a = 10;
int *j = &a;

// per stampare 0x e l'indirizzo si sarebbe potuto usare anche %#p
printf("Indirizzo riferito da j [ 0x%p ] PRIMA del passaggio dell'argomento.\n", j);

foo(j); // passo un puntatore...

// per stampare 0x e l'indirizzo si sarebbe potuto usare anche %#p
printf("Indirizzo riferito da j [ 0x%p ] DOPO il passaggio dell'argomento "
"e p = &k;.\n", j);

return (EXIT_SUCCESS);
}

void foo(int *p)
{
int k = 100;
p = &k; // ok j non è interessato... pass by value
}

Output 7.2 Dal Listato 7.2 PointersAndPassByValue.c.

Indirizzo riferito da j [ 0x0028fee8 ] PRIMA del passaggio dell'argomento.
Indirizzo riferito da j [ 0x0028fee8 ] DOPO il passaggio dell'argomento e p = &k;.

Il Listato 7.2 mostra come anche un puntatore sia passato by value: infatti, quando nel main viene invocata la funzione foo viene fatta una copia dell’indirizzo di memoria contenuto nel puntatore j, e tale copia viene posta nel parametro p, anch’esso un puntatore.

A questo punto entrambi i puntatori referenziano lo stesso oggetto, ovvero la variabile a.

Poi, nell’ambito della funzione foo, al puntatore p viene assegnato un altro indirizzo di memoria, quello della variabile k, senza però che tale assegnamento incida sull’indirizzo di memoria dell’argomento j che rimane, infatti, inalterato.

A questo punto il puntatore j continua a puntare alla variabile a mentre il puntatore p “rompe” il puntamento verso la variabile a e imposta un nuovo puntamento verso la variabile k (Figura 7.6).

7_6.JPG

Figura 7.6 Immodificabilità di un puntatore passato come argomento a una funzione.

Scorrendo il sorgente è interessante notare l’utilizzo, nell’ambito della funzione printf, dello specificatore di formato %p che consente di stampare, in modo dipendente dall’implementazione, un numero esadecimale che rappresenta il valore della locazione di memoria contenuta nel puntatore.

Mostriamo, infine, la scrittura di una funzione swap (Listato 7.3) che correttamente, grazie all’impiego dei puntatori, consente di scambiare i valori di due variabili passate come argomenti per il tramite dei corrispettivi parametri.

Listato 7.3 SwapWithPointers.c (SwapWithPointers).

/* SwapWithPointers.c :: Scambio di valori con l'uso dei puntatori :: */
#include <stdio.h>
#include <stdlib.h>

/* prototipo di swap */
void swap(int *w, int *z);

int main(void)
{
int a = 10, b = 20;
printf("a e b prima dello swap: a=%d - b=%d\n", a, b);

// passo i puntatori ad a e b
swap(&a, &b);

printf("a e b dopo lo swap: a=%d - b=%d\n", a, b);

return (EXIT_SUCCESS);
}

/* definizione di swap */
void swap(int *w, int *z)
{
int tmp = *w;
*w = *z;
*z = tmp;
}

Output 7.3 Dal Listato 7.3 PointersAndPassByValue.c.

a e b prima dello swap: a=10 - b=20
a e b dopo lo swap: a=20 - b=10

TERMINOLOGIA

Quando si passa a una funzione un argomento tipo &a si può anche direttamente dire che si sta passando un puntatore ad a piuttosto che l’indirizzo di a perché, dato che l’operatore & genera l’indirizzo di una variabile, &a ne rappresenta un puntatore.

Puntatori come valori di ritorno dalle funzioni

Una funzione può essere anche definita con la possibilità di avere come valore di ritorno un tipo puntatore; per compiere quest’operazione è sufficiente dichiarare il puntatore nel consueto modo, ossia tipo di dato, simbolo * e identificatore, e porlo come tipo di ritorno prima dell’identificatore della relativa funzione (Snippet 7.5).

Snippet 7.5 Funzione che ritorna un tipo puntatore.

int *foo(void) { … }

La funzione foo dello Snippet 7.5 è definita con un valore di ritorno che è un puntatore a un int, cioè dovrà ritornare un indirizzo di memoria di un oggetto dello stesso tipo di dato.

Quando si definisce una funzione in questo modo bisogna prestare attenzione a non ritornare mai un indirizzo di memoria di una variabile locale automatica alla funzione stessa: questo perché, quando la funzione ritorna, tale variabile cesserà di esistere e pertanto il relativo puntatore sarà considerato invalido (Listato 7.4).

Listato 7.4 ReturningAPointer.c (ReturningAPointer).

/* ReturningAPointer.c :: Ritorno di un puntatore a una variabile locale :: */
#include <stdio.h>
#include <stdlib.h>

int *foo(void);
void bar(void);

int main(void)
{
int *p = foo();

printf("Valore di j per il tramite di *p: %d\n", *p);

bar(); // invoco un'altra funzione

printf("Valore di j per il tramite di *p: %d\n", *p);

return (EXIT_SUCCESS);
}

int *foo(void)
{
int j = 1000;
printf("Indirizzo di j in foo: %#p\n", &j);
return &j;
}

void bar(void)
{
int b = 2000;
printf("Indirizzo di b in bar: %#p\n", &b);
}

Output 7.4 Dal Listato 7.4 ReturningAPointer.c.

Indirizzo di j in foo: 0x28febc
Valore di j per il tramite di *p: 1000
Indirizzo di b in bar: 0x28febc
Valore di j per il tramite di *p: 2000

Il Listato 7.4 definisce la funzione foo che ritorna un puntatore alla variabile locale j lì definita, la quale contiene il valore 1000, e la funzione bar, che non ritorna nulla e che definisce la variabile locale b, che contiene il valore 2000.

La relativa funzione main invoca subito la funzione foo che ritorna nel puntatore p l’indirizzo della variabile locale j. Poi, per il tramite del puntatore p, ne stampa il valore che è congruo, ossia vale ancora 1000.

Successivamente invoca la funzione bar e poi, alla sua uscita, stampa di nuovo, sempre per mezzo del puntatore p, il valore riferito, che però questa volta non è più congruo, ossia non vale più 1000 ma 2000.

Il motivo di questo comportamento è spiegabile analizzando l’output del programma, dove si nota che quando viene invocata la funzione foo il compilatore crea uno stack frame dove pone la variabile locale j all’indirizzo di memoria 0x28febc che è ritornato al puntatore p. Quando però la funzione foo termina, lo stack frame relativo viene rimosso dal function call stack e la variabile locale j non esiste più e l’indirizzo di memoria 0x28febc diviene invalido (potrà a questo punto essere usato per allocare altri dati), pur continuando a contenere il valore 1000 come mostrato dalla successiva istruzione printf invocata nel main.

A questo punto viene invocata la funzione bar e viene creato un altro stack frame dove la variabile locale b viene allocata all’indirizzo 0x28febc ancora disponibile e utilizzabile (è lo stesso della ex variabile j) e lì viene posto il valore 2000.

All’uscita della funzione bar, però, il suo stack frame viene rimosso dal function call stack e la variabile b viene distrutta; quando nel main è invocata in seguito la funzione printf, il puntatore p, puntando ancora all’indirizzo di memoria 0x28febc, stampa l’ultimo valore trovato, ossia 2000.

In pratica l’indirizzo di memoria 0x28febc impiegato per le variabili locali citate può essere utilizzato da ogni invocazione di un nuova funzione per le proprie necessità; questo spiega il problema della “sovrascrittura” del valore che in origine era di j.

NOTA

Il compilatore GCC, quando si compila il sorgente del Listato 7.4, emetterà il seguente avviso: warning: function returns address of local variable. Un buon compilatore dovrebbe dare sempre un avviso di questo tipo per evitare che un programmatore possa utilizzare un puntatore a un indirizzo di memoria non valido.

Puntatori e array

Uno degli aspetti tra i più interessanti di C è la stretta correlazione tra puntatori e array. Dato un array come, per esempio, data, la sua valutazione ritorna un indirizzo di memoria, ossia un puntatore al suo primo elemento che si può quindi assegnare come valore a un puntatore dello stesso tipo.

Snippet 7.6 Valutazione del nome di un array.

int data[] = {10, 100, 20, 40, 50, 60, 70};
int *ptr_to_data = data;

7_7.jpg

Figura 7.7 ptr_to_data, dopo l’assegnamento, punterà al primo elemento dell’array data.

Lo Snippet 7.6 definisce l’array data deputato a contenere 7 elementi di tipo intero e poi ne assegna l’indirizzo del primo elemento (l’elemento 0 con valore 10) al puntatore a int denominato ptr_to_data.

L’assegnamento di data a ptr_to_data è equivalente al seguente che, comunque, anche se più esplicito e chiaro è raramente usato: int *ptr_to_data = &data[0].

Quanto sopra fa conseguire che qualsiasi accesso a un elemento di un array ottenibile mediante la nota sintassi che fa uso dell’operatore di subscript [ ] e di un indice è anche ottenibile tramite un puntatore e la possibilità di “aggiungere” o “sottrarre” da esso un valore numerico che indica un valore di “scostamento” (o offset) rispetto all’attuale indirizzo lì contenuto (Snippet 7.7).

Snippet 7.7 Utilizzo di un puntatore per accedere a un elemento di un array riferito.

int data[] = {10, 100, 20, 40, 50, 60, 70};
int *ptr_to_data = data;

// fa puntare ptr_to_data al quarto elemento dell'array
ptr_to_data += 3;

7_8.jpg

Figura 7.8 ptr_to_data dopo lo scostamento, in aggiunta, di 3 unità di storage.

Lo Snippet 7.7 evidenzia come aggiungendo il valore 3 all’attuale indirizzo in memoria di ptr_to_data lo faccia puntare all’indirizzo di memoria del quarto elemento dell’array data referenziato (in pratica all’indirizzo di “base” di 0x0041f7bc sono state aggiunte 3 “unità di storage di scostamento” che l’hanno fatto incrementare al valore di 0x0041f7c8).

È qui importante precisare che gli scostamenti sono espressi in unità di storage perché ogni tipo di dato necessità di una determinata quantità di memoria per allocare il corrispondente valore (per esempio, su un sistema a 32 bit un int richiede 4 byte per memorizzare un intero); pertanto aggiungere 1 unità a un puntatore a int significa spostare il suo indirizzo di 4 byte e non di 1 byte.

Ritornando al nostro esempio, l’espressione ptr_to_data += 3 sposterà il puntatore di 12 byte (ossia 3 * 4 byte con un int di 32 bit) facendolo, quindi, puntare all’elemento 3 dell’array data.

Facciamo ora un ulteriore passo in avanti che mostra l’equivalenza tra l’aritmetica degli indici propria di un array e quella propria dei puntatori. Un’espressione come data[i] si può scrivere anche come *(data + i); ossia: data, essendo di fatto un puntatore al primo elemento di un array, farà sì che data + i punterà all’i-esimo elemento di quell’array e l’operatore di deriferimento * consentirà di ottenere il relativo valore.

In linea generale è dunque possibile asserire che &data[i] e data + i sono sinonimi, poiché entrambi sono puntatori all’i-esimo elemento di data.

Per contro, è possibile utilizzare la notazione propria degli array anche con un puntatore: per esempio, per accedere all’elemento 5 dell’array data per il tramite di ptr_to_data, possiamo scrivere anche ptr_to_data[5].

ATTENZIONE

L’equivalenza tra un array e un puntatore è solo relativa alla possibilità di usare in modo intercambiabile l’aritmetica dei puntatori con l’indicizzazione degli array. Tra di essi vi è comunque un’importante differenza: il nome di un array è un lvalue non modificabile (per esempio è un errore scrivere data++), mentre un puntatore è un lvalue modificabile (per esempio è lecito scrivere ptr_to_data++). Quindi, un nome di un array non è un puntatore; sono, infatti, tecnicamente, due oggetti distinti ed è solo in un determinato contesto valutativo che il compilatore converte un array in un puntatore.

CURIOSITÀ

È lecito scrivere qualcosa come i[data] al posto di data[i]? La risposta è certamente affermativa perché il compilatore, per effetto delle equivalenze discusse tra array e puntatori, quando incontra un’espressione come questa la trasforma in *(i + data), che è dunque corretta. Allo stesso modo data[i] sarebbe trasformata in *(data + i).

Aritmetica dei puntatori

L’aritmetica dei puntatori (pointer arithmetic), conosciuta anche come aritmetica degli indirizzi (address arithmetic), dato un puntatore o dei puntatori che puntano a elementi di un array, consente di compiere le seguenti operazioni.

  • Aggiungere un valore intero a un puntatore: consente di incrementare l’attuale indirizzo di memoria riferito dal puntatore di tante unità di storage quante indicate dal valore fornito. Così, se ptr_to_data punta all’elemento x dell’array data (data[x]), aggiungere y a esso (ptr_to_data + y) lo farà puntare all’elemento di data posto y unità di storage dopo l’attuale indirizzo di ptr_to_data (data[x + y]).

Snippet 7.8 Aggiungere un valore intero a un puntatore (Figura 7.9).

int x = 2;
int y = 3;
int data[] = {10, 100, 20, 40, 50, 60, 70};

int *ptr_to_data = &data[x]; // punta all'elemento 2 con valore 20
ptr_to_data = ptr_to_data + y; // ora punta all'elemento 5 con valore 60

7_9.jpg

Figura 7.9 Aggiungere un valore intero a un puntatore.

  • Sottrarre un valore intero da un puntatore: consente di decrementare l’attuale indirizzo di memoria riferito dal puntatore di tante unità di storage quante indicate dal valore fornito. Così, se ptr_to_data punta all’elemento x dell’array data (data[x]), sottrarre y da esso (ptr_to_data - y) lo farà puntare all’elemento di data posto y unità di storage prima dell’attuale indirizzo di ptr_to_data (data[x - y]).

Snippet 7.9 Sottrarre un valore intero da un puntatore (Figura 7.10).

int x = 5;
int y = 4;
int data[] = {10, 100, 20, 40, 50, 60, 70};

int *ptr_to_data = &data[x]; // punta all'elemento 5 con valore 60
ptr_to_data = ptr_to_data - y; // ora punta all'elemento 2 con valore 100

7_10.jpg

Figura 7.10 Sottrarre un valore intero da un puntatore.

  • Sottrarre un puntatore da un altro puntatore: consente di ottenere la distanza, in elementi dell’array, tra due puntatori che puntano a indirizzi di memoria dell’array medesimo. Così, se ptr_to_data punta all’elemento x dell’array data (data[x]) e ptr_to_data_2 punta all’elemento y dell’array data (data[y]), sottrarre ptr_to_data_2 da esso (ptr_to_data – ptr_to_data_2) oppure sottrarre ptr_to_data da ptr_to_data_2 (ptr_to_data_2 – ptr_to_data) restituirà il numero di elementi di distanza, rispettivamente, in “negativo” nel primo caso e in “positivo” nel secondo caso.

Snippet 7.10 Sottrarre un puntatore da un altro puntatore (Figura 7.11).

int x = 1;
int y = 3;
int data[] = {10, 100, 20, 40, 50, 60, 70};

int *ptr_to_data = &data[x]; // punta all'elemento 1 con valore 100
int *ptr_to_data_2 = &data[y]; // punta all'elemento 3 con valore 40

ptrdiff_t distance_1 = ptr_to_data - ptr_to_data_2; // -2
ptrdiff_t distance_2 = ptr_to_data_2 - ptr_to_data; // 2

7_11.jpg

Figura 7.11 Dove i puntatori ptr_to_data e ptr_to_data_2 stanno correntemente puntando.

Lo Snippet 7.10 mostra come il tipo utilizzato per contenere la differenza tra due puntatori sia ptrdiff_t, il quale è definito nel file header <stddef.h> (generalmente con un typedef di un tipo intero con segno) ed è il modo “portabile” per esprimere tale differenza (con la funzione printf il correlativo specificatore di formato utilizzabile è, per esempio, %td).

ATTENZIONE

Se si compiono le operazioni di aritmetica dei puntatori qui citate con un puntatore che non punta a un elemento di un array oppure con dei puntatori che non riferiscono elementi di uno stesso array (come è il caso di sottrazione di un puntatore da un altro), il comportamento sarà non definito. In ogni caso l’indirizzo di memoria subito successivo a quello dell’ultimo elemento di un array è garantito essere valido.

Comparazione tra puntatori

Oltre alle operazioni proprie dell’aritmetica dei puntatori è possibile anche comparare dei puntatori mediante l’utilizzo degli operatori relazionali (<, <=, >, >=) e di uguaglianza (==, !=); ciò consente di sapere se, dati due puntatori che puntano a elementi dello stesso array, uno è minore o maggiore di un altro oppure se uno è uguale o diverso da un altro (in quest’ultimo caso gli operatori == e != possono essere usati anche con puntatori dello stesso tipo anche se non riferiscono elementi dello stesso array).

I criteri di confronto sono effettuati sugli indirizzi di memoria lì contenuti. Così, se ptr_to_data punta all’elemento x dell’array data (data[x]) e ptr_to_data_2 punta all’elemento y dell’array data (data[y]), utilizzare l’operatore minore di (ptr_to_data < ptr_to_data_2) ritornerà il valore 1 se l’indirizzo di memoria riferito da ptr_to_data sarà inferiore all’indirizzo di memoria riferito da ptr_to_data_2 (e il valore 0 in caso contrario). Anche per gli altri operatori il risultato del confronto sarà sempre 1 oppure 0.

Snippet 7.11 Comparazione di due puntatori (Figura 7.12).

int x = 4;
int y = 6;
int data[] = {10, 100, 20, 40, 50, 60, 70};

int *ptr_to_data = &data[x]; // punta all'elemento 4 con valore 50
int *ptr_to_data_2 = &data[y]; // punta all'elemento 6 con valore 70

// ptr_to_data -> indirizzo: 0x0041f7cc
// ptr_to_data_2 -> indirizzo: 0x0041f7d4
_Bool res = ptr_to_data < ptr_to_data_2; // 1 cioè vero!

7_12.jpg

Figura 7.12 Dove i puntatori ptr_to_data e ptr_to_data_2 stanno correntemente puntando.

ATTENZIONE

Se si compiono le operazioni di comparazione dei puntatori qui citate con gli operatori relazionali, con dei puntatori che non riferiscono elementi di uno stesso array, il comportamento sarà non definito. Tuttavia l’indirizzo di memoria subito successivo a quello dell’ultimo elemento di un array è garantito essere valido e può essere utilizzato per un confronto.

Puntatori e parametri di una funzione di tipo array

Quando definiamo una funzione con un parametro formale di tipo array, per il compilatore esso è automaticamente interpretato come un puntatore al tipo di dato dell’array; ossia, se un parametro formale è scritto come int data[], per il compilatore lo stesso sarà trattato come int *data, ossia un puntatore a un int.

Quanto evidenziato è comunque perfettamente lecito perché quando si passa, poi, come parametro attuale il nome di un array (per esempio numbers), quello che si fornisce è l’indirizzo di memoria del suo primo elemento, ossia un puntatore all’elemento 0.

IMPORTANTE

È solo nell’ambito della definizione di un parametro formale di una funzione che per C vi è un’equivalenza tra, per esempio, int data[] e int *data. Il parametro data è cioè sempre considerato come un puntatore a un int (Listato 7.5).

Listato 7.5 ArrayAsPointerInParameterDefinition.c (ArrayAsPointerInParameterDefinition).

/* ArrayAsPointerInParameterDefinition.c :: Array vs puntatori come parametri di una funzione  :: */
#include <stdio.h>
#include <stdlib.h>

#define SIZE 6

// prototipo della funzione subtraction
// equivalenti --> int subtraction(int *data, int length);
// int subtraction(int [], int length);
// int subtraction(int *, int length);
int subtraction(int data[], int length);

int main(void)
{
int some_data[] = {369, 10, 15, 65, 88, 66};
printf("L'array some_data, nella funzione main, ha una dimensione di "
"%zu byte\n", sizeof some_data);

int res = subtraction(some_data, SIZE);

printf("Il risultato della sottrazione di tutti gli elementi di "
"some_data e': %d\n", res);

return (EXIT_SUCCESS);
}

// definizione della funzione subtraction
// equivalente --> int subtraction(int *data, int length) { ... }
int subtraction(int data[], int length)
{
printf("\"L'array data\", nella funzione subtraction, ha una dimensione "
"di %zu byte\n", sizeof data);

int result = *data;

// utilizzo dell'aritmetica dei puntatori per scorrere un array...
for (int *p = data + 1; p < data + length; p++)
result -= *p;

return result;
}

Output 7.5 Dal Listato 7.5 ArrayAsPointerInParameterDefinition.c.

L'array some_data, nella funzione main, ha una dimensione di 24 byte
"L'array data", nella funzione subtraction, ha una dimensione di 4 byte
Il risultato della sottrazione di tutti gli elementi di some_data e': 125

Il Listato 7.5 definisce una funzione subtraction che, dato un array come argomento, ne restituisce un valore che è la differenza di tutti i valori dei suoi elementi.

L’importanza del programma del listato non risiede di sicuro nella funzione di sottrazione, che è banale, quanto piuttosto perché evidenzia due aspetti di rilievo: il primo è legato alla verifica che nell’ambito della definizione di una funzione un parametro di tipo array è di fatto considerato come un puntatore a un suo elemento; il secondo è relativo a come è possibile utilizzare l’aritmetica dei puntatori per scorrere gli elementi di un array riferito da un puntatore in sostituzione della consueta indicizzazione.

Per quanto riguarda il primo aspetto, lo stesso è verificabile guardando all’output del programma, dove:

  • l’operatore sizeof applicato all’array some_data nell’ambito della funzione main dà come risultato il valore 24 in accordo con il fatto che è un tipo array di int che contiene 6 elementi di tipo int di 4 byte ciascuno sul corrente sistema a 32 bit;
  • l’operatore sizeof applicato nell’ambito della funzione subtraction dà come risultato il valore 4 in accordo con il fatto che è un tipo puntatore a un int, e dunque sul corrente sistema a 32 bit 4 byte sono lo spazio utilizzato per allocare un tipo puntatore atto a contenere un indirizzo di memoria.

Il secondo aspetto è invece dimostrabile nel ciclo for della funzione subtraction: qui si utilizza espressamente l’aritmetica dei puntatori per manipolare il parametro data fornendo al puntatore p l’indirizzo dell’elemento 1 (data + 1) e verificando, come condizione di continuazione del ciclo, che l’indirizzo corrente di p sia nel range di indirizzi dove sia possibile validamente ottenere un valore numerico da computare (p < data + length) mediante l’operatore di deriferimento (result -= *p).

Puntatori e array multidimensionali

Dato un array multidimensionale, come per esempio quello a due dimensioni definito nello Snippet 7.12 e con la rappresentazione in memoria fornita dalla Figura 7.13, possiamo tracciare, al pari di quanto già fatto per gli array monodimensionali, come si relazioni rispetto a un puntatore.

Snippet 7.12 Definizione di un array bidimensionale.

int data[2][3] = // 2 righe per 3 colonne
{
{1,2,3}, // I riga
{-1, -2, -3} // II riga
};

7_13.jpg

Figura 7.13 Rappresentazione tabellare e in memoria della matrice data.

Prima di procedere oltre ricordiamo che per C: un array a due dimensioni è un array di array, ossia un array a una dimensione dove ogni elemento è esso stesso un altro array; la disposizione in memoria di un array a due dimensioni è fatta riga per riga (row major order, prima la riga 0, poi la riga 1 e così via per tutte le altre righe).

NOTA

Per semplicità useremo gli array a due dimensioni come forma di array multidimensionale. I concetti generali esposti saranno comunque validi anche per array di dimensioni maggiori.

Nel prossimo elenco riportiamo la “valutazione” da parte del compilatore dell’identificatore data, ossia a che tipo di puntatore “corrisponde” quando gli applichiamo o meno l’operatore di indirizzamento &.

  1. data corrisponde a int (*)[3], ossia a un puntatore a un array di 3 elementi. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3.
  2. &data[0] corrisponde a int (*)[3], ossia a un puntatore a un array di 3 elementi. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3. Quindi, data e &data[0] sono “sinonimi”.
  3. data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3.
  4. &data[0][0] corrisponde a int *, ossia a un puntatore a int. Esso ritorna l’indirizzo di memoria (0x00a2fbac) dell’elemento che si trova alla riga 0 e alla colonna 0;
  5. &data corrisponde a int (*)[2][3], ossia a un puntatore a un array di 2 righe per 3 colonne. Esso ritorna l’indirizzo di memoria (0x00a2fbac) che è in pratica quello a partire dal quale inizia tutto l’array a due dimensioni.

Le espressioni viste sono importanti perché evidenziano che, pur ritornando tutte lo stesso indirizzo di memoria, avranno comunque dei tipi di puntatori differenti, e ciò ci sarà utile per comprendere cosa accadrà quando dovremo applicare l’operatore di deriferimento * su di esse. Avremo infatti quanto segue

  1. *data corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3.
  2. *&data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3.
  3. *data[0] corrisponde a un int. Esso ritorna il valore dell’elemento, ossia 1, posto alla colonna 0 e riga 0.
  4. *&data[0][0] corrisponde a un int. Esso ritorna il valore dell’elemento, ossia 1, posto alla colonna 0 e riga 0.
  5. *&data corrisponde a int (*)[3], ossia a un puntatore a un array di 3 elementi. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3.

In più se si vuole utilizzare in modo assoluto la notazione puntatore/offset in alternativa a quella propria degli array allora avremo che:

  1. data + r sposta il puntatore alla riga indicata da r e ritorna un tipo int (*)[3]. Per esempio, data + 1 sposterà il puntatore alla riga 1 ossia all’indirizzo 0x00a2fbb8. Ciò avviene perché data è un puntatore a un array di 3 colonne e pertanto aggiungere un’unità di storage a esso ne farà incrementare l’indirizzo di 12 byte (3 elementi della riga per la dimensione di 4 byte propria di un int sul corrente sistema di 32 bit);
  2. *(data + r) sposta il puntatore alla riga indicata da r e poi la dereferenzia ritornando un tipo int *. Per esempio, *(data + 1) sposterà il puntatore alla riga 1 e tornerà un riferimento al suo primo elemento posto all’indirizzo 0x00a2fbb8;
  3. *(data + r) + c sposta il puntatore alla riga indicata da r, poi la dereferenzia ritornando l’indirizzo di memoria dell’elemento 0 come tipo int * che poi sposta di c unità di storage. Per esempio, *(data + 1) + 1 sposterà il puntatore alla riga 1 e colonna 1, ossia all’indirizzo 0x00a2fbbc. Ciò avviene perché il puntatore da spostare si riferisce a quello che “muove” le colonne dove ogni unità di storage vale 4 byte (ossia un int di 32 bit sull’attuale sistema);
  4. *(*(data + r) + c) sposta il puntatore alla riga indicata da r, poi la dereferenzia ritornando l’indirizzo di memoria dell’elemento 0 come tipo int * che poi sposta di c unità di storage. Infine, dereferenzia tale indirizzo di memoria ritornando un tipo int. Per esempio *(*(data + 1) + 1), ritornerà il valore -2 che è posto nella colonna 1 della riga 1.

Quanto mostrato sinora è senza dubbio complesso e richiede una certa attenzione e pazienza per comprendere in modo ottimale la relazione tra un puntatore e un array bidimensionale; comunque ciò non deve indurre a eccessive preoccupazioni perché, quando si scrivono programmi che fanno uso di array bidimensionali, è sufficiente:

  • per accedere a un determinato elemento, utilizzare la più semplice notazione con indicizzazione propria degli array (per esempio, data[1][1] accede alla colonna 1 della riga 1);
  • per impiegare un parametro formale di tipo array bidimensionale, dichiararlo con la consueta notazione con indicizzazione degli array già vista (per esempio void foo(int data[][3]) { … }) oppure con la notazione che fa uso dei puntatori (per esempio void foo(int (*data)[3]) { … }).

ATTENZIONE

Le parentesi ( ) intorno a *data sono essenziali. Se le omettessimo, la dichiarazione relativa, ossia int *data[3], per effetto della più alta precedenza dell’operatore [ ] rispetto all’operatore *, significherebbe che data è un array di 3 puntatori a int. Invece, scrivere int (*data)[3] fa sì che l’espressione significhi che data è un puntatore a un array di 3 elementi di tipo int.

Listato 7.6 PointerToArrayAsParameter.c (PointerToArrayAsParameter).

/* PointerToArrayAsParameter.c :: Puntatori come parametri di una funzione per array 2d :: */
#include <stdio.h>
#include <stdlib.h>

#define ROWS 3
#define COLS 5

/* prototipo della funzione search */
// equivalente -> int search(int (*ptr_to_data)[COLS], int rows);
int search(int (*)[COLS], int);

int main(void)
{
int data[][COLS] =
{
{1, 2, 3, 4, 5},
{-4, -6, 10, 2, 9},
{100, -100, 33, 34, 24}
};

// invocazione di search
int res = search(data, ROWS);

printf("La matrice data contiene %d numeri negativi!\n", res);

return (EXIT_SUCCESS);
}

/* definizione della funzione search */
int search(int (*ptr_to_data)[COLS], int rows)
{
int nr = 0;

// qui r++ fa spostare alla riga successiva della matrice perché
// ptr_to_data è di tipo int (*)[5]
for (int r = 0; r < rows; r++)
{
// qui c++ fa spostare alla colonna successiva della riga corrente perché
// *(ptr_to_data + r) è di tipo int *
for (int c = 0; c < COLS; c++)
{
// sintassi alternativa puntatore/offset a quella propria degli array
int val = *(*(ptr_to_data + r) + c);
if (val < 0)
nr++;
}
}
return nr;
}

Output 7.6 Dal Listato 7.6 PointerToArrayAsParameter.c.

La matrice data contiene 3 numeri negativi!

Il Listato 7.6 esplicita in modo pratico, nell’ambito della funzione search, come utilizzare la notazione puntatore/offset per riferirsi agli elementi di una matrice. Il ciclo for più esterno si occupa di far spostare il puntatore corrente alla riga successiva; lo fa incrementando ptr_to_data di una unità di storage alla volta che è pari a 20 byte perché ogni riga contiene 5 elementi di tipo int con un int di 4 byte (ricordiamo che ptr_to_data è di tipo int (*)[5]). Il ciclo for più interno, invece, fa spostare il corrente puntatore alla colonna successiva, e lo fa incrementando *(ptr_to_data + r) di una unità di storage alla volta che è pari a 4 byte perché ogni colonna è un elemento di tipo int con un int di 4 byte (ricordiamo che *(ptr_to_data + r) è di tipo int *).

Infine, applica di nuovo l’operatore di deriferimento sul puntatore ritornato in modo da ottenere il valore della corrente colonna.

Per comprendere in modo meno astratto quanto detto, la Figura 7.14 mostra dove si troverà il puntatore ptr_to_data quando r varrà 1 e c varrà 2, ossia sulla colonna 2 della riga 1 (elemento con valore 10).

7_14.jpg

Figura 7.14 Visualizzazione del puntatore ptr_to_data dopo uno spostamento per offset.

Per quanto concerne, infine, la scrittura dell’array bidimensionale come parametro di una funzione, possiamo ora comprendere perché una sintassi come int data[][] non sarebbe mai accettata dal compilatore; dato che esso “converte” la notazione a indicizzazione degli array come notazione a puntatore, quando valuterà qualcosa come data + 1 non avrà dati a sufficienza per sapere di quante unità di storage spostare il puntatore corrente alla prossima riga.

Ecco, quindi, perché bisogna sempre indicare il numero di colonne dell’array mentre non è obbligatorio indicare il numero di righe (il parametro int data[][COLS] è trattato dal compilatore come int (*data)[COLS], e infatti il relativo argomento passato è un puntatore a un array di COLS colonne).

Array di puntatori

Gli array di puntatori sono vettori dove ciascun elemento è un puntatore a un determinato tipo. Così, una dichiarazione come int *data[4] stabilisce che il nome data è un array di 4 elementi ciascuno dei quali contiene come valore un indirizzo di memoria di un tipo int ossia un suo puntatore.

Questo tipo di oggetto si presta in modo ottimale a creare i cosiddetti array triangolari o irregolari, ossia array dove ogni riga può avere un numero di colonne differente.

Snippet 7.13 Un array irregolare.

// array di 4 puntatori a int
int *data[] =
{
(int[]) {1, 2}, // 2 colonne
(int[]) {3, 4, 5}, // 3 colonne
(int[]) {6, 7, 8, 9}, // 4 colonne
(int[]) {10, 11, 12, 13, 14} // 5 colonne
};

Lo Snippet 7.13 crea l’array data composto da 4 righe dove ciascuna riga punta a un array di colonne, definito con la sintassi “letterale”, di differente dimensione.

Poiché il nome di un array è valutato come un puntatore al suo primo elemento, di fatto, ogni elemento di data ne contiene un adeguato riferimento ossia un puntatore a int.

Così, data[0] conterrà l’indirizzo di memoria del primo elemento del primo array letterale (elementi con valori 1 e 2), data[1] conterrà l’indirizzo di memoria del primo elemento del secondo array letterale (elementi con valori 3, 4 e 5) e così via per data[2] e data[3].

Per quanto riguarda la valutazione da parte del compilatore del nome data, da solo e con l’operatore di indirizzo &, abbiamo che:

  1. data corrisponde a int **, ossia a un puntatore a un puntatore a int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x0028fe90) che è in pratica visualizzabile come il primo puntatore all’array contenente i valori 1 e 2;
  2. &data[0] corrisponde a int **, ossia a un puntatore a un puntatore a int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x0028fe90) che è in pratica visualizzabile come il primo puntatore all’array contenente i valori 1 e 2;
  3. data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria riferito dall’elemento 0 (0x0028fea0) che è in pratica visualizzabile come il primo elemento dell’array puntato (valore 1);
  4. &data[0][0] corrisponde a int *, ossia a un puntatore a int. Esso ritorna l’indirizzo di memoria (0x0028fea0) dell’elemento 0 del primo array riferito (valore 1);
  5. &data corrisponde a int *(*)[4], ossia a un puntatore a un array di 4 elementi a puntatori a int. Esso ritorna l’indirizzo di memoria (0x0028fe90) che è in pratica quello a partire dal quale inizia tutto l’array di puntatori a int.

7_15.jpg

Figura 7.15 Rappresentazione tabellare e in memoria con GCC della matrice irregolare data.

Invece, per la valutazione di data con l’operatore di deriferimento * abbiamo che:

  1. *data corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria riferito dall’elemento 0 (0x0028fea0) che è in pratica visualizzabile come il primo elemento dell’array puntato (valore 1);
  2. *&data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di memoria riferito dall’elemento 0 (0x0028fea0) che è in pratica visualizzabile come il primo elemento dell’array puntato (valore 1);
  3. *data[0] corrisponde a un int. Esso ritorna il valore dell’elemento posto alla colonna 0 e riga 0 del primo array riferito, ossia 1;
  4. *&data[0][0] corrisponde a un int. Esso ritorna il valore dell’elemento posto alla colonna 0 e riga 0 del primo array riferito, ossia 1;
  5. *&data corrisponde a int **, ossia a un puntatore a un puntatore di int. Esso ritorna l’indirizzo di memoria dell’elemento 0 (0x0028fe90) che è in pratica visualizzabile come il primo puntatore all’array contenente i valori 1 e 2.

Infine, utilizzando in modo assoluto la notazione puntatore/offset in alternativa a quella propria degli array, avremo che:

  • data + r sposta il puntatore alla riga indicata da r e ritorna un tipo int **. Per esempio, data + 1 sposterà il puntatore alla riga 1, ossia all’indirizzo 0x0028fe94. Ciò avviene perché data è un puntatore a un puntatore di int; pertanto aggiungere un’unità di storage a esso ne farà incrementare l’indirizzo di 4 byte, perché tale dimensione, sul sistema in uso, è quella usata per allocare un puntatore a un int;

DETTAGLIO

In sostanza in questo caso, rispetto al puntatore data di int (*data)[4], il nome data è l’indirizzo di memoria del primo puntatore a int allocato che contiene un puntamento verso il primo array (quello con i valori 1 e 2). Quindi l’incremento di una unità farà spostare il puntamento corrente al secondo puntatore a int che contiene un puntamento verso il secondo array (quello con i valori 3, 4 e 5). Lo spostamento sarà di 4 byte alla volta perché con il sistema corrente il compilatore userà 4 byte per allocare un tipo puntatore a int. La Figura 7.15 evidenzia, per esempio, come &data[0] sia il primo puntatore allocato all’indirizzo 0x0028fe90 (l’elemento 0 dell’array data), che contiene come valore l’indirizzo 0x0028fea0, che è l’area di memoria a partire da cui si troveranno gli elementi dell’array riferito (in pratica la prima riga dell’array data).

  • *(data + r) sposta il puntatore alla riga indicata da r e poi la dereferenzia ritornando un tipo int *. Per esempio, *(data + 1) sposterà il puntatore alla riga 1 e tornerà un riferimento al suo primo elemento posto all’indirizzo 0x0028fea8;
  • *(data + r) + c sposta il puntatore alla riga indicata da r e poi la dereferenzia ritornando l’indirizzo di memoria dell’elemento 0 come tipo int *, che poi sposta di c unità di storage. Per esempio, *(data + 1) + 1 sposterà il puntatore alla riga 1 e colonna 1, ossia all’indirizzo 0x0028feac. Ciò avviene perché il puntatore da spostare si riferisce a quello che “muove” le colonne dove ogni unità di storage vale 4 byte (ossia un int di 32 bit sull’attuale sistema);
  • *(*(data + r) + c) sposta il puntatore alla riga indicata da r e poi la dereferenzia ritornando l’indirizzo di memoria dell’elemento 0 come tipo int *, che poi sposta di c unità di storage. Infine, dereferenzia tale indirizzo di memoria ritornando un tipo int. Per esempio *(*(data + 1) + 1), ritornerà il valore 4 che è posto nella colonna 1 della riga 1.

Quanto mostrato, seppur complesso, si può sintetizzare dicendo che:

  • per accedere a un determinato elemento della matrice, si può utilizzare la più semplice notazione con indicizzazione propria degli array (per esempio data[1][1] accede alla colonna 1 della riga 1);
  • per impiegare un parametro formale di tipo array bidimensionale irregolare, si può dichiararlo con la consueta notazione con indicizzazione degli array (per esempio void foo(int *data[]) { … }) oppure con la notazione che fa uso esclusivo dei puntatori (per esempio void foo(int **data) { … }).

Listato 7.7 ArrayOfPointersAsParameter.c (ArrayOfPointersAsParameter).

/* ArrayOfPointersAsParameter.c :: Array di puntatori come parametri di una funzione per array 2d :: */
#include <stdio.h>
#include <stdlib.h>

#define ROWS 4
#define COLS_I 2
#define COLS_II 3
#define COLS_III 4
#define COLS_IV 5

// prototipo della funzione search
// equivalente --> int search(int **, int);
// --> int search(int *ptr_to_data[], int rows);
// --> int search(int **ptr_to_data, int rows);
int search(int *[], int);

int main(void)
{
// array di 4 puntatori a int
int *data[] =
{
(int[]) {1, 2}, // 2 colonne
(int[]) {3, -4, 5}, // 3 colonne
(int[]) {6, -7, 8, 9}, // 4 colonne
(int[]) {10, 11, -12, 13, -14} // 5 colonne
};

// invocazione di search
int res = search(data, ROWS);

printf("La matrice data contiene %d numeri negativi!\n", res);

return (EXIT_SUCCESS);
}

// definizione della funzione search
// equivalente --> int search(int **ptr_to_data, int rows) { ... }
int search(int *ptr_to_data[], int rows)
{
int nr = 0;
int cols_nr = 0;

// qui r++ fa spostare alla riga successiva della matrice perché
// ptr_to_data è di tipo int **
for (int r = 0; r < rows; r++)
{
switch(r) // necessario per sapere quante colonne ha la corrente riga
{
case 0: cols_nr = COLS_I; break;
case 1: cols_nr = COLS_II; break;
case 2: cols_nr = COLS_III; break;
case 3: cols_nr = COLS_IV; break;
}

// qui c++ fa spostare alla colonna successiva della riga corrente perché
// *(ptr_to_data + r) è di tipo int *
for (int c = 0; c < cols_nr; c++)
{
// sintassi alternativa puntatore/offset a quella propria degli array
int val = *(*(ptr_to_data + r) + c);
if (val < 0)
nr++;
}
}
return nr;
}

Output 7.7 Dal Listato 7.7 ArrayOfPointersAsParameter.c.

La matrice data contiene 4 numeri negativi!

Il Listato 7.7 è simile come logica al Listato 7.6, ossia data una matrice ne deve cercare gli elementi che contengono dei numeri negativi.

Tuttavia presenta le seguenti importanti differenze:

  • la matrice data è dichiarata come array di puntatori a int ed è irregolare, ossia ogni riga ha un diverso numero di colonne;
  • il prototipo e la definizione della funzione search hanno un parametro dichiarato, rispettivamente, come int *[] e int *ptr_to_data[] (come detto, sarebbe preferibile scrivere i prototipi di funzione indicando anche gli identificatori dei tipi dei relativi parametri; tuttavia in taluni casi, come quello appena indicato, abbiamo ritenuto opportuno non farlo per ragioni di chiarezza);
  • dato che non è possibile sapere dinamicamente quante colonne ha ciascuna riga [per esempio, un sizeof(ptr_to_data) quando ptr_to_data si riferisce alla riga 0 darebbe come valore 4 e non 8 perché tanti sono i byte che occorrono per allocare un tipo int *], siamo costretti a utilizzare un costrutto switch che a seconda della riga corrente valorizza la variabile cols_nr con la costante simbolica relativa (per esempio, se r vale 0 allora cols_nr conterrà il valore di COLS_I e così via per gli altri valori di r).

Puntatori e array di lunghezza variabile

Un puntatore può riferire anche un array, sia monodimensionale sia multidimensionale, dichiarato con una lunghezza variabile (VLA, variable-length array).

Snippet 7.14 Puntatori a VLA.

// puntatore che riferisce un array monodimensionale
int nr_of_el = 5;
int data[nr_of_el];
int *ptr_to_data = data;

// puntatore che riferisce un array bidimensionale (un puntatore a un array di 5 colonne)
int nr_of_rows = 5;
int nr_of_cols = 5;
int data_m[nr_of_rows][nr_of_cols];
int (*ptr_to_data_m)[nr_of_cols] = data_m;

// puntatore che riferisce un array bidimensionale (un array di 5 puntatori a int)
int *data_o_m[nr_of_rows];
int **ptr_to_data_o_m = data_o_m;

I puntatori dichiarati nello Snippet 7.14 sono definiti dallo standard C11 come variably modified types perché il loro tipo dipende da valori non costanti: dal valore della variabile nr_of_el, nel caso di ptr_to_data, e dal valore della variabile nr_of_rows, nel caso di ptr_to_data_m e ptr_to_data_o_m.

TERMINOLOGIA

Un variably modified (VM) type è nella sostanza un puntatore a un array a lunghezza variabile (VLA).

Questi puntatori hanno poi, al pari dei VLA, alcune restrizioni come, per esempio: possono essere dichiarati solo nelle funzioni (anche come parametri) o in qualsiasi blocco di codice oppure come parametri formali nei prototipi delle funzioni; i relativi identificatori devono essere degli identificatori ordinari (non possono essere, per esempio, identificatori di puntatori a VLA che sono membri di struct o union).

Infine, per essi, l’aritmetica dei puntatori è ben definita, si comporta cioè come se tali puntatori puntassero ad array non VLA.

Puntatori a puntatori

Un puntatore a puntatore, già incontrato nel corso della trattazione sugli array di puntatori, è, in linea più generale, una variabile che contiene un indirizzo di memoria di un’altra variabile la quale contiene, anch’essa, un indirizzo di memoria di un’altra variabile che contiene un valore di un tipo determinato (Snippet 7.15).

Snippet 7.15 Puntatore a puntatore.

// dichiarazione di un doppio puntatore...
int number = 100;
int *ptr_to_number = &number;
int **ptr_to_ptr_to_number = &ptr_to_number;

// un deriferimento
// ritorna come valore l'indirizzo di memoria contenuto in ptr_to_number
int *first_der = *ptr_to_ptr_to_number;

// doppio deriferimento
// ritorna come valore il numero 100 che è contenuto in number
int value = **ptr_to_ptr_to_number;

In sostanza lo Snippet 7.15 si può leggere come segue, considerando anche, per i primi tre punti, la Figura 7.16.

  1. Dichiariamo la variabile di tipo int number contenente il valore 100 che viene allocata all’indirizzo di memoria 0x006ffb84.
  2. Dichiariamo la variabile di tipo puntatore a int ptr_to_number contenente il valore 0x006ffb84 (che è l’indirizzo di memoria di number) che viene allocata all’indirizzo di memoria 0x006ffb78.
  3. Dichiariamo la variabile di tipo puntatore a puntatore a int ptr_to_ptr_to_number contenente il valore 0x006ffb78 (che è l’indirizzo di memoria di ptr_to_number) che viene allocata all’indirizzo di memoria 0x006ffb6c.
  4. Dichiariamo la variabile di tipo puntatore a int first_der contenente il valore 0x006ffb84 (che è l’indirizzo di memoria di number). Essa contiene tale indirizzo perché l’operatore di deriferimento applicato sul nome ptr_to_ptr_to_number fa ritornare il valore contenuto nell’indirizzo di memoria 0x006ffb78 che è, per l’appunto, 0x006ffb84.
  5. Dichiariamo la variabile di tipo int value contenente il valore 100. Essa contiene tale valore perché il primo operatore di deriferimento applicato sul nome ptr_to_ptr_to_number fa ritornare il valore 0x006ffb84 (che è l’indirizzo di memoria di number), e poi l’altro operatore di deriferimento applicato su tale indirizzo fa ritornare, per l’appunto, il valore 100.

7_16.jpg

Figura 7.16 Rappresentazione grafica di un doppio puntatore.

Vediamo ora un pratico esempio di utilizzo di un doppio puntatore (Listato 7.7) che consente di “simulare” un passaggio per riferimento di un argomento a un parametro di un funzione (ricordiamo che in C gli argomenti sono passati sempre per valore), in modo che venga modificato l’argomento stesso piuttosto che il valore da esso riferito (per effetto di ciò il parametro della funzione può essere considerato una sorta di alias dell’argomento, ossia un nome alternativo cui riferirlo e attraverso cui manipolarlo).

Listato 7.8 SimulatingPassByReference.c (SimulatingPassByReference).

/* SimulatingPassByReference.c :: Pass by reference con i doppi puntatori :: */
#include <stdio.h>
#include <stdlib.h>

void foo(int **p);

int main(void)
{
int a = 10;
int *j = &a;

// per stampare 0x si sarebbe potuto usare anche %#p
printf("Indirizzo riferito da j [ 0x%p ] PRIMA del passaggio dell'argomento\n", j);

foo(&j); // passo l'indirizzo di memoria di j esso stesso puntatore...

// per stampare 0x si sarebbe potuto usare anche %#p
printf("Indirizzo riferito da j [ 0x%p ] DOPO il passaggio dell'argomento e "
"*p = &k;\n", j);

return (EXIT_SUCCESS);
}

void foo(int **p)
{
static int k = 100;
*p = &k; // j è interessato... simulazione del pass by reference
}

Output 7.8 Dal Listato 7.8 SimulatingPassByReference.c.

Indirizzo riferito da j [ 0x0037fed8 ] PRIMA del passaggio dell'argomento
Indirizzo riferito da j [ 0x003a901c ] DOPO il passaggio dell'argomento e *p = &k;

Nel Listato 7.8 la funzione main dichiara la variabile a di tipo int e poi la variabile j come puntatore a essa. Stampa quindi l’indirizzo di memoria contenuto in j (0x0037fed8) che è quello dove la variabile a è stata allocata. Invoca poi la funzione foo alla quale passa l’indirizzo di memoria dove è stato allocato il puntatore j medesimo (0x0037fecc).

A questo punto la funzione foo, che prende il controllo dell’esecuzione del codice, definisce la variabile locale statica k e poi ne assegna l’indirizzo di memoria (0x003a901c) a ciò cui punta p, ossia al puntatore j, che d’ora in poi punterà a questa nuova variabile piuttosto che a quella originaria a.

TERMINOLOGIA

Una variabile locale a una funzione si definisce statica quando conserva il suo valore anche se il flusso di esecuzione del codice esce dal blocco ove è dichiarata. Per definire una variabile come statica si deve usare lo specificatore di classe di memorizzazione espresso tramite la keyword static. Ritorneremo in modo approfondito su questo punto nel Capitolo 9.

NOTA

Nella funzione foo si è reso necessario dichiarare la variabile k come statica perché non viene distrutta al termine dell’esecuzione della funzione, e dunque il suo indirizzo di memoria, riferito dal puntatore j dopo l’assegnamento a esso compiuto per mezzo dell’istruzione *p = &k;, resta ancora valido (non è utilizzato per allocare altre variabili) così come il valore lì contenuto.

Quando, infine, il flusso di esecuzione del codice ritorna nella funzione main, la successiva istruzione printf stampa nuovamente il valore dell’indirizzo di memoria riferito dal puntatore j (0x003a901c) che, questa volta, è quello non più alla variabile a, ma quello della variabile k.

Dunque, per il tramite del parametro p della funzione foo è stato possibile cambiare l’argomento stesso riferito grazie a un’implementazione “manuale” del meccanismo del passaggio degli argomenti by reference non presente, di “serie”, nel linguaggio C.

La Figura 7.17 dà una panoramica visuale di quanto descritto, sia dal punto di vista dei puntamenti sia della disposizione in memoria delle variabili utilizzate.

7_17.jpg

Figura 7.17 Modificabilità di un puntatore passato come argomento a un doppio puntatore.

In sostanza, dall’analisi della disposizione in memoria della variabili mostrata nella Figura 7.17 abbiamo che, prima dell’assegnamento di *p = &k nella funzione foo:

  1. la variabile a di tipo int è allocata all’indirizzo di memoria 0x0037fed8;
  2. la variabile j di tipo puntatore a int è allocata all’indirizzo di memoria 0x0037fecc e contiene come valore l’indirizzo di a, ossia 0x0037fed8;
  3. la variabile p di tipo puntatore a puntatore a int è allocata all’indirizzo di memoria 0x0037fdf8 e contiene come valore l’indirizzo di memoria di j, ossia 0x0037fecc;
  4. la variabile k di tipo puntatore a int è allocata all’indirizzo di memoria 0x003a901c.

Al termine delle operazioni avremo che p conterrà un riferimento a j che conterrà un riferimento ad a che conterrà il valore 10.

Dopo l’assegnamento di *p = &k nella funzione foo abbiamo, invece, la seguente disposizione in memoria delle stesse variabili:

  1. la variabile a di tipo int continua a essere allocata all’indirizzo di memoria 0x0037fed8;
  2. la variabile j di tipo puntatore a int continua a essere allocata all’indirizzo di memoria 0x0037fecc, ma ora contiene come valore l’indirizzo di k ossia 0x003a901c;
  3. la variabile p di tipo puntatore a puntatore a int continua a essere allocata all’indirizzo di memoria 0x0037fdf8 e a contenere come valore l’indirizzo di memoria di j, ossia 0x0037fecc;
  4. la variabile k di tipo puntatore a int continua a essere allocata all’indirizzo di memoria 0x003a901c.

Cos’è accaduto, quindi, di diverso dopo l’esecuzione dell’istruzione *p = &k;? In pratica &k dice al compilatore di fornire l’indirizzo di memoria della variabile k (0x003a901c) e di assegnarlo come contenuto all’indirizzo di memoria contenuto in p (0x0037fecc) opportunamente dereferenziato. Tale indirizzo di memoria, appartenendo a j, conterrà un nuovo indirizzo di puntamento, non più verso la variabile a ma verso la variabile k. Al termine delle operazioni avremo che p conterrà un riferimento a j che conterrà un riferimento a k che conterrà il valore 100.

Puntatori a funzione

C, da quello straordinario linguaggio che è, consente di utilizzare i puntatori anche per memorizzare in essi un indirizzo di memoria di una funzione, ossia di “codice” piuttosto che di “dati”, come è il caso dei puntatori sin qui analizzati.

Quanto detto non deve sorprendere: se un puntatore è una variabile deputata a contenere come valore un indirizzo di memoria allora, dal “suo punto di vista”, non fa alcuna differenza se tale indirizzo è quello utilizzato per allocare una variabile oppure per caricare del codice di una funzione (l’indirizzo di memoria di una funzione è quel punto della memoria a partire dal quale inizia il codice eseguibile della funzione stessa).

Sintassi 7.8 Dichiarazione di un puntatore a funzione.

data_type (*fptr_identifier)(void | parameters_list);

La Sintassi 7.8 evidenzia che la dichiarazione di un puntatore a funzione è espressa scrivendola come un normale prototipo di funzione, ma con la differenza che l’identificatore della funzione deve essere preceduto dal carattere asterisco * e racchiuso tra una coppia di parentesi tonde ( ).

Snippet 7.16 Dichiarazione di un puntatore a funzione.

int (*ptr_to_func)(int, int);

Lo Snippet 7.16 dichiara un puntatore a funzione, ptr_to_func, capace di contenere un indirizzo di memoria di una qualsiasi funzione che ha la sua stessa segnatura, ossia è del suo stesso tipo: ritorna un int e accetta due parametri di tipo int.

Le parentesi tonde che racchiudono il nome del tipo sono essenziali perché permettono di identificare ptr_to_func come un puntatore a una funzione di tipo (int, int) -> int. In loro assenza, infatti, int *ptr_to_func(int, int); significherebbe tutt’altro, e cioè che ptr_to_func è una funzione che ritorna un puntatore a un int e ha due parametri di tipo int, e quindi la sua segnatura sarebbe (int, int) -> int *.

Dopo aver dichiarato un puntatore a funzione di un determinato tipo è possibile passare a esso l’indirizzo di memoria di una funzione dello stesso tipo utilizzando il consueto operatore di assegnamento, dove l’operando di sinistra sarà l’identificatore del puntatore a funzione mentre l’operando di destra sarà l’identificatore della funzione scelta (Snippet 7.17).

Snippet 7.17 Assegnamenti validi e non validi a un puntatore a funzione.

...
// vari prototipi di funzione…
int sub_P(int a, int b);
double sqrt_P(double j);

int main(void)
{
// puntatore a funzione di tipo (int, int) -> int
int (*ptr_to_func)(int, int);

ptr_to_func = sub_P; // ok, stesso tipo
ptr_to_func = sqrt_P; // no, non dello stesso tipo
ptr_to_func = sub_P(1,2); // no, nessun indirizzo ritornato
...
}

Nello Snippet 7.17 il puntatore a funzione ptr_to_func è inizializzato con tre valori differenti, ma solo il primo assegnamento è valido e dunque corretto perché il nome sub_P si riferisce all’indirizzo di memoria di una funzione del suo stesso tipo.

Il secondo assegnamento non è valido perché il nome sqrt_P si riferisce all’indirizzo di memoria di una funzione di un tipo diverso (double) -> double, mentre il terzo assegnamento è ancora non valido perché il valore assegnato è di tipo int, ossia quello ritornato dall’invocazione della funzione sub con i valori 1 e 2.

Nel secondo e terzo caso un compilatore come GCC ritornerà, nell’ordine, i messaggi di warning assignment from incompatible pointer type e assignment makes pointer from integer without a cast.

A parte la validità o meno degli assegnamenti mostrati, è anche importante comprendere che quando il compilatore incontra un identificatore di una funzione, se è sprovvisto delle parentesi tonde, allora lo valuta ritornando un puntatore, ovvero l’indirizzo di memoria dove inizia il suo codice eseguibile (non è necessario usare l’operatore di indirizzo &). Se invece è seguito dalla parentesi tonde, allora le stesse ne rappresentano l’operatore di invocazione di funzione ed è generato l’opportuno codice di chiamata della funzione stessa.

Per quanto attiene alla modalità di utilizzo di un puntatore a funzione, ossia a come sia possibile invocare la funzione riferita possiamo fare quanto segue (Snippet 7.18).

Snippet 7.18 Utilizzo di un puntatore a funzione.

...
// vari prototipi di funzione…
int sub_P(int a, int b);
double sqrt_P(double j);

int main(void)
{
// puntatore a funzione di tipo (int, int) -> int
int (*ptr_to_func)(int, int);

ptr_to_func = sub_P; // ok, stesso tipo...
int res = (*ptr_to_func)(100, 100); // ...risultato corretto

ptr_to_func = sqrt_P; // no, non dello stesso tipo...
double res_2 = (*ptr_to_func)(100, 100); // ...comportamento non definito

ptr_to_func = sub_P(1, 2); // no, nessun indirizzo ritornato...
int res_3 = (*ptr_to_func)(100, 100); // ...comportamento non definito
}

In definitiva è sufficiente usare le parentesi tonde al cui interno dereferenziare il relativo puntatore al fine di far ritornare l’indirizzo di memoria della funzione da invocare con i seguenti argomenti. Per esempio, quando ptr_to_func si riferirà a sub_P, scrivere (*ptr_to_func)(100, 100) ne farà ritornare l’indirizzo, e dunque l’operatore di invocazione di funzione con gli argomenti 100 e 100 sarà utilizzato su di esso (sarà, in pratica, invocata sub_P per il tramite di ptr_to_func e infatti da questo punto di vista *ptr_to_func è considerabile un alias di sub_P).

ATTENZIONE

Quando un puntatore di funzione contiene un riferimento verso un indirizzo di memoria di una funzione di un tipo differente il comportamento del compilatore sarà non definito.

NOTA

È possibile invocare una funzione riferita tramite un puntatore a funzione utilizzando anche la forma ptr_to_func(100, 100), ossia senza usare le parentesi tonde ( ) e l’operatore di deriferimento *; questo perché quando ptr_to_func è valutato ritorna l’indirizzo di memoria di una funzione direttamente chiamabile (ne è fatto un implicito deriferimento). In ogni caso, anche se più prolissa, la prima forma, cioè (*ptr_to_func)(100,100), rende più esplicito che ptr_to_func è un puntatore a funzione ed è per il suo tramite che si sta invocando un’altra funzione. Se, infatti, si utilizzasse un identificatore non significativo, per esempio func, si potrebbe pensare che func sia “direttamente” il nome della funzione che si sta invocando.

I puntatori a funzione sono un utile strumento di programmazione impiegato spesso per scrivere algoritmi o funzionalità “generiche”, dove cioè sia possibile separare il “cosa” l’algoritmo deve fare da il “come” lo deve fare effettivamente.

Il “come”, in C, è codificato in un’apposita funzione che viene poi fornita come argomento (il suo puntatore) al parametro (di tipo puntatore a funzione) di un’altra funzione, che ne rappresenta il “cosa”.

Per comprendere quanto asserito è possibile riferirci a un classico esempio didattico che fa uso della funzione qsort, dichiarata nel file header <stdlib.h> della libreria standard del linguaggio C con prototipo void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)), il cui obiettivo computazionale è quello di ordinare gli elementi di un array in base a un determinato criterio.

Analizzando il suo prototipo si nota subito come tale funzione sia in effetti “generica” perché sarà il client utilizzatore che dovrà fornirgli, come ultimo argomento, un puntatore a una funzione di comparazione che deciderà come gli elementi dell’array dovranno essere ordinati, ossia secondo quale modalità un elemento dovrà essere considerato minore, maggiore oppure uguale rispetto a un altro elemento.

In definitiva, la funzione qsort dice “cosa” sta eseguendo in quel momento, cioè che sta ordinando gli elementi di un array; il “come” debbano essere ordinati, però, è espresso tramite un’altra funzione che le viene passata come argomento.

TERMINOLOGIA

Una funzione che può accettare come suo argomento un’altra funzione e/o restituire una funzione come risultato della sua computazione è sovente indicata con il termine di higher-order function (funzione di ordine superiore).

Listato 7.9 PointersToFunctions.c (PointersToFunctions).

/* PointersToFunctions.c :: Puntatori a funzioni :: */
#include <stdio.h>
#include <stdlib.h>

// prototipi di funzione
// notare come gli identificatori dei parametri abbiano un nome diverso da quello
// dei corrispettivi identificatori indicati nella definizione di tali funzioni;
// ricordiamo che ciò non rappresenta alcun problema: sono semplicemente ignorati!
int makeOperations(int a, int b, int (*f)(int, int));

int addition(int a, int b);
int subtraction(int a, int b);
int multiplication(int a, int b);
int division(int a, int b);

int main(void)
{
int val1, val2, op = 0;

// array di puntatori a funzioni di tipo (int, int) -> int
int (*array_of_op[])(int, int) = {addition, subtraction, multiplication, division};

// array di puntatori a caratteri
char *op_name[] = {"addizione", "sottrazione", "moltiplicazione", "divisione"};

printf("***************** Operazioni Aritmetiche ***************************\n\n");
printf("[0] addizione\n[1] sottrazione\n[2] moltiplicazione\n[3] divisione\n\n");
scanf("%d", &op);

while (op < 0 || op > 3)
{
printf("Operazione aritmetica\n");
printf("[0] addizione, [1] sottrazione, [2] moltiplicazione, [3], divisione ");
scanf("%d", &op);
}

printf("\nPrimo numero: ");
scanf("%d", &val1);

printf("Secondo numero: ");
scanf("%d", &val2);

// stampa il risultato
printf("\nLa %s tra %d e %d ha prodotto come risultato %d\n",
op_name[op], val1, val2, makeOperations(val1, val2, array_of_op[op]));
printf("********************************************************************\n\n");

return (EXIT_SUCCESS);
}

// definizioni delle funzioni
int makeOperations(int value_1, int value_2, int (*op)(int, int))
{
// esegue la funzione riferita; può essere qualsiasi funzione di tipo
// (int, int) -> int
return (*op)(value_1, value_2);
}

int addition(int v1, int v2)
{
return v1 + v2;
}

int subtraction(int v1, int v2)
{
return v1 - v2;
}

int multiplication(int v1, int v2)
{
return v1 * v2;
}

int division(int v1, int v2)
{
return v1 / v2;
}

Output 7.9 Dal Listato 7.9 PointersToFunctions.c.

***************** Operazioni Aritmetiche ***************************

[0] addizione
[1] sottrazione
[2] moltiplicazione
[3] divisione

2

Primo numero: 20
Secondo numero: 30

La moltiplicazione tra 20 e 30 ha prodotto come risultato 600
********************************************************************

Il Listato 7.9 mette in pratica quanto sin qui detto sulla possibilità di scrivere funzioni generiche che si avvalgono dei puntatori a funzioni; illustra anche un altro comune pattern di utilizzo degli stessi, ovvero quello che prevede la capacità di definire un array di puntatori a funzioni che consente di scegliere quale funzione invocare direttamente e in modo arbitrario tramite la comune e compatta notazione a indice propria degli array.

Nel programma, tutto ruota attorno alla funzione makeOperations che esegue una qualsiasi operazione tra due valori numerici di tipo int espressa tramite un’apposita funzione di tipo (int, int) -> int che le viene passata come argomento.

NOTA

Nel prototipo di makeOperations il parametro puntatore a funzione può essere scritto anche senza indicarne il nome, come in int (*)(int, int). Invece, nella definizione di makeOperations, il parametro puntatore a funzione può essere scritto con la stessa sintassi vista per la dichiarazione (Sintassi 7.8), come in int (*op)(int, int).

La funzione makeOperations permette, quindi, di generalizzare cosa computa e di separare, nettamente il suo codice dal codice delle funzioni che eseguono nella sostanza la relativa computazione (come, cioè, essa è eseguita).

Questo aspetto è di notevole importanza perché consente di evitare che all’interno della funzione makeOperations si scriva anche il codice delle computazioni, cioè delle operazioni aritmetiche; se, infatti, una qualsiasi delle funzioni di computazione viene in seguito cambiata (si pensi alla funzione division che nell’implementazione corrente non prevede il check della possibile divisione per 0), tale cambiamento non ha alcun impatto su makeOperations, che continua a funzionare in modo trasparente.

La funzione main, invece, definisce un menu di scelta delle quattro operazioni aritmetiche fondamentali memorizzandone il risultato nella variabile op, la quale verrà poi utilizzata per “estrarre” dall’array array_op e op_name, rispettivamente, la funzione dell’operazione da invocare e una stringa di caratteri che ne dà il nome significativo.

In merito all’identificatore array_op, esso dimostra come dichiarare un array di puntatori a funzioni di un certo tipo; nel nostro caso è un array di puntatori a funzioni che ritornano un int e accettano come argomenti due int, e pertanto si presta bene a contenere come elementi i puntatori alle funzioni addition, subtraction, multiplication e division.

L’identificatore op_name, invece, è un array dove ogni elemento è un puntatore a un carattere, ossia a un indirizzo di memoria a partire dal quale si trovano, in successione, tutti gli altri caratteri della stringa corrispondente.

Dopo la scelta dell’operazione da eseguire, memorizziamo i valori da computare nelle variabili val1 e val2 ed eseguiamo la funzione printf dove forniamo come argomenti, nell’ordine: un stringa che descrive l’operazione scelta; il primo valore da computare; il secondo valore da computare; il risultato dell’operazione relativa.

Il risultato dell’operazione è ottenuto tramite l’espressione makeOperations(val1, val2, array_of_op[op]). Data la sua importanza didattica, appare opportuno scomporre nei seguenti passi per comprenderla pienamente.

  1. Viene invocata la funzione makeOperations alla quale si passano val1, val2 e poi il puntatore alla funzione che rappresenta l’elemento dell’array array_of_op posizionato all’indice op. Se per esempio op vale 1, allora array_of_op[1] ritornerà il puntatore alla funzione subtraction.
  2. Viene eseguita la funzione makeOperations, che dereferenzia il suo parametro op in modo da ottenere la locazione di memoria della funzione da eseguire alla quale passa come valori value_1 e value_2. Al termine dell’esecuzione della funzione puntata ne ritorna il risultato al chiamante. Ritornando al nostro esempio del punto 1, *op rappresenterà la funzione subtraction che sarà eseguita e che ritornerà come risultato la differenza tra i valori forniti come argomenti, che sarà a sua volta ritornata al main da makeOperations.

Un altro esempio molto comune di utilizzo dei puntatori a funzione si ha quando si desidera impostare delle funzioni listener o di callback, ossia delle funzioni che devono essere invocate all’accadimento di un determinato evento.

Per implementare tale meccanismo si può definire una funzione, diciamo foo, che avrà come parametro un puntatore a funzione (che riferirà la funzione di callback) la quale, allo scatenarsi dell’evento desiderato, per il “tramite” di foo, verrà invocata.

NOTA

Il meccanismo delle funzioni di callback è sovente usato per assegnare delle funzioni handler come argomenti ad altre funzioni che rappresentano degli eventi che possono accadere sui componenti o widget delle interfacce grafiche. Così, potremmo definire una funzione onclick per un pulsante alla quale poter assegnare un’altra funzione che rappresenta il “comportamento” che dovrà essere intrapreso quando l’utente farà clic sul pulsante relativo.

Listato 7.10 Callback.c (Callback).

/* Callback.c :: Puntatori a funzioni come callback:: */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define NR_OF_ELEMS 10

// prototipi di funzione
void done(int res); // viene invocata solo se il risultato della somma è positivo
void fail(int code_nr); // viene invocata solo se il risultato della somma è negativo

// primo parametro -> array di elementi da sommare
// secondo parametro -> funzione di callback da eseguire se la computazione è eseguita
// correttamente
// terzo parametro -> funzione di callback da eseguire se la computazione
// NON è eseguita correttamente
void sum(int elems[], void(*done)(int), void(*fail)(int));

int main(void)
{
// inizializzazione del generatore pseudo-casuale dei numeri
srand((unsigned int) time(NULL));

int elems[NR_OF_ELEMS];

// inizializzazione elementi dell'array
for (int i = 0; i < NR_OF_ELEMS; i++)
elems[i] = (rand() % 1001) + (-500); // tra +500 e -500

sum(elems, done, fail);

return (EXIT_SUCCESS);
}

// definizioni delle funzioni
void done(int res)
{
printf("Il risultato e' %d\n", res);
}

void fail(int code_nr)
{
printf("Attenzione errore %d di computazione [ risultato < 0 ]\n", code_nr);
}

void sum(int elems[], void(*done)(int), void(*fail)(int))
{
int total = 0;
for (int ix = 0; ix < NR_OF_ELEMS; ix++)
total += elems[ix];

if (total >= 0)
(*done)(total); // chiamo la callback riferita dal parametro done
else
(*fail)(-1); // chiamo la callback riferita dal parametro fail
}

Output 7.10 Dal Listato 7.10 Callback.c.

Attenzione errore -1 di computazione [ risultato < 0 ]

Il Listato 7.10 mostra un semplice esempio di implementazione di un meccanismo di callback dove la funzione sum è definita in modo che, all’accadimento dell’evento di completamento della somma degli elementi di un array passato come primo argomento, se il risultato della computazione è positivo, allora verrà invocata la funzione done passata come secondo argomento altrimenti, se il risultato è negativo, come è il caso mostrato dall’Output 7.10, verrà invocata la funzione fail passata come terzo argomento.

typedef e puntatori a funzione

La sintassi di dichiarazione di un puntatore a funzione è abbastanza elaborata e a volte la sua prolissità può portare a scrivere codice poco leggibile. Al fine, quindi, di rendere più chiaro il codice sorgente ma anche più agevole l’utilizzo di un puntatore a funzione, è possibile utilizzare la keyword typedef. Per esempio, l’istruzione typedef int (*ptr_to_func)(int, int) crea l’alias ptr_to_func, che è un puntatore a una funzione che ha due parametri di tipo int e ritorna un tipo int. Poi, nell’ambito del codice sorgente si potrà scrivere: ptr_to_func myFunc; per dichiarare myFunc come un identificatore del tipo puntatore a funzione ptr_to_func, ossia del tipo puntatore a una funzione di tipo (int, int) -> int (Listato 7.11).

Listato 7.11 typedefForFunctionPointer.c (typedefForFunctionPointer).

/* typedefForFunctionPointer.c :: typedef e puntatori a funzioni :: */
#include <stdio.h>
#include <stdlib.h>

// typedef per una funzione di tipo (int, int) -> int
typedef int (*ptr_to_operations)(int, int);

int sum(int a, int b);
int sub(int a, int b);

// senza il typedef la dichiarazione di una funzione che ritorna un puntatore a funzione
// di tipo (int, int) -> int sarebbe stata molto più complessa e poco leggibile:
// int(*choose(char))(int, int);
ptr_to_operations choose(char);

// senza il typedef la dichiarazione di una funzione che accetta come argomento
// un puntatore a funzione di tipo (int, int) -> int sarebbe stata molto più complessa
// e poco leggibile:
// int makeComputation(int (*)(int, int), int, int);
int makeComputation(ptr_to_operations, int, int);

int main(void)
{
int value1 = 2000;
int value2 = 1000;

// eseguo prima l'addizione
printf("Addizione tra %d e %d = %d\n", value1, value2,
makeComputation(choose('+'), value1, value2));

// eseguo poi la sottrazione
printf("Sottrazione tra %d e %d = %d\n", value1, value2,
makeComputation(choose('-'), value1, value2));

return (EXIT_SUCCESS);
}

int sum(int n1, int n2)
{
return n1 + n2;
}

int sub(int n1, int n2)
{
return n1 - n2;
}

// senza il typedef la definizione di una funzione che ritorna un puntatore a funzione
// sarebbe stata molto più complessa e poco leggibile:
// int(*choose(char code))(int, int)
ptr_to_operations choose(char code)
{
switch (code)
{
case '+': return sum;
case '-': return sub;
}

// nessuna scelta valida, quindi ritorniamo un puntatore nullo a indicare nessun
// indirizzo di puntatore a funzione valido
return NULL;
}

// senza il typedef la dichiarazione di una funzione che accetta come argomento
// un puntatore a funzione sarebbe stata molto più complessa e poco leggibile:
// int makeComputation(int (*op)(int, int), int n1, int n2)
int makeComputation(ptr_to_operations op, int n1, int n2)
{
// se ptr_to_operations contiene un indirizzo di puntatore a funzione valido
// esegui la funzione riferita; equivalente a *op != NULL
if (*op)
return (*op)(n1, n2);
else
{
printf("ATTENZIONE ptr_to_operations contiene un indirizzo non usabile!\n");
printf("ESCO subito dal programma!\n");
exit(EXIT_FAILURE);
}
}

Output 7.11 Dal Listato 7.11 typedefForFunctionPointer.c.

Addizione tra 2000 e 1000 = 3000
Sottrazione tra 2000 e 1000 = 1000

Puntatori a void

Dal punto di vista di un puntatore, un indirizzo di memoria non è altro che una locazione di storage a partire dalla quale viene memorizzato un valore di un determinato tipo.

Sinora abbiamo visto che ogni puntatore deve avere un tipo associato affinché il compilatore possa essere in grado, quando si applica l’operatore di deriferimento * sul puntatore medesimo, di interpretarlo in modo adeguato ed estrarne il valore corretto.

In ogni caso, in C è possibile dichiarare anche puntatori al tipo void (void *), ossia puntatori che non puntano a nessun tipo in particolare, oppure, detto in altro modo, puntatori che possono puntare a qualsiasi tipo (sono definiti, infatti, puntatori generici).

Tuttavia, quando si utilizzano puntatori a void è importante considerare che su di essi non è possibile applicare l’operatore di deriferimento *, così come usare l’aritmetica dei puntatori, perché, contenendo degli indirizzi di memoria di tipi “sconosciuti”, il compilatore non è in grado di sapere quanti byte deve usare per dereferenziarli correttamente.

Però, a differenza dei puntatori a un qualsiasi tipo T, laddove se si assegna un puntatore a un tipo (per esempio float *) a un puntatore a un tipo diverso (per esempio int *) un compilatore emette un apposito messaggio di diagnostica di incompatibilità di assegnamento, i puntatori a tipi diversi possono sempre essere assegnati a puntatori a void e viceversa un puntatore a void può essere sempre assegnato a un puntatore a un altro tipo.

ATTENZIONE

È possibile solo dichiarare variabili di tipo void * ossia puntatori a void ma mai variabili di tipo void ossia variabili di tipo void.

Listato 7.12 VoidPointers.c (VoidPointers).

/* VoidPointers.c :: Puntatori a void :: */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int i_data = 100;
double d_data = 223.2232;
int *ptr_to_int = &i_data;
double *ptr_to_double = &d_data;

// ATTENZIONE assegnamento tra puntatori a tipi diversi
ptr_to_int = ptr_to_double;

printf("Deriferimento di ptr_to_int che contiene l'indirizzo contenuto in "
"ptr_to_double\n[ %d ]\n", *ptr_to_int);

ptr_to_int = &i_data;

// puntatore a void
void *ptr_to_void = ptr_to_int; // ora punta a un int

// ptr_to_void è convertito a int * e poi dereferenziato
printf("Deriferimento di ptr_to_void che contiene l'indirizzo contenuto in "
"ptr_to_int\n[ %d ]\n", *(int*) ptr_to_void);

ptr_to_void = ptr_to_double; // ora punta a un double

// ptr_to_void è ora convertito a double * e poi dereferenziato
printf("Deriferimento di ptr_to_void che contiene l'indirizzo contenuto in "
"ptr_to_double\n[ %.4f ]\n", *(double*) ptr_to_void);

return (EXIT_SUCCESS);
}

Output 7.12 Dal Listato 7.12 VoidPointers.c.

Deriferimento di ptr_to_int che contiene l'indirizzo contenuto in ptr_to_double
[ 1951633139 ]
Deriferimento di ptr_to_void che contiene l'indirizzo contenuto in ptr_to_int
[ 100 ]
Deriferimento di ptr_to_void che contiene l'indirizzo contenuto in ptr_to_double
[ 223.2232 ]

Il Listato 7.12 definisce alcune variabili e dei puntatori a esse. Poi prova ad assegnare la variabile ptr_to_double, che è un puntatore a un double, alla variabile ptr_to_int, che è un puntatore a int, facendo emettere dal compilatore in uso (GCC) il messaggio warning: assignment from incompatible pointer type.

Proviamo, quindi, a far stampare mediante la funzione printf il valore puntato da ptr_to_int che però, come dimostrato dall’Output 7.12, è incongruo; ciò si verifica perché l’indirizzo di memoria contenuto nella variabile ptr_to_int è di una variabile di tipo double (d_data), che è a sua volta contenuto nella variabile ptr_to_double. Pertanto il compilatore quando userà l’operatore di deriferimento * su ptr_to_int interpreterà i byte dell’indirizzo come memoria contenente un valore intero e non, invece, come memoria contenente un valore di tipo decimale (leggerà, per esempio, solo i primi 4 byte propri di un int e non tutti gli 8 byte propri di un double sul corrente sistema target).

Infine, dimostriamo come l’assegnamento di un puntatore a un tipo (per esempio int * o double *) a un puntatore a void (void *) non faccia generare alcun warning da parte del compilatore ma, anzi, la relativa conversione con cast esplicito [(int *) e poi (double *)], e poi l’operazione di deriferimento, consentano di far accedere al valore corretto presente nel corrente indirizzo di memoria riferito.

Solitamente, comunque, i puntatori a void sono un utile strumento per costruire funzioni generiche, ossia funzioni che sono in grado di accettare argomenti di differente tipo oppure di ritornare tipi differenti (Listato 7.13).

Listato 7.13 GenericFunctions.c (GenericFunctions).

/* GenericFunctions.c :: Funzioni generiche :: */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// prototipo di swap
void g_swap(void *val_1, void *val_2, size_t size);

int main(void)
{
int a = 10;
int b = 20;

float f = 10.20f;
float g = 22.33f;

printf("Valori di a e b prima dello swap\t[ %d ] [ %d] \n", a, b);

// swap di tipi int
g_swap(&a, &b, sizeof (int));
printf("Valori di a e b dopo lo swap\t\t[ %d ] [ %d] \n", a, b);

printf("Valori di f e g prima dello swap\t[ %.2f ] [ %.2f] \n", f, g);

// swap di tipi float
g_swap(&f, &g, sizeof (float));
printf("Valori di f e g dopo lo swap\t\t[ %.2f ] [ %.2f] \n", f, g);

return (EXIT_SUCCESS);
}

// definizione di swap
void g_swap(void *val_1, void *val_2, size_t size)
{
void *tmp = malloc(size);
memcpy(tmp, val_1, size);
memcpy(val_1, val_2, size);
memcpy(val_2, tmp, size);
free(tmp);
}

Output 7.13 Dal Listato 7.13 GenericFunctions.c.

Valori di a e b prima dello swap        [ 10 ] [ 20]
Valori di a e b dopo lo swap [ 20 ] [ 10]
Valori di f e g prima dello swap [ 10.20 ] [ 22.33]
Valori di f e g dopo lo swap [ 22.33 ] [ 10.20]

Il Listato 7.13 definisce la funzione generica g_swap che consente di scambiare il valore di due variabili di qualsiasi tipo. Essa è definita con due parametri formali di tipo puntatore a void e un terzo parametro formale, di tipo size_t, atto a contenere la dimensione del tipo di dato da elaborare e che è fondamentale per processare i valori dei tipi passati (un void * è considerabile come un puntatore a un blocco di memoria grezzo, e non sa nulla in merito a cosa è un tipo di dato o alla sua dimensione in byte).

Il body della funzione g_swap dichiara, come primo oggetto, la variabile tmp come un tipo void * e le passa, grazie alla funzione malloc, l’indirizzo di memoria di partenza dello spazio di storage allocato della dimensione fornita dal parametro size.

Ciò permetterà di allocare e processare la giusta quantità di memoria occorrente per dei tipi int, float, double e così via, passati come argomenti alla funzione g_swap.

Successivamente, utilizza la funzione memcpy, dichiarata nel file header <string.h>, per copiare il contenuto della memoria di una variabile sorgente (il secondo argomento) nell’area di memoria riferita da una variabile destinazione (il primo argomento):

  • con la prima invocazione, memcpy copierà il contenuto della variabile riferita da val_1 nell’area di memoria riferita da tmp;
  • con la seconda invocazione, memcpy copierà il contenuto della variabile riferita da val_2 nell’area di memoria riferita da val_1;
  • con la terza invocazione di memcpy, copierà il contenuto della memoria riferita da tmp nell’area di memoria riferita da val_2.

Infine utilizza la funzione free, dichiarata nel file header <stdlib.h>, per liberare la memoria allocata e puntata dalla variabile tmp.

Puntatori nulli

Un puntatore nullo (null pointer) è un puntatore che contiene un valore “speciale” atto a segnalare che esso non punta a niente, ossia non punta e non riferisce alcun indirizzo di memoria concretamente utilizzabile di alcun oggetto (dato) o funzione (codice).

Lo standard del linguaggio C stabilisce che un’espressione costante intera con il valore 0 oppure la stessa espressione convertita in un puntatore a void (void *) rappresenta una costante di tipo puntatore nullo (null pointer constant), la quale genera un puntatore nullo quando è utilizzata durante un’istruzione di inizializzazione, assegnamento o comparazione con una variabile di tipo puntatore.

Tale costante di tipo puntatore nullo è espressa attraverso la macro NULL, che è definita, in linea generale e dalla maggior parte dei compilatori, come #define NULL ((void *)0) in molti file header come <stdio.h>, <stdlib.h>, <stddef.h> e così via.

ATTENZIONE

Un puntatore nullo è differente da un puntatore non inizializzato. Il primo, infatti, non punta né a un oggetto né a una funzione; il secondo, invece, punta a qualsiasi cosa.

NOTA

Ogni compilatore è libero di scegliere la propria rappresentazione di un puntatore nullo, e dunque questo non necessariamente dovrà avere come riferimento un indirizzo di memoria tipo 0x00000000 ma potrà anche contenere un indirizzo di memoria non esistente. Pertanto, dal punto di vista di un programmatore, è sufficiente utilizzare NULL o 0 per generare un puntatore nullo e avere la certezza che esso non punterà a niente di validamente utilizzabile.

Quando si vuole assegnare a un puntatore un puntatore nullo è possibile, in modo intercambiabile, utilizzare il valore 0 oppure il valore NULL.

Tuttavia, è solo in quel contesto che 0 e NULL sono equivalenti (entrambi rappresentano una costante di tipo puntatore nullo). Per esempio, assegnare il valore 0 a una variabile di tipo int significa semplicemente che essa conterrà quel valore costante intero, mentre assegnare alla stessa variabile il valore NULL potrà fare generare a un compilatore un messaggio di diagnostica come initialization makes integer from pointer without a cast, che indicherà, per l’appunto, che si sta provando ad assegnare direttamente un puntatore a void a una variabile di tipo intero.

CONSIGLIO

Per motivi si stile e chiarezza usare sempre la costante NULL per assegnare a un puntatore un puntatore nullo.

I puntatori nulli si rilevano, dunque, essenziali quando si deve verificare se un puntatore contiene un valido indirizzo di memoria referenziabile (si pensi alla funzione malloc che se riesce ad allocare lo spazio di storage richiesto ritorna un puntatore valido a esso altrimenti ritorna un puntatore nullo). Infatti: in caso di verifica affermativa (ha un indirizzo di memoria diverso da 0) la valutazione del puntatore ritornerà un valore uguale a 1 (true); in caso di verifica negativa (ha un indirizzo di memoria uguale a 0) la valutazione del puntatore ritornerà un valore uguale a 0 (false) e permetterà di evitare deriferimenti che potrebbero causare eventi disastrosi come il crash del programma attualmente in esecuzione (in ogni caso, per lo standard di C, dereferenziare un puntatore nullo causerà un comportamento non definito, e un compilatore potrebbe anche non far terminare un programma ma generare piuttosto un risultato inaspettato o non prevedibile).

Snippet 7.19 Puntatore nullo.

int value = 200;

// p_value è impostata per puntare a un puntatore nullo
int *p_value = NULL;

// p_value conterrà un riferimento a un puntatore nullo
// sarà quindi valutata come uguale a 0
_Bool b_p = p_value; // false - b_p conterrà il valore 0

p_value = &value; // puntatore a value

// p_value conterrà un riferimento a un puntatore valido
// sarà quindi valutata come uguale a 1
b_p = p_value; // true - b_p conterrà il valore 1

// un test per verificare se il puntatore è nullo
int nr = 100;
int *p_nr = &nr;
int storage;

// solo se p_nr contiene un indirizzo valido, dereferenziarlo ponendo
// il valore dell'oggetto puntato nella variabile storage
if (p_nr) // equivalente ma prolisso: if(p_nr != NULL)
storage = *p_nr;
else
storage = 0;

La keyword const e i puntatori

Il qualificatore di tipo espresso tramite la keyword const può essere applicato anche a un puntatore che, a seconda della sua collocazione, assumerà una determinata semantica (Sintassi 7.9, 7.10 e 7.11):

Sintassi 7.9 Puntatore a costante – qualificatore const prima del tipo di dato.

const data_type *ptr_identifier;

La Sintassi 7.9 permette di dichiarare un puntatore che potrà contenere un indirizzo di memoria di un oggetto di un determinato tipo che sarà considerato costante.

Ciò implica che il puntatore potrà puntare a un qualsiasi altro indirizzo di memoria, ma il valore dell’oggetto riferito non potrà subire modifiche per effetto dell’applicazione dell’operatore di deriferimento * oppure dell’operatore di subscript con un indice, i quali potranno quindi essere utilizzati solo per delle operazioni di lettura.

NOTA

Un puntatore a costante potrà contenere validamente sia l’indirizzo di memoria di un tipo costante sia di un tipo non costante. Ciò implica che la non modificabilità del valore è significativa solo dal punto di vista del puntatore a costante. Infatti, se contiene un indirizzo di memoria di un dato non costante, quest’ultimo potrà ancora subire modifiche per il tramite del suo identificatore ma non per il tramite dell’identificatore del puntatore a costante.

Snippet 7.20 Puntatore a costante.

// array non costante
int data[] = {100, 200, 300};

// array costante
const int ro_data[] = {-1, -2, -3};

// puntatore a costante di tipo int
// assegnamento di un dato non costante
const int *ptr_1 = data;
*ptr_1 = 10; // error: assignment of read-only location '*ptr_1'
data[0] = -100; // OK data non è const

// puntatore a costante di tipo int
// assegnamento di un dato costante
const int *ptr_2 = ro_data;
*ptr_2 = 10; // error: assignment of read-only location '*ptr_2'
ro_data[0] = 1; // error: assignment of read-only location 'ro_data[0]

// ok i puntatori a costante possono puntare ad altri oggetti
int other = 2;
ptr_1 = ptr_2 = &other;

Sintassi 7.10 Puntatore costante – qualificatore const prima dell’identificatore.

data_type *const ptr_identifier;

La Sintassi 7.10 permette di dichiarare un puntatore che potrà contenere un indirizzo di memoria di un oggetto di un determinato tipo ma che non potrà puntare e riferire altri indirizzi di memoria di altri oggetti dello stesso tipo.

Ciò implica che il puntatore non potrà puntare a qualsiasi altro indirizzo di memoria, ma il valore dell’oggetto riferito potrà subire modifiche per effetto dell’applicazione dell’operatore di deriferimento * oppure dell’operatore di subscript con un indice i quali potranno quindi essere utilizzati per operazioni di scrittura e di lettura.

Snippet 7.21 Puntatore costante.

// array non costante
int data[] = {100, 200, 300};

// array costante
const int ro_data[] = {-1, -2, -3};

// puntatore costante a un int
// assegnamento di un dato non costante
int *const ptr_1 = data;
*ptr_1 = 10; // OK il puntatore non è un puntatore a costante
data[0] = -100; // OK data non è const

// puntatore costante a un int
// assegnamento di un dato costante
int *const ptr_2 = ro_data; // warning: initialization discards 'const' qualifier
// from pointer target type
*ptr_2 = 10; // modifica di un dato costante con *ptr_2; comportamento non definito
ro_data[0] = 1; // error: assignment of read-only location 'ro_data[0]

int other = 2;
ptr_1 = &other; // error: assignment of read-only variable 'ptr_1'
ptr_2 = &other; // error: assignment of read-only variable 'ptr_2'

Lo Snippet 7.21 dichiara le stesse variabili dello Snippet 7.20 ma fa un’importante modifica: i puntatori ptr_1 e ptr_2 diventano puntatori costanti, e dunque, dopo l’assegnamento, rispettivamente dell’indirizzo di memoria del primo elemento di data e dell’indirizzo di memoria del primo elemento di ro_data, non potranno puntare ad altri oggetti come evidenziato dall’errore di compilazione che il compilatore ha emesso quando abbiamo tentato di assegnare l’indirizzo della variabile other ai predetti puntatori.

Notiamo anche che il compilatore ha emesso un warning quando l’indirizzo del primo elemento di ro_data è stato assegnato al puntatore costante ptr_2.

Questo è accaduto perché, come regola generale, lo standard di C asserisce che se si prova a modificare un oggetto costante per il tramite di un oggetto non costante il comportamento sarà non definito; il nostro compilatore, dunque, ci mette in guardia che quell’inizializzazione scarta il qualificatore const dell’oggetto riferito dal puntatore ptr_2 (l’oggetto riferito era infatti di tipo const int) e pertanto su di esso potranno avvenire modifiche per il tramite dell’oggetto non costante *ptr_2 (è di tipo int). Tuttavia non è possibile esere certi se queste modifiche avverranno o meno oppure se vi potranno essere effetti differenti (il comportamento è cioè non definito; potrà accadere qualsiasi cosa).

Quello che bisogna comprendere è che la non modificabilità di un puntatore costante è riferita solo al fatto che esso non può puntare a un altro indirizzo di memoria ma può certamente cambiare il valore dell’oggetto riferito.

Infatti, una dichiarazione come int *const ptr_2 può essere letta in modo estensivo, partendo da destra verso sinistra, come “ptr_2 è un identificatore costante di un oggetto non costante che è un puntatore a un int”; dunque, in questo caso, quello che è costante, è il puntatore che non può subire modifiche del valore lì contenuto una volta assegnato (in questo caso, quindi, &ro_data[0] e ptr_2 non avranno corrispondenza di dichiarazioni const tra il dato che potrà essere riferito, che sarà di tipo const int, e l’oggetto che potrà essere dereferenziato, che sarà di tipo int).

Invece, una dichiarazione come const int *ptr_2 presente nello Snippet 7.20 può essere letta in modo dettagliato, sempre da destra verso sinistra, come: “ptr_2 è un identificatore non costante di un oggetto costante che è un puntatore a un const int”; dunque, in questo caso, quello che è costante è il dato dereferenziato (in questo caso, quindi, &ro_data[0] e ptr_2 avranno corrispondenza di dichiarazioni const tra il dato che potrà essere riferito, che sarà di tipo const int, e l’oggetto che potrà essere dereferenziato, che sarà di tipo const int).

Sintassi 7.11 Puntatore costante a costante – qualificatore const prima del tipo e prima dell’identificatore.

const data_type *const ptr_identifier;

La Sintassi 7.11 permette di dichiarare un puntatore che potrà contenere un indirizzo di memoria di un oggetto di un determinato tipo che sarà considerato costante e non potrà puntare ad altri indirizzi di memoria di altri oggetti dello stesso tipo.

Ciò implica che il puntatore non potrà puntare a qualsiasi altro indirizzo di memoria, e il valore dell’oggetto riferito non potrà subire modifiche per effetto dell’applicazione dell’operatore di deriferimento * oppure dell’operatore di subscript con un indice, i quali potranno quindi essere utilizzati solo per delle operazioni di lettura.

Snippet 7.22 Puntatore costante a costante.

// array non costante
int data[] = {100, 200, 300};

// array costante
const int ro_data[] = {-1, -2, -3};

// puntatore costante a costante di tipo int
// assegnamento di un dato non costante
const int *const ptr_1 = data;
*ptr_1 = 10; // error: assignment of read-only location '*ptr_1'
data[0] = -100; // OK data non è const

// puntatore costante a costante di tipo int
// assegnamento di un dato costante
const int *const ptr_2 = ro_data;
*ptr_2 = 10; // error: assignment of read-only location '*ptr_2'
ro_data[0] = 1; // error: assignment of read-only location 'ro_data[0]

int other = 2;
ptr_1 = &other; // error: assignment of read-only variable 'ptr_1'
ptr_2 = &other; // error: assignment of read-only variable 'ptr_2'

CONSIGLIO

Per leggere e comprendere correttamente le dichiarazioni di puntatori con o senza l’uso del qualificatore const si può procedere nel seguente modo, partendo dall’identificatore e poi procedendo con tutti gli altri elementi posti alla sua sinistra; per esempio, int *p; dichiara p come un puntatore a un int (p as pointer to int); const int *p; dichiara p come un puntatore a una costante di tipo int (p as pointer to const int); int *const p; dichiara p come un puntatore costante a un int (p as const pointer to int); const int *const p; dichiara p come un puntatore costante a una costante di tipo int (p as const pointer to const int).

La keyword const viene sovente impiegata con i parametri di una funzione di tipo puntatore per specificare che gli stessi non potranno modificare i relativi argomenti.

È molto comune, per esempio, dichiarare una funzione che deve elaborare gli elementi di un array passato come argomento con un parametro che è un puntatore a costante del tipo degli elementi dell’array. Questa tecnica, di fatto, permette il raggiungimento di due scopi contemporaneamente: il primo è legato all’efficienza, perché nel parametro non sono copiati tutti gli elementi dell’array ma solo l’indirizzo di memoria del suo primo elemento; il secondo, invece, è legato alla sicurezza, perché gli elementi dell’array passato come argomento non potranno subire modifiche inattese per il tramite del parametro puntatore.

Snippet 7.23 Caso d’uso di un puntatore a costante di tipo int come parametro di una funzione.

...
int sumArray(const int *elems, int size)
{
int sum = 0;
for (int i = 0; i < size; i++)
{
// se un elemento è negativo lo voglio rendere positivo...
if (*elems < 0)
*elems = -(*elems); // error: assignment of read-only location '*elems'
sum += elems[i]; // ok elems è usato solo in lettura
}

return sum;
}

int main(void)
{
int data[] = {-1, -2, -3, -4, -5, -6, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
sumArray(data, sizeof data / sizeof (int));
...
}

Lo Snippet 7.23 definisce la funzione sumArray, dove esplicita che il parametro elems è un puntatore a costante di tipo int, ossia per il tramite di esso non sarà possibile modificare un qualsiasi oggetto riferito che, per il nostro obiettivo didattico, sono tutti gli elementi dell’array data passato come argomento e processati nel relativo ciclo for.

Questa limitazione ha permesso di evitare che nel body di sumArray potessimo modificare il valore di un elemento dell’array data, quando negativo, in positivo; in pratica abbiamo sia protetto da modifiche l’array originario sia ottenuto un’adeguata performance, perché nel puntatore elems, quando sumArray è stata invocata, sono stati copiati solo 4 byte (nel sistema in uso a 32 bit), ossia la dimensione dello spazio di storage richiesto per memorizzare un puntatore che nel nostro caso è rappresentato dall’indirizzo di memoria del primo elemento dell’array data.

La keyword restrict e i puntatori

A partire dallo standard C99, è stato introdotto un nuovo qualificatore di tipo espresso tramite la keyword restrict che è applicabile solo a un puntatore il quale diventa, in conseguenza della sua applicazione, un puntatore ristretto (restricted pointer).

Sintassi 7.12 Puntatore ristretto.

data_type *restrict ptr_identifier;

La Sintassi 7.12 illustra che la keyword restrict deve essere posta prima dell’identificatore del puntatore; essa, infatti, qualifica il puntatore come ristretto e non l’oggetto cui punta. Prima di spiegare cos’è un puntatore ristretto, è opportuno illustrare alcuni concetti preliminari che aiuteranno a comprendere il perché della sua introduzione.

  • Aliasing: situazione per cui due o più oggetti si riferiscono alla stessa locazione di memoria che può dunque essere manipolata, in modo equivalente, per il tramite dei predetti oggetti (Snippet 7.24). In pratica l’aliasing consente di riferire uno stesso oggetto mediante l’impiego di più nomi.

Snippet 7.24 Aliasing.

int i_value = 100;

// aliasing: i due puntatori si riferiscono allo stesso indirizzo in memoria
int *ptr_to_i_value_1 = &i_value;
int *ptr_to_i_value_2 = &i_value;

// *ptr_to_i_value_1 è anche un alias di i_value
// entrambi riferiscono la stessa area di memoria che è modificata con il valore 300
*ptr_to_i_value_1 = 300;

La possibilità nel linguaggio C di creare alias può però portare sia a sottili errori di programmazione difficili da scoprire se tali alias non sono stati pianificati correttamente (per esempio, in una funzione si modifica il valore di un oggetto puntato da un parametro di tipo puntatore ma tale modifica non doveva però accadere) sia a possibili restrizioni cui devono sottostare i compilatori che non possono compiere eventuali ottimizzazioni sul codice eseguibile prodotto (per esempio, se un indirizzo di memoria è riferito da più oggetti, il compilatore dovrà necessariamente, per ogni oggetto, compiere opportune operazioni di lettura e/o scrittura del relativo valore perché ciascun oggetto potrà avere compiuto con tale indirizzo delle manipolazioni; in caso contrario, però, se un solo oggetto riferisce un indirizzo di memoria un compilatore potrebbe memorizzarne il valore nelle veloci unità di memoria quali sono i registri). Dal punto di vista di un compilatore, pertanto, non viene fatta alcuna assunzione che l’aliasing non avvenga e pertanto non compie, nell’eventualità, alcuna ottimizzazione.

  • Strict aliasing: regola per cui un compilatore assume che due o più puntatori, di tipo differente non si riferiscono mai alla stessa locazione di memoria. In questo caso, quindi, un compilatore è in grado di compiere certe ottimizzazioni per rendere il codice eseguibile più veloce ed efficiente. Questo, comunque, se da una parte può migliorare l’efficienza del codice generato, può portare a comportamenti non definiti se il programmatore vìola la regola dello strict aliasing (GCC, per esempio, genera solo il messaggio warning: dereferencing type-punned pointer will break strict-aliasing rules, se si accorge della sua violazione).

TERMINOLOGIA

Per type punning si intende una tecnica mediante la quale è possibile “aggirare” il type system di un linguaggio di programmazione al fine di far manipolare il valore di un tipo come valore di un altro tipo. Ciò può avvenire, per esempio, quando si fa il cast in un puntatore a int (destinazione) da un puntatore a float (sorgente) e se ne manipola il valore tramite il puntatore di destinazione.

Snippet 7.25 Strict aliasing.

float *data[2];

// violazione di strict aliasing:
// puntatori di tipo diverso puntano alla stessa area di memoria

// warning: dereferencing type-punned pointer will break strict-aliasing rules
int *i = (int *) data;

// warning: dereferencing type-punned pointer will break strict-aliasing rules
short *s = (short *) data;

// manipolazione dell'area di memoria con due puntatori a tipi differenti
// qualsiasi risultato possibile perché il compilatore assume che il
// programmatore rispetti la regola dello strict aliasing e prova a fare
// delle ottimizzazioni
*i = 42;
s[0] = 0;
s[1] = 1;

NOTA

In GCC si possono usare durante la fase di compilazione i seguenti flag: -fstrict-aliasing per consentire al compilatore di assumere che venga rispettata la regola dello strict aliasing; -Wstrict-aliasing=2 per far attivare dei warning se il codice di un programma vìola la regola dello strict aliasing che il compilatore usa per compiere delle ottimizzazioni; -O3 per far attivare tutte le ottimizzazioni possibili. Ciò detto è possibile mandare in esecuzione del codice sorgente contenente lo Snippet 7.25 con, per esempio, il seguente comando: gcc -std=c11 -fstrict-aliasing -Wstrict-aliasing=2 -O3 7.25.c -o 7.25.

Come abbiamo detto, un compilatore assumerà sempre che l’aliasing tra oggetti sia potenzialmente effettuabile e pertanto non provvederà mai a compiere alcuna ottimizzazione sul codice al fine di renderlo più efficiente.

Ecco allora che entra in gioco l’utilità della keyword restrict: essa darà l’indicazione al compilatore che il programmatore “si impegnerà” a non far mai puntare la stessa area di memoria a due o più puntatori dello stesso tipo e, pertanto, potrà tentare di eseguire qualsiasi ottimizzazione sul codice che riterrà opportuno.

NOTA

Un compilatore può anche ignorare la keyword restrict e non compiere alcuna ottimizzazione.

Tuttavia, e questo è un punto di notevole importanza da ricordare, il compilatore “si fiderà” del programmatore e non verificherà la violazione di questa sorta di contratto stipulato con esso: ciò significa che se viene fatto un alias di un puntatore ristretto e poi per il tramite di tale alias viene modificato l’oggetto puntato, avremo un comportamento non definito.

Snippet 7.26 Puntatori ristretti.

int value = 100;

int *restrict ptr_to_value_1 = &value;

// ATTENZIONE *ptr_to_value_2 è un alias di *ptr_to_value_1
int *restrict ptr_to_value_2 = ptr_to_value_1;

// ciò potrà causare un comportamento non definito
*ptr_to_value_2 = 1000;

Conversioni e puntatori

Anche i tipi puntatore, così come gli altri tipi di oggetti, sono soggetti a delle regole di conversione quando utilizzati nelle comuni operazioni di assegnamento, inizializzazione o confronto con altri tipi di puntatori oppure altri tipi di oggetti o costanti.

La Tabella 7.1 ne dà un riepilogo dove: la colonna T.O.S. (tipo operando a sinistra) indica il tipo posto a sinistra dell’operatore usato; la colonna T. O. D. (tipo operando a destra) indica il tipo posto a destra dell’operatore usato; la colonna Risultato indica se l’operazione dà un risultato definito (ossia se è fattibile senza problemi), non definito oppure dipendente dalla corrente implementazione.

Tabella 7.1 Conversioni tra puntatori, oggetti e valori costanti.
T. O. S. T. O. D. Risultato
puntatore a un tipo T void * Definito
void * puntatore a un tipo T Definito
puntatore a funzione di tipo T void * Comportamento non definito
void * puntatore a funzione di tipo T Comportamento non definito
puntatore a un tipo T 0 Definito
puntatore a un tipo T qualsiasi intero Definito dall’implementazione
puntatore a funzione di tipo T 0 Definito
puntatore a funzione di tipo T qualsiasi intero Definito dall’implementazione
qualsiasi intero puntatore a un tipo T Definito dall’implementazione1
qualsiasi intero puntatore a funzione di tipo T Definito dall’implementazione1
puntatore a un tipo T puntatore a un tipo W Definito2
puntatore a un tipo T puntatore a un tipo T Definito
puntatore a funzione di tipo T puntatore a funzione di tipo W Definito3
puntatore a funzione di tipo T puntatore a funzione di tipo T Definito
1 Se però il risultato della conversione non può essere rappresentato in un tipo intero, allora il comportamento sarà non definito.
2 Se però il puntatore risultante non è correttamente allineato in memoria per il tipo referenziato allora, se riconvertito nuovamente, il risultato sarà non definito. Altrimenti, se l’allineamento è corretto e il puntatore è riconvertito, il puntatore risultante dovrà essere uguale al puntatore originario (Snippet 7.27).
3 Se però il puntatore della funzione di tipo T è usato per invocare la funzione riferita di tipo W, che non è quindi del suo stesso tipo, il comportamento sarà non definito. In più, dato un puntatore a una funzione di tipo W convertito in un puntatore a una funzione di tipo T, se il puntatore alla funzione di tipo T viene convertito nuovamente nel puntatore alla funzione di tipo W, vi dovrà essere la garanzia che il puntatore risultante sarà uguale al puntatore a funzione originario, e dunque il codice invocato sarà quello della funzione corretta.

Snippet 7.27 Eventuale problema di allineamento tra puntatori a tipi differenti.

char c = 'A';

// conversione tra un puntatore a un char e un puntatore a un int
// potrebbe esserci perdita di informazione...
int *ip = (int*) &c;

// riconversione del puntatore a un int nel puntatore a un char
// non è detto che *cp ritorni il carattere 'A'
// in alcune implementazioni cp potrebbe non essere uguale a &c
char *cp = (char*) ip;

// b = true o b = false a seconda dell'implementazione!
_Bool b = cp == &c;

TERMINOLOGIA

Lo standard di C11 fornisce le seguenti indicazioni terminologiche, laddove per behavior intende quel comportamento che un programma può intraprendere a seguito dell’utilizzo dei costrutti del linguaggio C e per implementation intende l’ambiente software (compilatore, linker, sistema di run-time e così via) utilizzato per una particolare piattaforma hardware.

Undefined behavior (comportamento non definito): indica il comportamento di un programma a seguito di codice non portabile o erroneo per cui lo standard non impone alcun requisito in particolare. Ciò significa che un programma: può ignorare la situazione producendo però risultati non prevedibili; può segnalare il problema durante la compilazione o esecuzione; può terminare bruscamente e così via. In pratica, poiché qualsiasi cosa può accadere, è buona norma evitare sempre di scrivere codice che può produrre comportamenti non definiti.

Unspecified behavior (comportamento non specificato): indica che un programma può “scegliere” di comportarsi sulla base di un set di comportamenti definiti dallo standard. Per esempio, un comportamento non specificato si ha sull’ordine di valutazione degli argomenti di una funzione laddove, data una funzione invocata come foo(a, b), potrebbe essere valutato prima l’argomento b e poi l’argomento a o viceversa. Resta inteso che se gli argomenti di una funzione producono side-effect, e pertanto l’ordine di valutazione è importante, allora il codice eseguibile può produrre bug non facilmente individuabili. Anche in questo caso, quindi, è bene evitare di scrivere codice che può produrre comportamenti non specificati.

Implementation-defined behavior (comportamento definito dall’implementazione): indica che un programma può intraprendere uno dei comportamenti non specificati ma l’implementazione ne documenta la scelta. In pratica, il comportamento di un programma è dipendente dalla corrente implementazione e può, dunque, variare da implementazione a implementazione. Se si devono scrivere programmi portabili è consigliabile evitare di scrivere codice dipendente da una specifica implementazione.

Snippet 7.28 Esempi di conversioni con void *.

...
int sum(int a, int b)
{
return a + b;
}

double foo(void)
{
return 1.0;
}

int main(void)
{
int a = 100;
int b = 200;

// puntatore a un tipo T
int *ptr_to_a = &a;

// puntatore a una funzione di tipo T
int(*ptr_to_f)(int, int) = sum;

// puntatore a un tipo void
void *v_ptr = &b;

// puntatore a un tipo T <---> void *
// DEFINITO
int *a_ptr = v_ptr;

// void * <---> puntatore a un tipo T
// DEFINITO
void *v2_ptr = ptr_to_a;

// puntatore a una funzione di tipo T <---> void *
// COMPORTAMENTO NON DEFINITO
ptr_to_f = v_ptr;

// void * <--- > puntatore a una funzione di tipo T
// COMPORTAMENTO NON DEFINITO
double(*ptr_to_f_2)(void) = foo;
void *v3_ptr = ptr_to_f_2;
...
}

NOTA

Se un puntatore a un tipo T è convertito in un puntatore a void e poi il puntatore a void è convertito nuovamente nel puntatore al tipo T, dovrà essere garantito che non ci sarà alcuna perdita di informazione (in pratica il puntatore risultante dalla riconversione del puntatore a void nel puntatore al tipo T dovrà essere uguale al puntatore originario).

Snippet 7.29 Esempi di conversioni con tipi interi.

...
int sum(int a, int b)
{
return a + b;
}

double foo(void)
{
return 1.0;
}

int main(void)
{
int a = 100;
int b = 200;

// puntatore a un tipo T
int *ptr_to_a = &a;

// puntatore a una funzione di tipo T
int(*ptr_to_f)(int, int) = sum;

// puntatore a un tipo T <---> un int
// DEFINITO DALL'IMPLEMENTAZIONE
int *ptr_to_int = 0x22334455;

// un int <---> puntatore a un tipo T
// DEFINITO DALL'IMPLEMENTAZIONE
int any = ptr_to_a;

// puntatore a una funzione di tipo T <---> un int
// DEFINITO DALL'IMPLEMENTAZIONE
ptr_to_f = 0x66777788;

// un int <---> puntatore a una funzione di tipo T
// DEFINITO DALL'IMPLEMENTAZIONE
double(*ptr_to_f_2)(void) = foo;
int any_2 = ptr_to_f_2;
...
}

Snippet 7.30 Esempi di conversioni tra puntatori a tipi differenti.

int a = 100;
float c = 222.3f;

// puntatore a un tipo T
int *ptr_to_a = &a;

// puntatore a un tipo W
float *ptr_to_c = &c;

// puntatore a un tipo T <---> puntatore a un tipo W
// DEFINITO se allineamento in memoria corretto
ptr_to_a = ptr_to_c;

// ptr_to_a riconvertito nuovamente in ptr_to_c
// se allineamento in memoria corretto *ptr_to_c darà come valore 222.3
// ossia il suo valore in virgola mobile
ptr_to_c = ptr_to_a;

Snippet 7.31 Esempi di conversioni tra puntatori a funzione di tipi differenti.

...
int sum(int a, int b)
{
return a + b;
}

double foo(void)
{
return 1.0;
}

int main(void)
{
// puntatore a una funzione di tipo T
int(*ptr_to_f)(int, int) = sum;

// puntatore a una funzione di tipo W
double(*ptr_to_f_2)(void) = foo;

// puntatore a una funzione di tipo T <---> puntatore a una funzione di tipo W
// DEFINITO
ptr_to_f = ptr_to_f_2;

// ptr_to_f riconvertito nuovamente in ptr_to_f_2
// COMPORTAMENTO 'RIPRISTINATO' ptr_to_f_2 è di tipo (void) -> double
// e referenzia correttamente il codice da invocare
ptr_to_f_2 = ptr_to_f;

// puntatore a funzione di tipo (float, float) -> float che contiene
// un riferimento a una funzione di tipo (int, int) -> int
// DEFINITO
float (*ptr_to_f_3)(float, float) = sum;

// invocazione di sum per il tramite di ptr_to_f_3
// il tipo della funzione riferita è diverso dal tipo della funzione di cui
// l'identificatore ptr_to_f_
// COMPORTAMENTO NON DEFINITO
(*ptr_to_f_3)(5, 6);
...
}