Aritmetica dei puntatori, questa sconosciuta • Puntatori III

A

 

Aritmetica dei puntatori, QUESTA SCONOSCIUTA

Nel precedente articolo abbiamo iniziato ad analizzare alcune situazioni nelle quali l’uso dei puntatori è indispensabile per poter scrivere un codice efficiente, sia dal punto di vista della memoria utilizzata che del tempo impiegato nell’esecuzione di determinati compiti. In questo articolo esamineremo a fondo l’aritmetica dei puntatori, in modo da avere delle solide basi per gestire array e stringhe C-like, sebbene tratteremo queste ultime nel prossimo articolo.

 

L’ARITMETICA DEI PUNTATORI

Sulle variabili puntatore è possibile applicare un’aritmetica tanto semplice quanto fonte di errori e confusione. Le uniche tre operazioni effettuabili sono le seguenti:

  • Somma tra puntatore e numero intero: il risultato è un puntatore dello stesso tipo.
  • Differenza tra puntatore e numero intero: il risultato è un puntatore dello stesso tipo.
  • Differenza tra due puntatori dello stesso tipo: il risultato è un numero intero.

Inoltre due puntatori dello stesso tipo possono essere confrontati per determinare se puntano o meno alla stessa variabile. Nello specifico gli operatori relazionali (==, !=, <, >, <= e >=) lavorano correttamente.
In un array è possibile determinare, ad esempio, se una varabile puntatore punta ad un membro che precede o segue quello puntato da un secondo puntatore.
È possibile anche valutare se un puntatore è nullo, confrondandolo con “NULL” oppure “nullptr” in base al linguaggio utilizzato.

Incrementare o decrementare un puntatore di un qualsiasi numero intero provocherà risultati diversi in base al tipo del puntatore in questione. Se una variabile necessita di n byte per essere memorizzata, ricordando che l’indirizzo di una variabile corrisponde all’indirizzo del byte iniziale, è ragionevole aspettarsi che l’indirizzo della successiva variabile sia almeno n byte maggiore del precedente (o almeno n byte minore nel caso della variabile precedente). Il puntatore, se incrementato o decrementato, dovrà quindi scorrere almeno n byte.

Può accadere di dover scorrere un numero maggiore a n di byte poiché, per ragioni di efficienza, una variabile di tipo non nativo potrebbe necessitare di occupare qualche byte in più. Byte utilizzati non per memorizzare una qualche informazione, ma solo ed esclusivamente come offset per rispettare le regole dell’allineamento dei dati nella memoria.
L’operazione sizeof() applicata al tipo di questa variabile avrà come risultato il numero di byte utilizzati in totale, comprendendo quindi i byte di offset.

Per fare un esempio, consideriamo un puntatore a “int” e un puntatore a “short int” che puntano al medesimo indirizzo. Evidenzio la parola esempio poiché esso corrisponde a una pericolosa gestione dei puntatori, con l’unico scopo di chiarire le prime due operazioni concesse su questo particolare tipo di variabile. Utilizziamo un’architettura dove il tipo “int” occupa 4 byte per la sua memorizzazione, mentre il tipo “short int” ne occupa 2.
Entrambi i puntatori verranno incrementati di una sola unità. L’aritmetica dei puntatori imporrà al puntatore a “int” di spostarsi di 4 byte in avanti, mentre imporrà al puntatore a “short int” di spostarsi di soli 2 byte in avanti.

#include <iostream>
  
int main()
{
    int number = 10;
  
    // tramite un operazione di cast costringo
    // i due puntatori a memorizzare il medesimo indirizzo
    int * pointerToInt = &number;
    short int * pointerToShortInt = (short int*) pointerToInt;

    // in questa architettura un int occupa 4 byte in memoria,
    // mentre uno short int ne occupa 2
    // esempio di stampa: 4 - 2
    std::cout << sizeof(int) << " - " << sizeof(short int) << std::endl;

    // stampo i due indirizzi e verifico la loro uguaglianza
    // esempio di stampa: 0x72fe3c - 0x72fe3c
    std::cout << pointerToInt << " - " << pointerToShortInt << std::endl;
  
    // incremento entrambi i puntatori di una unità
    pointerToInt++; 
    pointerToShortInt++;
    
    // pointerToInt si aspetta di trovare un int 4 byte più avanti
    // esempio di stampa: 0x72fe40 uguale al precedente indirizzo (0x72fe3c) + 4
    std::cout << pointerToInt << std::endl;
    
    // pointerToShortInt si aspetta di trovare uno short int 2 byte in avanti
    // esempio di stampa: 0x72fe3e uguale al precedente indirizzo (0x72fe3c) + 2
    std::cout << pointerToShortInt << std::endl;
}

L’aritmetica dei puntatori è fortemente legata all’allineamento in memoria degli stessi, quindi all’allineamento dei dati puntati. Un dato si dice allineato quando il suo indirizzo è un multiplo del numero di byte che lo compongono, considerando anche eventuali byte di offset. Tutti i dati presenti in memoria devono essere allineati.

Questo significa che le operazioni di lettura e/o scrittura di un valore di tipo “int” partendo dal byte con indirizzo 0x72fe3c, oppure partendo da 0x72fe40, sarebbero considerate accessi validi alla memoria. Mentre tentare anche solo di interpretare (leggere) un valore di tipo int partendo, ad esempio, dall’indirizzo 0x72fe3d (immediatamente successivo al valido 0x72fe3c), verrebbe considerato come errore di allineamento.
Se incrementando o decrementando un puntatore a interi, contenente un indirizzo valido, di una unità ci si spostasse effettivamente di un solo byte, ogni tentativo di lettura e/o scrittura tramite il nuovo indirizzo produrrebbe un errore a run-time. L’aritmetica dei puntatori supplisce a questa necessità, evidenziando all’estremo l’utilità dell’uso dei tipi.

È possibile quindi modificare il valore di un puntatore tramite le seguenti istruzioni:

// post e pre incremento
puntatore++;
++puntatore;

// post e pre decremento
puntatore--;
--puntatore;

// incremento e decremento
puntatore += n; // con n numero intero
puntatore -= n; // con n numero intero
puntatore = puntatore + n; // con n numero intero
puntatore = puntatore - n; // con n numero intero

L’ultima operazione da analizzare è quella riguardante la differenza tra due puntatori dello stesso tipo. Essa restituisce la distanza, calcolata in numero di elementi (variabili), tra i due puntatori. Ovviamente la distanza tra due puntatori contenenti il medesimo indirizzo equivale a 0. L’esempio seguente chiarisce il concetto:

#include <iostream> 
  
int main()
{
    int digits[10] = {0,1,2,3,4,5,6,7,8,9};
  
    // creo un puntatore a interi e lo inizializzo con l'indirizzo
    // del terzo valore intero presente nell'array digits
    int * pointerToInt = &digits[2];
    
    // creo un secondo puntatore a interi e lo inizializzo con l'indirizzo
    // dell'ottavo valore presente nell'array digits
    int * anotherPointerToInt = &digits[7];

    // pointerToInt punterà quindi al numero 2
    // anotherPointerToInt punterà invece al numero 7

    // quanti elementi separano i due puntatori? 
    std::cout << anotherPointerToInt - pointerToInt << std::endl; 
}

La risposta è 5. I due puntatori sono di tipo “int*”, perciò le due variabili di tipo “int” puntate da essi sono separate da 5 elementi sempre di tipo “int”. Il calcolo viene effettuato contando il numero di elementi presenti nel seguente intervallo: [pointerToInt : anotherPointerToInt).
Più in generale, sottraendo un puntatore “p” da un altro puntatore “q”, eseguendo quindi l’operazione “q-p”, il risultato è il numero di elementi presenti nell’intervallo [p : q).

È bene sapere che l’aritmetica dei puntatori, specialmente l’operazione di sottrazione tra essi, è definita solo finché i puntatori puntano allo stesso blocco di memoria allocato, come ad esempio un array. È garantita anche per il primo elemento oltre la fine di un array, utile per molti algoritmi, tenendo a mente che tale elemento non è parte dell’array e quindi le operazioni di lettura e/o scrittura tramite dereferenziazione sono vietate (da evitare).
Il primo esempio riportato in questo articolo trasgredisce chiaramente queste regole e non è assolutamente da prendere come modello.

Quale è il rischio? Generare e assegnare, ad esempio con un operazione di post-incremento, a un puntatore un indirizzo che, anche se rispettasse le regole sull’allineamento dei dati (e non è assolutamente garantito), non è comunque valido per altri motivi. In alcuni casi non è nemmeno necessario dereferenziare tale indirizzo per ottenere un errore a run-time, è sufficiente averlo assegnato a un puntatore.

 

Puntatori VS array

La gestione degli array e dei puntatori da parte del C/C++ permette di affermare l’esistenza di un’equivalenza tra i puntatori e gli array. Ad ogni modo, la maggior parte della confusione riguardante questo argomento è data dal fraintendimento di questa assunzione. Affermare che gli array e i puntatori sono equivalenti non significa assolutamente asserire che sono identici né che sono sempre intercambiabili.
Ciò significa invece che un puntatore può essere utilizzato per accedere a un array o per simulare un array. In altre parole, l’aritmetica dei puntatori e l’indicizzazione degli array sono equivalenti, mentre puntatori e array sono differenti.

Il fulcro dell’equivalenza tra le parti è ben espresso nella seguente definizione chiave:

Il riferimento a un oggetto del tipo “array-di-T” che appare in un’espressione viene implicitamente convertito (decade), con alcune eccezioni, in un puntatore al suo primo elemento; il tipo del puntatore risultante è “puntatore-a-T”.

Questa regola rende valido il seguente codice:

// creo un array di interi contenente le cifre decimali
int digits[10] = {0,1,2,3,4,5,6,7,8,9};

// creo un puntatore a interi e lo inizializzo
// con l'indirizzo del primo elemento dell'array
 int * pointerToInt = digits;

Ecco che nell’espressione che inizializza il puntatore a interi, “digits” (il riferimento a un oggetto del tipo “array-di-interi”) viene implicitamente convertito in un puntatore al suo primo elemento, ovvero viene automaticamente convertito in “&digits[0]”.
Naturalmente nulla ci vieta di scrivere direttamente int * pointerToInt = &digits[0];.

Perché è necessaria questa conversione implicita? In realtà, cosa è “digits”?

La dichiarazione dell’array int digits[10] = ... richiede che uno spazio per memorizzare 10 numeri interi venga messo da parte, spazio che verrà identificato col nome “digits”. Ovvero, esisterà una locazione chiamata “digits” dove 10 numeri interi potranno venire memorizzati. L’identificatore “digits”, senza la conversione implicita, si riferirebbe quindi all’intera locazione della memoria.

Le tre principali eccezioni a questo comportamento confermano quanto detto fin’ora:

  • L’operazione sizeof(digits);restituisce la dimensione dell’intero array.
    La possibilità di conoscere la dimensione di un blocco di memoria allocato attraverso il puntatore che ne ha memorizzato l’indirizzo non è contemplata nel C/C++. È risaputo che l’operazione sizeof(puntatore); restituisce la dimensione in memoria del puntatore stesso, ovvero 4 oppure 8 byte. Risultato restituito anche dall’operazione sizeof(&digits[0]);. Perciò se “digits” fosse un puntatore non otterremmo la dimensione dell’intero array.
    È necessario sottolineare questo aspetto poiché è comune presentare il riferimento a un array esattamente uguale all’indirizzo del primo elemento dell’array stesso. Questa uguaglianza è data invece da una conversione implicita che presenta delle eccezioni.
  • L’operazione &digits; restituisce l’indirizzo dell’intero array. Questo particolare puntatore è di tipo “puntatore-ad-array”, e ne vedremo l’utilità in un futuro articolo dove tratteremo l’argomento degli array multidimensionali.
  •  Nel caso in cui l’array in questione fosse un letterale stringa inizializzatore di un array di caratteri, ad esempio "Ciao" inizializzatore di char helloInItalian[], non verrebbe generato alcun puntatore al primo carattere del letterale stringa, bensì ogni singolo carattere inizializzerà una cella dell’array di caratteri. Questo caso verrà ripreso e approffondito nella sezione riguardante le stringhe C-like e la loro gestione tramite i puntatori.

La conversione implicita avviene anche in espressioni come digits++; e digits += n;.
Esse producono un ben preciso errore di compilazione, errore che viene erroneamente interpretato come il tentativo di spostare un puntatore costante. Gli array non sono puntatori costanti. Il legame tra array e puntatori è ben espresso dalla regola generale citata precedentemente. L’errore evidenziato dal compilatore è quello della mancanza di un l-value come operando dell’espressione.
In breve la distinzione tra l-value e r-value:

  • l-value sono oggetti che hanno una precisa locazione in memoria: variabili, variabili costanti e array.
  • r-value sono valori temporanei che non sopravvivono oltre l’espressione che li utlilizza: valori restituiti da un’espressione matematica, valori restituiti da una chiamata a funzione e valori restituiti dalla lettura del contenuto di una variabile.

L’espressione “digits” restituisce il valore dell’indirizzo del primo elemento, valore che può venire liberamente incrementato o decrementato:

int digits[10] = {0,1,2,3,4,5,6,7,8,9};

int * pointerToInt = digits + 5;
int * anotherPointerToInt = digits - 3;

L’oggetto “digits[0]” ha una precisa locazione nella memoria, perciò è un l-value. L’indirizzo di questa locazione può venire restituito tramite l’uso dell’operatore “&”, il quale appunto ne restitutisce il valore (“&digits[0]”). Questo valore non è memorizzato a sua volta in una precisa locazione della memoria; essendo quindi un r-value può venire modificato a piacere, ma non sovrascritto. Ecco che “digits”, dopo essere stato implicitamente convertito in “&digits[0]”, è a tutti gli effetti un r-value.

Esaminiamo quindi nel dettaglio quale è l’effetto dell’operatore paretesi quadre “[]” applicato ad un array e a un puntatore.

È noto che dato un array “a” di n elementi, l’operazione “a[m]” (con 0 <= m < n) permette di interagire (leggere e/o scrivere) con l'(m+1)-esimo elemento dell’array “a”.

#include <iostream>

int main()
{
    // n vale 4
    int a[4] = {0,1,2,3};

    // m vale 2: leggo il valore del 3° elemento
    std::cout << a[2] << std::endl;

    // m vale 1: modifico il valore del 2° elemento
    a[1] = 9;
}

È possibile effettuare la medesima operazione utilizzando una variabile puntatore contenente l’indirizzo del primo elemento dell’array.
Perciò dato un array “a” di n elementi e un puntatore “p” uguale a “&a[0]”, l’operazione “p[m]” (con 0 <= m < n) permette di interagire (leggere e/o scrivere) con l'(m+1)-esimo elemento dell’array “a”.

#include <iostream>
int main()
{
    // n vale 4
    int a[4] = {0,1,2,3};
    
    // il puntatore memorizza l'indirizzo del primo elemento dell'array
    int * pointerToInt = a;
   
    // m vale 2: leggo il valore del 3° elemento tramite il puntatore e l'operatore []
    std::cout << pointerToInt[2] << std::endl;

    // m vale 1: modifico il valore del 2° elemento tramite il puntatore e l'operatore []
    pointerToInt[1] = 9;
}

Analizziamo quindi le motivazioni che si celano dietro questo curioso, ma decisamente utile, comportamento. Procediamo per step: vedremo innanzitutto il meccanismo sul quale l’operatore parentesi quadre “[]” si basa, esamineremo nel dettaglio le conseguenze della sua applicazione su una variabile puntatore e infine dimostreremo il perché il suo utilizzo su un array non è una terza casistica, bensì è la medesima situazione della precedente.

L’operatore parentesi quadre [], applicato a un puntatore “p”, è definito nel seguente modo: “p[n]” => “*(p + n)”.
Semplice. Sfruttando l’aritmetica dei puntatori, l’indirizzo memorizzato nella variabile puntatore “p” viene prelevato, incrementato del valore intero n e infine dereferenziato. Il puntatore “p” ovviamente non viene sovrascritto. Questo ci permette invece di interagire direttamente (leggere e/o scrivere) con la variabile presente nella n-esima locazione successiva a quella puntata da “p”.

Applicando l’operatore “[]” al nome di un array il discorso non cambia, perché, ricordiamo, esso viene implicitamente convertito in un puntatore al primo elemento dell’array stesso. Per questo motivo l’espressione “a[n]”, con “a” identificatore di array, viene valutata nel seguente modo: “*(&a[0] + n)”. Potremmo quindi accedere all'(n+1)-esimo elemento dell’array, cioè la n-esima locazione successiva a quella con indirizzo uguale a “&a[0]”.

Abbiamo precedentemente affermato che l’aritmetica dei puntatori e l’indicizzazione degli array sono equivalenti, e questo ne è una prova. La possibilità di simulare un array tramite le variabili puntatore è uno dei punti di forza dell’allocazione dinamica della memoria, della quale tratteremo in un futuro articolo.

Il passaggio di un array a una funzione è un’altra occasione dove questa possibilità è essenziale. È possibile dichiarare il parametro corrispondente come un array, ma la conversione implicita, più volte sopracitata, farà si che durante la chiamata a funzione venga passato solamente l’indirizzo del primo elemento dell’array stesso. Questa conversione non si può evitare.

Perciò una dichiarazione come le seguenti:

void function (char a [])
{
    // code
}
 
// oppure
 
void function (char a [n])
{ 
    // code 
}

verrà interpretata dal compilatore come:

void function (char * a)
{
    // code
}

Ed ecco che l’operatore “sizeof()” perderà la sua utilità, restituendo la quantità di memoria necessaria per la memorizzazione di un indirizzo (4 – 8 byte):

#include <iostream>

void function_0(int digits[10])
{
    // "int digits[10]" viene interpretato come "int * digits"

    // stampa la dimensione del puntatore digits nella memoria
    std::cout << sizeof(digits) << std::endl;
}

void function_1(int digits[])
{
    // "int digits[]" viene interpretato come "int * digits"

    // stampa la dimensione del puntatore digits nella memoria
    std::cout << sizeof(digits) << std::endl;
}

void function_2(int * digits)
{
    // stampa la dimensione di un puntatore nella memoria
    std::cout << sizeof(digits) << std::endl;
}

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

    // sizeof(digits) è una delle eccezioni dove "digits" non 
    // decade in un puntatore al primo elemento dell'array
    // stampa 40: 4 byte per 10 numeri interi
    std::cout << sizeof(digits) << std::endl;
    
    // "digits" viene convertito
    // viene passato l'indirizzo del primo elemento dell'array
    function_0(digits);
    function_1(digits);
    function_2(digits);
}

Questa conversione, da dichiarazione di array a dichiarazione di puntatore, avviene solo ed esclusivamente all’interno delle dichiarazioni dei parametri formali di una funzione.

L’unico modo per determinare in una funzione la dimensione di un determinato array passatole come parametro, senza scomodare strumenti più complessi come i template, è quella di aggiungere alla lista dei parametri formali una variabile contenente il numero di elementi presenti nell’array:

#include <iostream>

void function(int * digits, unsigned int numberOfElements)
{
    for(unsigned int i = 0; i < numberOfElements; i++)
    {
        // simulo l'array tramite il puntatore digits

        // sia per operazioni di scrittura sull'array originale
        digits[i] = i;
        // sia per operazioni di lettura
        std::cout << digits[i] << std::endl;
    }
}

int main()
{
    int digits[10];
    unsigned int numberOfElements = 10;

    // passo alla funzione sia l'indirizzo del primo elemento dell'array
    // che il numero di elementi in esso presente
    function(digits, numberOfElements);
}

Da notare come abbiamo facilmente interagito con gli elementi dell’array nella funzione tramite il puntatore.

 

CONCLUSIONE

L’aritmetica dei puntatori è un potente strumento, che se usato dovutamente accresce enormemente il valore delle variabili puntatore aumentando le operazioni effettuabili mediante essi. Nel prossimo articolo considereremo un’altra applicazione dell’aritmetica dei puntatori nella gestione delle stringhe C-like.

 

[Scarica l’articolo in formato PDF]

 

A proposito di me

Andrea Simone Costa

Giovane appassionato di coding, frequento un corso di studi incentrato sul front-end web. Nel tempo libero amo sviscerare gli arcani misteri dell’informatica, con un’attenzione particolare al linguaggio C e C++.

Gli articoli più letti

Articoli recenti

Commenti recenti