Last update Marzo 1, 2024 11:52 AM

 

 


Personal Website


Articoli / Articles
   

Pagina Iniziale Home Page

Informazioni About Me

Curriculum Vitae Resume
Attività Activities
Didattica Teaching
Guide Tutorials
Articoli Articles
Libri Books

Contatti Contacts
     
     
 
     
 

Puntatori nel linguaggio di programmazione C
di Roberto Saia

Uno degli aspetti che risulta più ostico da comprendere a coloro che per la prima volta si avvicinano al linguaggio C, è certamente quello relativo ai puntatori, un potente strumento reso disponibile dal linguaggio, con il quale è possibile compiere numerose operazioni in modo inusuale rispetto a quanto possibile senza il loro utilizzo, ragione per cui la loro comprensione può risultare inizialmente difficoltosa.

Attraverso i puntatori è possibile svolgere delle operazioni altrimenti impossibili come, ad esempio, gestire un insieme di dati (strutture di dati) la quale dimensione può variare dinamicamente nel corso del programma. Il concetto alquanto semplice che sta alla base dei puntatori è il seguente: un puntatore non è altro che una normale variabile che invece di contenere un valore direttamente utilizzabile, contiene un indirizzo di memoria che punta a questo valore, cioè, l'indirizzo di memoria dove il valore è memorizzato. Mentre con le variabili ci si riferisce direttamente ai valori, con i puntatori questo avviene in modo indiretto. La dichiarazione e l'inizializzazione di un puntatore avviene in modo identico a quanto visto per le variabili, con l'unica differenza che, in questo caso, occorrerà far precedere il nome della variabile dal simbolo * (asterisco):

int *mio_puntatore;

La precedente dichiarazione si riferisce a un puntatore di tipo int, cioè un puntatore a un valore intero denominato mio_puntatore (correttamente, si dirà che mio_puntatore punta a un oggetto di tipo int).

Nota: qualora sulla stessa riga sono dichiarati più puntatori, occorre far precedere ciascun loro nome dal simbolo asterisco, in caso contrario, i nomi privi di asterisco identificheranno delle semplici variabili del tipo specificato.

Una volta che un puntatore viene dichiarato, esso non sarà più distinguibile da una normale variabile a meno che non si utilizzi, come è abitudine di molti programmatori, qualcosa nel nome in grado di distinguerlo dalle comuni variabili come, ad esempio, un particolare prefisso o suffisso: distinguere i puntatori è molto importante, in quanto, trattarli erroneamente come fossero delle normali variabili può ingenerare errori gravi e di difficile individuazione. Un'altra accortezza quando si opera con i puntatori è quella di inizializzarli: questa operazione può essere eseguita sia contestualmente alla loro dichiarazione, sia successivamente mediante un'apposita istruzione. Un puntatore può essere inizializzato con un indirizzo di memoria, con il valore zero o con il valore speciale NULL: quest'ultimo è una particolare costante definita all'interno del file di intestazione stddef.h (anche se non richiamato esplicitamente, questo file viene automaticamente richiamato da numerosi altri file di intestazione come, ad esempio, stdio.h); inizializzare un puntatore con il valore zero oppure NULL (preferibile quest'ultima soluzione), significa indicare che esso non punta a nulla.

Nota: effettuando una semplificazione, la memoria può essere considerata come una sequenza di celle contigue (definite anche locazioni), ciascuna delle quali capace di contenere un certo numero di bit (questi ultimi sono tipicamente gestiti in byte, cioè in gruppi di otto bit); lo spazio fisico della memoria può essere quindi pensato come una sequenza finita di locazioni, ciascuna capace di accogliere un byte; ogni locazione è univocamente distinguibile da un numero (tipicamente, un intero positivo) che consente di riferirsi a essa senza alcuna ambiguità (indirizzamento).


Figura 1 - Variabili e puntatori in memoria.

In Figura 1 è possibile osservare come si relazionano con la memoria le variabili e i puntatori: possiamo osservare che i puntatori sono trattati alla stregua delle variabili ordinarie, cioè vengono allocati in memoria con le stesse modalità; nel primo esempio, osserviamo come l'aver dichiarato due variabili di tipo int a e b, corrisponda a riservare due locazioni di memoria (non necessariamente contigue) capaci di memorizzare i possibili valori che esse potranno assumere durante l'esecuzione del programma; nel secondo esempio, infatti, assegnando il valore 8 alla variabile b, non faremo altro che modificare il contenuto della locazione di memoria a essa corrispondente; l'esempio più interessante è comunque l'ultimo, dove oltre a due comuni variabili di tipo int (a e b), viene dichiarata una variabile di tipo puntatore a intero (c), successivamente inizializzata con il valore della locazione di memoria (indirizzo) corrispondente alla variabile a (sfruttando l'operatore &); questo in pratica equivale a riferirci al contenuto di quest'ultima variabile non in modo diretto bensì indirettamente attraverso il suo indirizzo (in questo caso si dice che c punta a).

Nota: richiamando quanto detto in precedenza in merito alla necessità di inizializzare i puntatori, contestualmente o successivamente alla loro dichiarazione, osserviamo come l'inizializzazione di un puntatore al valore NULL equivalga a scrivere nella relativa locazione di memoria un indirizzo speciale (la costante NULL) indicante il fatto che non si tratta di un indirizzo utilizzabile, mentre lasciare un puntatore non inizializzato, è come lasciare che esso punti verso un indirizzo sconosciuto, con tutti i probelmi derivanti qualora esso venga utilizzato mentre si trova in questo stato.

Il successivo codice permette di verificare praticamente questo scenario, richiamando il valore memorizzato nella variabile a prima direttamente e poi indirettamente attraverso il puntatore c a essa (in questo esempio la variabile a è sttata inizializzata al valore 10) :

#include <stdio.h>

main()
{
int a=10;
int b=8;

int *c;

c=&a;
printf("Valore di a (accesso diretto): %d\n",a);
printf("Valore di a (acceso indiretto): %d\n",*c);

getchar();
}

Osserviamo l'istruzione c=&a, essa sfrutta l'operatore & per assegnare al puntatore c l'indirizzo di memoria della variabile a. All'interno della seconda istruzione printf, utilizziamo l'operatore * (definito operatore di indirezione) per ricavare l'oggetto puntato che, in questo caso, è il valore intero 10 assegnato alla variabile a. Nella successiva Tabella 1 sono brevemente riepilogate le caratteristiche degli operatori & (definito e-commerciale oppure ampersand) e * (asterisco) incontrati durante l'utilizzo dei puntatori.

Tabella 1 - Operatori relativi ai puntatori.

Operatore
Descrizione
*
Operatore unario di indirezione: chiamato anche operatore di deriferimento, restituisce il valore relativo all'oggetto puntato dal suo operando.
&
Operatore unario di indirizzo: restituisce l'indirizzo relativo al suo operando; esso può operare solo nei confronti delle variabili e non con elementi come costanti, espressioni, ecc.

A fini didattici, osserviamo la seguente variazione del precedente esempio, dalla quale è possibile accorgersi come il contenuto della variabile b corrisponda effettivamente all'indirizzo in memoria della variabile a: per farlo abbiamo utilizzato un particolare specificatore di conversione della funzione printf, lo specificatore %p che permette di visualizzare nel formato numerico esadecimale l'indirizzo di memoria al quale un puntatore si riferisce (per quanto poco utile in quel contesto, lo specificatore di conversione %p può essere adoperato anche con la funzione scanf):

#include <stdio.h>

main()
{
int a=10;

int *b;

b=&a;
printf("Valore di a : %d\n",a);
printf("Indirizzo di a : %p\n",&a);

printf("Valore di b : %p\n",b);

getchar();
}

Il risultato (relativamente agli indirizzi di memoria) dipende dall'ambiente nel quale si opera; in quello in uso al momento della stesura di questo testo, esso produceva il seguente risultato:

Valore di a: : 10
Indirizzo di a : 0022FF44
Valore di b : 0022FF44

dimostrando chiaramente quanto fin ora detto in merito ai puntatori. Ovviamente, è possibile fa riferire più puntatori a uno stesso indirizzo di memoria senza alcun problema. Un interessante uso dei puntatori è quello che svincola la loro dichiarazione da uno specifico tipo di dati: in qusto caso il puntatore viene dichiarato come void ed è potenzialmente capace di operare con indirizzi di memoria generici che prescindono da uno specifico tipo di dati.

void ptr_generico;

Una volta che si decidiamo di utilizzare i dati ai quali questo puntatore si riferisce, occorrerà preventivamente trasferire l'informazione relativa all'indirizzo puntato in un altro puntatore di tipo corretto, come è possibile osservare nel seguente esempio:

#include <stdio.h>

main()
{
int a=10;int *b;void *c;

/* Puntatore void all'indirizzo della variabile int a */
c=&a;

printf("Indirizzo di a : %p\n",&a);
printf("Indirizzo di c : %p\n",c);

/* Trasferimento da void a un puntatore di tipo int */
b=c;

printf("Indirizzo di b : %p\n",b);
printf("Contenuto di a : %d\n",*b);

getchar();
}

Tentare di utilizzare direttamente un puntatore di tipo void per risalire all'oggetto puntato, ad esempio mediante una istruzione del genere (dove c è il puntatore di tipo void):

printf("Indirizzo di a : %d\n",*c);

genererà un errore, in fase di compilazione.

Aritmetica dei puntatori
Un interessante aspetto relativo ai puntatori è rappresentato dalla possibilità offerta al programmatore di eseguire su essi delle operazioni di tipo
aritmetico che coinvolgono gli indirizzi di memoria ai quali essi puntano: questa possibilità, unita al fatto che la memoria può essere considerata una lunqa sequela di celle contigue, consente di realizzare alcune particolari operazioni. Una delle possibili operazioni è legata ai vettori: dato che i loro puntatori coincidono con le locazioni di memoria del loro primo elemento, possiamo aritmeticamente calcolare la posizione dei rimanenti elementi conoscendo semplicemente quest'unica informazione e accedere quindi ai valori memorizzati nel vettore in modo indiretto (per puntatore) piuttosto che direttamente attraverso l'indice. Osserviamo il successivo codice, esso mostra come accedere al contenuto di un vettore tramite il suo puntatore:

#include<stdio.h>
main()
{
int *p, mio_vettore[10]={0,1,2,3,4,5,6,7,8,9};

/* Inizializza il puntatore al vettore */
p=mio_vettore;

/* Accede al primo elemento del vettore */
printf("Indirizzo: %p -> Contenuto: %d\n",p,*p);

/* Accede al sesto elemento del vettore */
printf("Indirizzo: %p -> Contenuto: %d\n",(p+5),*(p+5));

/* Accede al settimo elemento del vettore */
p=p+6;printf("Indirizzo: %p -> Contenuto: %d\n",p,*p);

getchar();
}

Dopo aver inizializzato con alcuni valori un vettore di dimensione 10, inizializziamo il puntatore a int denominato p con il nome del vettore che, come ben sappiamo, corrisponde all'indirizzo in memoria del suo primo elemento; successivamente visualizziamo l'indirizzo e il contenuto di questo che sarà, appunto, il valore del primo elemento (zero). Nelle successive due operazioni, prima di effettuare la visualizzazione dell'elemento puntato da p, incrementeremo questo puntatore (cioè l'indirizzo da esso puntato), rispettivamente, di 5 e 6 unità. Una differenza sostanziale contraddistingue, però, la penultima e l'ultima operazione aritmetica, in quanto, nella prima, pur visualizzando il contenuto relativo all'elemento distante 5 locazioni dal puntatore, il valore di quest'ultimo rimane inalterato; nella seconda, invece, scrivendo p=p+6, il valore verrà permanentemente modificato e quindi, al suo successivo utilizzo, esso non punterà più al primo elemento del vettore, bensì al settimo.

Nota: operando aritmeticamente con i puntatori occorre fare molta attenzione per evitare di sconfinare al di fuori delle aree di memoria previste, in quanto, appare evidente come, in caso di errore, si possano puntare degli indirizzi di memoria non validi con conseguenze imprevedibili.

Così come avviene in altri contesti, agli operatori aritmetici principali è possibile affiancare un certo numero di operatori secondari in modo da dare vita a nuovi tipi di operazioni, operazioni che sono riassunte sinteticamente nella seguente Tabella 2.

Tabella 2 - Operazioni aritmetiche con i puntatori.

Operazione
Descrizione
p+n
Operatore principale di somma, addiziona il valore intero n al puntatore.
p-n
Operatore principale di sottrazione, sottrae il valore intero n dal puntatore.
p++
Operatore derivato di autoincremento, incrementa il puntatore di una unità.
p--
Operatore derivato di autodecremento, decrementa il puntatore di una unità.
p+=n
Operatore derivato di assegnamento e somma, equivale a scrivere p=p+n.
p-=n
Operatore derivato di assegnamento e differenza, equivale a scrivere p=p-n.

Un aspetto importante che è possibile scorgere attraverso l'esecuzione del successivo esempio, ricavato dal precedente, è legata alla dimensione dei dati: notiamo infatti come la distanza (rappresentata in esadecimale) delle locazioni di memoria corrispondenti agli elementi del vettore mio_vettore sia 4 byte, cioè la dimensione degli int nel sistema in uso.

#include<stdio.h>

main()
{
int i,*p, mio_vettore[10]={0,1,2,3,4,5,6,7,8,9};
p=mio_vettore;

for(i=0;i<10;i++) printf("Indirizzo: %p -> Contenuto: %d\n",p+i,*(p+i));
getchar();
}

segue l'output del precedente programma:

Indirizzo: 0022FF30 -> Contenuto: 0
Indirizzo: 0022FF34 -> Contenuto: 1
Indirizzo: 0022FF38 -> Contenuto: 2
Indirizzo: 0022FF3C -> Contenuto: 3
Indirizzo: 0022FF40 -> Contenuto: 4
Indirizzo: 0022FF44 -> Contenuto: 5
Indirizzo: 0022FF48 -> Contenuto: 6
Indirizzo: 0022FF4C -> Contenuto: 7
Indirizzo: 0022FF50 -> Contenuto: 8
Indirizzo: 0022FF54 -> Contenuto: 9

 

Sebbene il contenuto delle guide, seppur in forma sintetica e/o adattata al contesto, sia tratto dai miei libri/eBook, l'occasionalità e i rapidi tempi di stesura che ne caratterizzano la pubblicazione non garantiscono l'assenza di errori e/o omissioni.

Ritorna all'indice dei tutorial Index


 
Roberto Saia  -  All Rights Reserved