Puntatori void in linguaggio C

In linguaggio C, definire un puntatore significa specificare anche il tipo di dato a cui il puntatore punta.

Esiste, però, un altro tipo di puntatore, chiamato puntatore void, che può puntare a qualunque area di memoria, indipendentemente dal tipo di dato che vi è memorizzato. In questa lezione vedremo cosa sono i puntatori void e come usarli in linguaggio C.

Puntatori void

Nelle lezioni precedenti sulle variabili puntatore abbiamo visto che quando dichiariamo un puntatore in linguaggio C, dobbiamo specificare il tipo di dato a cui il puntatore punta. Ad esempio, se dichiariamo un puntatore a intero, il tipo di dato a cui il puntatore punta è int.

int *p;

Dichiarare il tipo a cui punta un puntatore è fondamentale per il compilatore. Infatti, conoscendo questa informazione il compilatore è in grado di calcolare correttamente l'offset di memoria per accedere al dato puntato e di effettuare il dereferenziamento del puntatore.

Abbiamo detto, infatti, che l'indirizzo di una qualunque variabile in memoria corrisponde all'indirizzo del suo primo byte.

Per cui, se abbiamo un puntatore a double, il compilatore sa che per accedere al dato puntato deve leggere, a partire dall'indirizzo puntato, 8 byte di memoria. Infatti un double occupa 8 byte.

double *p;

Esistono casi, però, in cui programmi che accedono direttamente all'hardware o alla memoria del computer devono memorizzare o passare indirizzi di locazioni di memoria senza conoscere quali tipi di dato vi siano effettivamente memorizzati.

Per questo motivo, in linguaggio C esiste un tipo speciale di puntatore, chiamato puntatore void.

Un puntatore void, spesso chiamato puntatore a void, è un puntatore generico che può essere definito con la sintassi che segue:

void *p;

Questo puntatore può puntare a qualunque area di memoria, indipendentemente dal tipo di dato che vi è memorizzato.

Ad esempio, se vogliamo usare un puntatore void per memorizzare l'indirizzo di una variabile di tipo int, possiamo fare quanto segue:

int a = 10;
void *p = &a;

In questo caso, il puntatore p punta all'indirizzo di memoria di a, ma il compilatore non sa che tipo di dato è memorizzato in quella locazione di memoria.

Per accedere al dato puntato da un puntatore void, dobbiamo effettuare un casting esplicito del puntatore al tipo di dato corretto.

int b = *((int *)p);

Nell'esempio di sopra abbiamo effettuato un casting del puntatore p al tipo int * e poi abbiamo dereferenziato il puntatore per ottenere il valore di a.

Definizione

Puntatore void *

In linguaggio C, un puntatore void è un puntatore generico che può puntare a qualunque area di memoria, indipendentemente dal tipo di dato che vi è memorizzato.

La sintassi per dichiarare un puntatore void è la seguente:

void *p;

Per accedere al dato puntato da un puntatore void, dobbiamo effettuare un casting esplicito del puntatore al tipo di dato corretto:

tipo b = *((tipo *)p);

Quando dereferenziamo un puntatore void, però, dobbiamo prestare la massima cura.

Cosa accade, infatti, quando effettuiamo un cast esplicito ad un tipo di dato sbagliato?

int a = 10;
void *p = &a;

/* Cosa accade in questo caso? */
double b = *((double *)p);

In questo caso, stiamo effettuando un casting del puntatore p al tipo double *, ma il dato puntato da p è di tipo int. Mentre un double occupa 8 byte, un int ne potrebbe occupare solo 4. Per cui, con l'operazione di dereferenziamento, potremmo accedere ai 4 byte successivi.

In tal caso, il comportamento del programma diventa indefinito. Il programma potrebbe funzionare correttamente, potrebbe generare un errore in fase di esecuzione o potrebbe restituire un valore errato.

Nota

Attenzione al casting di puntatori void

Quando dereferenziamo un puntatore void, dobbiamo fare attenzione a effettuare un casting esplicito al tipo di dato corretto. Se effettuiamo un casting ad un tipo di dato sbagliato, il comportamento del programma diventa indefinito.

Operazioni possibili con puntatori void

I puntatori void sono a tutti gli effetti dei puntatori. Pertanto è possibile effettuare le stesse operazioni dei puntatori normali su di essi ad eccezione di alcune operazioni.

Vediamo quali sono le operazioni consentite:

  1. Assegnamento di un indirizzo:

    Ad un puntatore void possiamo assegnare l'indirizzo di memoria di una variabile di qualsiasi tipo.

    int a = 10;
    void *p = &a;
    
  2. Dereferenziamento:

    Possiamo dereferenziare un puntatore void a patto che effettuiamo un casting esplicito del puntatore al tipo di dato corretto.

    int b = *((int *)p);
    
  3. Assegnamento di un puntatore a un altro puntatore:

    Possiamo assegnare un puntatore void ad un altro puntatore void.

    void *q = p;
    
  4. Confronto tra puntatori:

    Possiamo confrontare due puntatori void tra loro.

    if (p == q) {
        // ...
    }
    else {
        // ...
    }
    

In questo si comportano come i puntatori normali.

Tuttavia, poiché il compilatore non sa il tipo di dato a cui punta un puntatore void, non possiamo effettuare alcune operazioni che richiedono questa informazione. Le operazioni di aritmetica dei puntatori, infatti, non sono consentite su puntatori void:

  1. La somma o sottrazione di un intero non è consentita:

    Non possiamo effettuare la somma o la sottrazione di un intero ad un puntatore void.

    /* ERRORE: La somma di un intero ad un
     *         puntatore void non è consentita
     */
    p = p + 1;
    
    /* ERRORE: La sottrazione di un intero ad un
     *         puntatore void non è consentita
     */
    p = p - 1;
    
  2. L'incremento o decremento di un puntatore non è consentito:

    Non possiamo incrementare o decrementare un puntatore void.

    /* ERRORE: L'incremento di un puntatore void
     *         non è consentito
     */
    p++;
    
    /* ERRORE: Il decremento di un puntatore void
     *         non è consentito
     */
    p--;
    
  3. La sottrazione di due puntatori void non è consentita:

    Non possiamo sottrarre due puntatori void.

    /* ERRORE: La sottrazione di due puntatori void
     *         non è consentita
     */
    int diff = p - q;
    

Ricapitolando:

Definizione

Operazioni consentite con puntatori void

Sui puntatori void sono consentite tutte le operazioni consentite sui puntatori normali, ad eccezione delle operazioni di aritmetica dei puntatori.

Le operazioni consentite sui puntatori void sono:

  1. Assegnamento di un indirizzo
  2. Dereferenziamento
  3. Assegnamento di un puntatore a un altro puntatore
  4. Confronto tra puntatori

Sebbene esistano queste regole, alcuni compilatori, come GCC, permettono di effettuare alcune operazioni di aritmetica dei puntatori su puntatori void. Infatti, considerano i puntatori void come puntatori ad un byte. Pertanto, quando ad esempio si incrementa un puntatore void, si incrementa di un byte l'indirizzo puntato.

void *p = (void *)0x1000;
p++;

In questo caso, il puntatore p punta all'indirizzo 0x1001 se usiamo un compilatore che permette questa operazione.

Il nostro consiglio, però, è di evitare di effettuare operazioni di aritmetica dei puntatori su puntatori void per evitare comportamenti non definiti.

Inoltre, GCC può essere configurato per emettere un warning o un errore quando si effettuano operazioni di aritmetica dei puntatori su puntatori void. Basta usare l'opzione -Wpointer-arith per abilitare il warning o -Werror=pointer-arith per trasformare il warning in errore.

gcc -Wpointer-arith -o programma programma.c
gcc -Werror=pointer-arith -o programma programma.c

Quando usare i puntatori void

A questo punto sorge la domanda: visti i pericoli e le limitazioni nell'uso dei puntatori void, quando ha senso utilizzarli?

Principalmente esistono tre casi in cui i puntatori void vengono adoperati.

Il primo caso riguarda l'interfacciamento diretto con l'hardware. Quando si scrivono driver per dispositivi hardware, si lavora direttamente con aree di memoria, soprattutto oggigiorno, dal momento che molti dispositivi sono memory mapped.

Ad esempio, una scheda grafica potrebbe mappare, ossia associare, un'area di memoria alla quale scrivere i pixel direttamente in memoria. un driver o programma potrebbero accedere a questa memoria usando puntatori void e, successivamente, effettuare il casting esplicito al tipo di dato corretto.

Un secondo utilizzo riguarda invece funzioni che non possono fare assunzioni sul tipo di dato di uno o più parametri.

Un esempio semplice potrebbe essere quello di una funzione che deve effettuare la somma di due valori, ma non sa a priori se i valori sono interi, in virgola mobile o di altro tipo. Allora, la funzione potrebbe accettare due puntatori void, gli addendi, un puntatore void per il risultato e un quarto parametro che indica il tipo di dato degli addendi:

void somma(void *a, void *b, void *risultato, int tipo) {
    switch (tipo) {
        case 0:
            /* Somma tra Interi */
            *((int *)risultato) = *((int *)a) + *((int *)b);
            break;
        case 1:
            /* Somma tra Double */
            *((double *)risultato) = *((double *)a) + *((double *)b);
            break;
        default:
            break;
    }
}

Possiamo invocare questa funzione in questo modo:

int a = 10;
int b = 20;
int risultato;

somma(&a, &b, &risultato, 0);

Nelle prossime lezioni vedremo usi più sofisticati di funzioni che accettano puntatori void.

Un ultimo utilizzo tipico dei puntatori void riguarda l'allocazione dinamica della memoria. Questo è un argomento fondamentale che vedremo nelle prossime lezioni. Per ora basti sapere che in C è possibile allocare dinamicamente la memoria usando la funzione malloc e che questa funzione restituisce un puntatore void. Infatti, questa funzione accetta in ingresso non un tipo ma la dimensione in byte della memoria da allocare.

void *p = malloc(numero_di_byte);

In questo caso, p è un puntatore void che punta all'area di memoria allocata dinamicamente. Rimandiamo, però, i dettagli sull'allocazione dinamica della memoria alle prossime lezioni.

Esistono sicuramente altri casi di utilizzo ma questi sono i tre casi tipici. In ogni caso, in codice di alto livello è sempre meglio evitare l'uso dei puntatori void e bisogna guardare con sospetto codice che fa un uso eccessivo di questi puntatori perché potrebbe rappresentare un errore di progettazione del codice.

In Sintesi

In questa lezione abbiamo visto i puntatori void in linguaggio C. Abbiamo visto che i puntatori void sono puntatori generici che possono puntare a qualunque area di memoria, indipendentemente dal tipo di dato che vi è memorizzato.

Abbiamo visto che per accedere al dato puntato da un puntatore void dobbiamo effettuare un casting esplicito del puntatore al tipo di dato corretto.

Infine, abbiamo visto che i puntatori void sono a tutti gli effetti dei puntatori, ma che non possiamo effettuare alcune operazioni di aritmetica dei puntatori su di essi.