Comprendere i puntatori C/C++ • Puntatori I

C

 

COMPRENDERE I PUNTATORI C/C++

Chiunque frequenti un corso di studi che comprende linguaggi come C/C++ si troverà prima o poi ad affrontare il “problema” delle variabili puntatore.
È essenziale prendere confidenza con questo potente strumento, comprenderne appieno i meccanismi e sfruttare al meglio le possibilità che ci offre per scrivere codice di qualità.
I puntatori sono di fondamentale importanza per un’efficiente gestione della memoria, specialmente nei sistemi embedded dove le risorse disponibili sono decisamente limitate.
In C, dove a differenza del C++ non esiste un tipo predefinito per le stringhe, giocano un ruolo centrale nella gestione delle stesse. Inoltre la scelta di non utilizzare i puntatori preclude la possibilità, in entrambi i linguaggi, di allocare memoria dinamicamente (a run-time) in tutti quei casi dove non è possibile conoscere a priori il quantitativo di memoria necessario per un determinato compito.

Qual è quindi lo scopo di questa serie di articoli? Sebbene l’argomento puntatori sia veramente vasto, l’obiettivo non si discosta da quello dell’intero blog: essere un punto di riferimento su questo soggetto, facendo luce sulla maggior parte degli aspetti legati ad esso.

Non nascondendo un pizzico di ambizione, vi auguro buona lettura.

 

LE VARIABILI

Prima di addentrarci nel concetto di puntatore analizziamo meglio il significato di variabile.
Essa rappresenta, o astrae, una ben precisa zona della memoria, identificando un tot. di byte in base al tipo della stessa. Per conoscere l’esatto quantitativo di byte, utilizzati per un determinato tipo, è disponibile l’operatore ‘sizeof()’.
La sintassi da utilizzare è:  sizeof( <tipo> ); , con <tipo> uguale a uno qualsiasi dei tipi nativi, o uguale a un tipo creato dal programmatore stesso mediante l’uso di typedef, struct o class.

sizeof( int );

sizeof( long double );

typedef char myType_t;
sizeof( myType_t );

Il tipo del valore restituito è ‘size_t’. Esso corrisponde a un numero intero senza segno, perciò sempre positivo. L’idea non dovrebbe sorprenderci poiché è impossibile che la dimensione di un qualsiasi elemento sia minore di zero. La corrispondenza tra ‘size_t’ e un tipo nativo, come ‘unsigned int’ oppure ‘unsigned long’, dipende dall’implementazione.
Nel caso in cui, ad esempio, l’operazione sizeof( int ); restituisca come valore 4, possiamo affermare con certezza che la nostra architettura utilizza 4 byte per memorizzare un numero intero con segno.
Perciò un’istruzione come  int number = 16843009; riserverà nella memoria 4 byte per memorizzare il valore 16843009. Il programmatore non si deve preoccupare di quale è la locazione effettiva di questi 4 byte nella memoria: sarà il compilatore a trasformare le varie operazioni sulla variabile ‘number’ in operazioni di lettura e scrittura nei 4 byte che la costituiscono.

In genere, indipendentemente dal tipo dell’architettura, ogni singolo byte nella memoria ha uno specifico indirizzo. Cosa possiamo dire invece della nostra variabile ‘number’? Essendo formata da 4 byte con i rispettivi indirizzi, quale di essi viene scelto per rappresentare l’indirizzo della variabile stessa? Le regole da seguire nell’allineamento in memoria delle strutture di dati, sulle quali non ci soffermiamo, impongono che l’indirizzo in memoria di un dato composto da più byte contigui sia esattamente l’indirizzo in memoria del byte iniziale del dato stesso.

Vediamo di chiarire meglio questo concetto.

La conversione da decimale a esadecimale del numero 16843009 è pari a 01010101.
Prendiamo la seguente tabella come esempio, dove ognuno dei 4 byte usati per memorizzare un ‘int’ conterrà il solito valore (01 in esadecimale, 00000001 in binario):

Indirizzo in memoria Valore contenuto (HEX) Valore contenuto (BIN)
0072fe3c 01 00000001
0072fe3d 01 00000001
0072fe3e 01 00000001
0072fe3f 01 00000001

Essendo oo72fe3c l’indirizzo del byte iniziale, per la regola espressa poc’anzi è anche l’indirizzo della variabile ‘number’. Per conoscere l’indirizzo di una qualsiasi variabile il C/C++ mette a disposizione un operatore apposito: ‘&’.
Nel nostro esempio ‘number’ sarà pari a 16843009 e ‘&number’ sarà pari a 0072fe3c.

Tale ragionamento è valido a prescindere dal tipo della variabile number: qualsiasi sia il numero dei byte che la compongono, solo l’indirizzo del byte iniziale verrà tenuto in considerazione.
Questo, al contrario di quello che si potrebbe pensare, risalta l’importanza dell’uso dei tipi: quando sarà interpellata la variabile ‘number’ nel nostro programma, l’unico indizio per il compilatore riguardo al numero di byte da considerare come facenti parti della variabile, a partire da quello iniziale, è dato dal tipo della stessa.

 

IL FAMIGERATO PUNTATORE

Abbiamo quindi tutti gli elementi per definire il concetto di variabile puntatore: un tipo particolare di variabile adatta a memorizzare l’indirizzo di una locazione di memoria, ovvero adatta a memorizzare l’indirizzo di un’altra variabile, che prenderà il nome di variabile puntata, sfruttando il meccanismo dei tipi esposto poco fa.
In questo modo potremmo interagire con il valore contenuto nella variabile puntata sia attraverso il suo identificatore (il suo nome) come siamo abituati, sia tramite la variabile puntatore che la punta.

In alcune architetture un indirizzo in memoria è composto da 4 byte, mentre in altre raggiunge gli 8 byte. Questa sarà l’esatta dimensione di una qualsiasi variabile puntatore, poiché, ripetiamo, indipendentemente dal numero di byte che compongono una normale variabile, è quello iniziale il byte fondamentale, ed è il suo indirizzo che rappresenta l’indirizzo dell’intera variabile.
Non è però possibile, illusi dall’eguale dimensione, avere un tipo di puntatore generico, adatto quindi a qualsiasi tipo di variabile. La certezza di andare ad interagire sul corretto numero di byte a partire da un solo indirizzo si basa sull’obbligo categorico di fornire la nostra variabile puntatore di un tipo, come siamo soliti fare per le variabili normali.

Nel nostro esempio, essendo ‘number’ una variabile di tipo ‘int’, questo significa memorizzare 0072fe3c in una variabile puntatore ad interi con segno.

 

LA SINTASSI

Iniziamo quindi ad analizzare la sintassi per la creazione di una variabile puntatore; vedremo in seguito le istruzioni necessarie per inserirvi un indirizzo ed agire sulla variabile presente in memoria a quell’indirizzo.

La dichiarazione, con conseguente definizione, di una variabile puntatore consiste nell’aggiungere il simbolo di asterisco ‘*’ tra il tipo della variabile e il suo identificatore:
<tipo> * <identificatore>

Le seguenti sono valide dichiarazioni di variabili puntatore:

int* pointerToInteger;

float * pointerToFloat;

char *pointerToChar;

                                                                                                                                    DICHIARAZIONE, DEFINIZIONE E INIZIALIZZAZIONE

Abbiamo accennato alla definizione poiché viene subito allocato spazio in memoria anche per questo tipo di variabile, la quale conterrà quindi un valore casuale, ovvero un indirizzo casuale.
È necessario prestare particolarmente attenzione a questo aspetto, poiché se si tentasse di utilizzare, seguendo la sintassi che spiegheremo a breve, una variabile puntatore in questo stato, andremmo ad interagire con una zona di memoria sconosciuta, molto probabilmente esterna al programma stesso, con effetti disastrosi a run-time.
Il pericolo maggiore è la situazione in cui il nostro programma non presenta alcun problema evidente, come ad esempio un’interruzione da parte del Sistema Operativo, ma mostra un comportamento inaspettato per via di un uso improprio di una o più locazioni di memoria.

Come ovviare a questo problema? Abbiamo due alternative:

  • inizializzare la variabile puntatore con un indirizzo valido in fase di dichiarazione
  • inizializzare la variabile puntatore con un indirizzo non valido, ma che se usato per errore non abbia effetti collaterali se non il crash della nostra applicazione

Analizziamo innanzitutto la prima soluzione mediante le seguenti righe di codice:

// prima creo e inizializzo la variabile number con il valore 16843009
int number = 16843009;

// poi associo alla variabile puntatore pointerToInteger l’indirizzo della variabile number
int * pointerToInteger = &number;

Perciò, dopo aver creato una variabile intera ‘number’, essendo il suo indirizzo in memoria un indirizzo valido, lo memorizzo nella variabile puntatore dello stesso tipo ‘pointerToInteger’. Ricordate l’operatore ‘&’ introdotto qualche riga sopra? Esso estrapola e restituisce l’indirizzo di una qualsiasi variabile, nel nostro caso l’indirizzo della variabile ‘number’.
Considerando ancora valido il nostro esempio, possiamo affermare che, da ora in poi, la variabile puntatore ‘pointerToInteger’ conterrà il valore 0072fe3c.

La seconda soluzione consiste nell’inizializzare la variabile puntatore con il valore ‘NULL’ se stiamo scrivendo codice C, mentre con il valore ‘nullptr’ se stiamo scrivendo codice C++.
Il nostro puntatore prenderà quindi il nome di puntatore nullo.
Le problematiche dell’uso della macro ‘NULL’ che hanno portato, nel C++, alla creazione di un tipo adatto per la keyword ‘nullptr’ esulano dall’argomento di questo articolo. È sufficiente paragonare questo valore a 0, anche se più propriamente esso è un valore diverso da qualsiasi puntatore valido. Potrebbe essere 0 e, letteralmente parlando, qualsiasi operazione di lettura o scrittura effettuata sui byte iniziali della memoria (dall’indirizzo 00000000 in poi) viene bloccata, con conseguente crash della nostra applicazione, dal Sistema Operativo, poiché in essi vengono memorizzati dei dati di proprietà dell’OS stesso. Questo viene definito errore di segmentazione, poiché si tenta di accedere a una zona della memoria proibita.

La condizione if(pointer) verrà valutata falsa solo ed esclusivamente se “pointer” è un puntatore nullo.

// codice C
int * pointerToInteger = NULL;

// codice C++
char * pointerToChar = nullptr;

Dopo aver fatto questo, appena sarà necessario nella stesura del programma, potremmo modificare l’indirizzo contenuto nella nostra variabile puntatore assegnandogliene uno valido. Prestiamo attenzione alla sintassi.

// creo una variabile puntatore nulla
char * pointerToChar = nullptr;

// creo una variabile carattere
char character = ‘C’;

// memorizzo in pointerToChar l’indirizzo della variabile character
pointerToChar = &character;

È degno di nota il fatto che, essendo ‘pointerToChar’ a tutti gli effetti una variabile, per modificare il valore che contiene, ovvero per modificare l’indirizzo memorizzato in essa, NON è da utilizzare il simbolo di asterisco ‘*’.

L’unica eccezione è presente, come abbiamo già visto, in fase di dichiarazione, dove siamo obbligati a usare il simbolo asterisco per indicare che la variabile sarà di tipo puntatore, ma dove è anche possibile inizializzare il valore del puntatore con un qualsiasi indirizzo.

 

SEMPLICI OPERAZIONI SULLE VARIABILI PUNTATORE

Il divieto di utilizzare il simbolo di asterisco è valido anche per leggere l’indirizzo memorizzato in essa, ovvero l’indirizzo della variabile ‘character’, come potrebbe accadere in un listato simile al seguente codice C, dove lo stampiamo a video:

// codice precedente
char character = ‘C’;
char * pointerToChar = &character;

// %p è lo specificatore di formato per le variabili puntatore
printf("%p\n", pointerToChar);

Lo stesso dicasi per l’operazione di assegnamento. Nel momento in cui decido di creare una seconda variabile puntatore a carattere, posso inizializzarla con il valore contenuto nella variabile puntatore pointerToChar:

// codice precedente
char * pointerToChar = nullptr;
char character = ‘C’;
pointerToChar = &character;

// creo un’altra variabile puntatore a carattere e la pongo uguale a pointerToChar
char * anotherPointerToChar = pointerToChar;

Da ora in poi entrambe le variabili puntatore conterranno il medesimo indirizzo, perciò punteranno alla medesima variabile.
Essendo variabili puntatore distinte, una modifica al valore contenuto in una delle due non avrà affetti nell’altro:

// codice precedente
char * pointerToChar = nullptr;
char character = ‘C’;
pointerToChar = &character;
char * anotherPointerToChar = pointerToChar;

// imposto pointerToChar a nullptr
pointerToChar = nullptr;

Avendo impostato ‘pointerToChar’ a puntatore nullo, esso cesserà di puntare la variabile ‘character’. Invece ‘anotherPointerToChar’ continuerà a memorizzarne l’indirizzo.

 

OPERAZIONI VIETATE

Per rimanere in linea con il meccanismo dei tipi esposto qualche paragrafo sopra, non è possibile assegnare l’indirizzo di una variabile a un puntatore di tipo differente dalla variabile stessa, ne è possibile assegnare l’indirizzo memorizzato in un puntatore in uno di tipo differente.

// codice precedente
char character = ‘C’;
char * pointerToChar = &character;

// creo una variabile di tipo puntatore ad interi
int * pointerToInteger = nullptr;

// istruzione errata: assegno l’indirizzo di una variabile a un puntatore di tipo differente
pointerToInteger = &character;

// istruzione errata: assegno il contenuto di una variabile puntatore in una di tipo differente
pointerToInteger = pointerToChar;

                                                                                                                                                       CAST DI TIPO PUNTATORE E PROBLEMATICHE

In verità le precedenti operazioni diventano possibili tramite il CAST per il tipo puntatore, ma rimangono operazioni decisamente pericolose.

Riprendiamo la nostra cara variabile ‘number’ per spiegare alcuni dei possibili problemi. Il suo indirizzo è sempre 0072fe3c. Il linguaggio offre la certezza di andare ad operare esattamente sui 4 byte, a partire da 0072fe3c incluso, che compongono ‘number’, solo se utilizziamo il suo identificatore oppure, utilizzando una sintassi che dobbiamo ancora analizzare, se utilizziamo una variabile puntatore, dello stesso tipo, che ne contiene l’indirizzo.

Tramite il cast possiamo, in poche parole, far interpretare l’indirizzo 0072fe3c come indirizzo di una variabile di un qualsiasi tipo.

Interpretarlo come l’indirizzo di una variabile di tipo ‘char’, composta da un solo byte,

int number = 16843009;
char * pointerToChar = (char*) &number;

e quindi leggere e scrivere solo sul byte all’indirizzo 0072fe3c, utilizzando ‘pointerToChar’, non comporterebbe grossi problemi.
Sarebbe disastroso interpretarlo, ad esempio, come una variabile di tipo ‘long long int’.

int number = 16843009;
long long int * pointerToLongLongInteger = (long long int*) &number;

Ipotizziamo che l’operazione sizeof(long long int);dia come risultato 8. Questo significherebbe leggere e scrivere, utilizzando ‘pointerToLongLongInteger’, sia sui 4 byte giustamente allocati per la variabile ‘number’, che è di tipo ‘int’, sia sui 4 byte successivi.

Lascio a voi immaginare le conseguenze. Anzi, no. Nel caso fortunato il sistema operativo ci interrompe l’applicazione, perché, ad esempio, quei 4 byte non sono parte della memoria di competenza del nostro programma (anche questo è un errore di segmentazione), oppure perché viene riscontrato un problema di allineamento dei puntatori, del quale potremmo avere occasione di parlare in futuro. Nel caso peggiore una o più variabili nel nostro programma presenterebbero valori decisamente sballati, con il programma che prosegue imperterrito nella sua esecuzione.

Evidenziamo il fatto che stiamo analizzando il cast riguardante solo ed esclusivamente il tipo puntatore. Il cast tra variabili non puntatore di tipo diverso, che comporti o meno perdita di informazioni, rimane del tutto sicuro dal punto di vista della memoria.

 

LA DEREFERENZIAZIONE

Rimane quindi da esaminare la sintassi che ci permette di interagire direttamente (leggere e/o scrivere) con una variabile sfruttando il puntatore che ne ha memorizzato l’indirizzo. In questo caso entra in gioco l’operatore asterisco ‘*’ di dereferenziazione, o indirezione:

// creo una variabile intera e la inizializzo con il valore 10
int number = 10;

// creo un puntatore ad interi e lo inizializzo con l’indirizzo della variabile intera
int * pointerToInteger = &number;

// stampo il valore di number leggendolo tramite il puntatore: stampa 10
printf(“%d\n”, *pointerToInteger);

// modifico il valore di number tramite il puntatore
*pointerToInteger = 11;

// stampo il nuovo valore di number: stampa 11
printf(“%d\n”, number);

/* creo una seconda variabile intera e la inizializzo
 * con il valore della prima variabile intera, ovvero 11.
 * il valore viene letto tramite il puntatore */
int anotherNumber = *pointerToInteger;

La sintassi da rispettare è perciò *<identificatore puntatore>.
La utilizzo sia nei casi in cui voglio leggere il valore della variabile puntata,

printf(“%d\n”, *pointerToInteger);
int anotherNumber = *pointerToInteger;

sia nei casi in cui voglio modificare questo valore.

*pointerToInteger = 11;

L’unica eccezione rimane in ambito di dichiarazione del puntatore stesso: il simbolo di asterisco è obbligatorio per indicare che la variabile sarà di tipo puntatore. Perciò se decidiamo di inizializzare il puntatore nella medesima istruzione, ci riferiremo al contenuto della variabile puntatore nonostante compaia il simbolo asterisco.

Ovviamente, come abbiamo ampiamente spiegato, scrivere, e in alcuni casi leggere, su delle locazioni di memoria non idonee, tramite puntatori contenenti indirizzi non validi, porterà inesorabilmente a una serie di errori e problematiche più o meno evidenti e più o meno gravi.

 

CONCLUSIONE

Siamo quindi giunti alla fine di questo articolo. Abbiamo più che sufficientemente introdotto l’argomento puntatori, analizzando anche alcune delle possibili problematiche legate al loro uso. Non lasciatevi trarre in inganno dai semplici esempi comparsi fino ad ora, i quali potrebbero far apparire i puntatori superflui, se non del tutto inutili. Come anticipato all’inizio di questo articolo, i puntatori sono uno strumento veramente potente, e nei successivi articoli studieremo un buon numero di casi dove il loro uso è fondamentale.

 

[Scarica l’articolo in formato PDF]

 

A proposito di me

Andrea Simone Costa

Classe 1997, Toscano DOCP, Asimov oriented.
Si è diplomato come perito elettronico, ma ha ben presto tradito le origini per immergersi nell'universo JavaScript. Ama condividere ciò che ha imparato in modo semplice e pragmatico, senza mai ricadere nel banale, coltivando segretamente il sogno di insegnare.

Gli articoli più letti

Articoli recenti

Commenti recenti