[successivo] [precedente] [inizio] [fine] [indice generale] [indice ridotto] [indice analitico] [home] [volume] [parte]


Capitolo 577.   C: puntatori, array, stringhe e allocazione dinamica della memoria

Nel capitolo introduttivo sono stati mostrati solo i tipi di dati più semplici. Per poter utilizzare gli array si gestiscono dei puntatori alle zone di memoria contenenti tali strutture.

Quando si ha a che fare con i puntatori è importante considerare che il modello di memoria che si ha di fronte è un'astrazione, nel senso che una struttura di dati appare idealmente continua, mentre nella realtà il compilatore potrebbe anche provvedere a scomporla in blocchi separati.

Nella spiegazione che si fa in questo capitolo, come negli altri di questa parte, l'esposizione è semplificata rispetto alle definizioni dello standard; pertanto, per un approccio più preciso ci si deve rivolgere ai documenti ufficiali sul linguaggio C.

577.1   Espressioni a cui si assegnano dei valori

Quando si utilizza un operatore di assegnamento, come = o altri operatori composti, ciò che si mette alla sinistra rappresenta la «variabile ricevente» del risultato dell'espressione che si trova alla destra dell'operatore (nel caso di operatori di assegnamento composti, l'espressione alla destra va considerata come quella che si ottiene scomponendo l'operatore). Ma il linguaggio C consente di rappresentare quella «variabile ricevente» attraverso un'espressione, come nel caso dei puntatori che vengono descritti in questo capitolo. Pertanto, per evitare confusione, la documentazione dello standard chiama l'espressione a sinistra dell'operatore di assegnamento un lvalue (Left value o Location value).

Nel capitolo si evita questa terminologia, tuttavia è importante comprendere che un'espressione può rappresentare una «variabile», pur senza averle dato un nome (nella sezione 576.6 il concetto di lvalue e di rvalue viene descritto con migliore dettaglio).

577.2   Puntatori

Una variabile, di qualunque tipo sia, rappresenta normalmente un valore posto da qualche parte nella memoria del sistema.(1) Quando si usano i tipi di dati normali, è il compilatore a prendersi cura di tradurre i riferimenti agli spazi di memoria rappresentati simbolicamente attraverso dei nomi.

Attraverso l'operatore di indirizzamento e-commerciale (&), è possibile ottenere il puntatore (riferito alla rappresentazione ideale di memoria del linguaggio C) a una variabile «normale». Tale valore può essere inserito in una variabile particolare, adatta a contenerlo: una variabile puntatore.

Per esempio, se p è una variabile puntatore adatta a contenere l'indirizzo di un intero, l'esempio mostra in che modo assegnare a tale variabile il puntatore alla variabile i:

int i = 10;
...
p = &i;  // L'indirizzo di «i» viene assegnato al puntatore «p».

La dichiarazione di una variabile puntatore avviene in modo simile a quello delle variabili normali, con l'aggiunta di un asterisco prima del nome. L'esempio seguente dichiara la variabile p come puntatore a un tipo int. Si osservi che va indicato il tipo di dati a cui si punta, perché questa informazione è parte integrante del puntatore.

int *p;

Non deve essere interesse del programmatore il modo esatto in cui si rappresentano i puntatori dei vari tipi di dati, diversamente non ci sarebbe l'utilità di usare un linguaggio come il C invece di un semplice assemblatore di linguaggio macchina.

Una volta dichiarata la variabile puntatore, questa viene utilizzata normalmente, senza asterisco, finché si intende fare riferimento al puntatore stesso.

L'asterisco usato nella dichiarazione serve a definire il tipo di dati, quindi, int *p rappresenta la dichiarazione della variabile p di tipo int *. Tuttavia si può fare un ragionamento leggermente differente, con l'aiuto delle parentesi: int (*p) è la dichiarazione di una zona di memoria senza nome, di tipo int, a cui punta la variabile p attraverso la dereferenziazione *p. Le due cose sono equivalenti, in quanto portano comunque alla creazione della variabile p di tipo puntatore a intero, ma la seconda forma consente di comprendere, successivamente, la sintassi per la creazione di un puntatore a funzione.

È importante chiarire subito in che modo si dichiarano più variabili puntatore con una sola istruzione; si osservi l'esempio seguente in cui si creano le variabili p e p2, in particolare per il fatto che l'asterisco va ripetuto:

int *p, *p2;

Attraverso l'operatore di «dereferenziazione», l'asterisco (*), è possibile accedere alla zona di memoria a cui la variabile punta. Per «dereferenziare» si intende quindi l'azione con cui si toglie il riferimento e si raggiungono i dati a cui un puntatore si riferisce.(2)

Attenzione a non fare confusione con gli asterischi: una cosa è quello usato per dichiarare o per dereferenziare un puntatore e un'altra è l'operatore con cui invece si ottiene la moltiplicazione.

L'esempio già accennato potrebbe essere chiarito nel modo seguente, dove si mostra anche la dichiarazione della variabile puntatore:

int i = 10;
int *p;
...
p = &i;

A questo punto, dopo aver assegnato a p il puntatore alla variabile i, è possibile accedere alla stessa area di memoria in due modi diversi: attraverso la variabile i, oppure attraverso la dereferenziazione di p, ovvero la traduzione *p.

int i = 10;
int *p;
...
p = &i;
...
*p = 20;

Nell'esempio, l'istruzione *p=20 è tecnicamente equivalente a i=20. Per chiarire un po' meglio il ruolo delle variabili puntatore, si può complicare l'esempio nel modo seguente:

int i = 10;
int *p;
int *p2;
...
p = &i;
...
p2 = p;
...
*p2 = 20;

In particolare è stata aggiunta una seconda variabile puntatore, p2, solo per fare vedere che è possibile passare un puntatore anche ad altre variabili senza dover usare l'asterisco. Comunque, in questo caso, *p2=20 è tecnicamente equivalente sia a *p=20, sia a i=20.

Si osservi che l'asterisco è un operatore che, evidentemente, ha la precedenza rispetto a quelli di assegnamento. Eventualmente, anche in questo caso si possono usare le parentesi per togliere ambiguità al codice:

int i = 10;
int *p;
...
p = &i;
...
(*p2) = 20;

Come accennato inizialmente, il tipo di dati a cui un puntatore si rivolge, fa parte integrante del puntatore stesso. Ciò è importante perché quando si dereferenzia un puntatore occorre sapere quanto è grande l'area di memoria a cui si deve accedere a partire dal puntatore. Per questa ragione, quando si assegna a una variabile puntatore un altro puntatore, questo deve essere compatibile, nel senso che deve riferirsi allo stesso tipo di dati, altrimenti si rischia di ottenere un risultato inatteso. A questo proposito, l'esempio seguente contiene probabilmente un errore:

char *pc;
int  *pi;
...
pi = pc;  // I due puntatori si riferiscono a dati di tipo differente!
...

Quando invece si vuole trasformare realmente un puntatore in modo che si riferisca a un tipo di dati differente, si può usare un cast, come si farebbe per convertire i valori numerici:

char *pc;
int  *pi;
...
pi = (int *) pc;  // Il programmatore dimostra di essere consapevole
                  // di ciò che sta facendo attraverso un cast!
...
...

Nello schema seguente appare un esempio che dovrebbe consentire di comprendere la differenza che c'è tra i puntatori, in base al tipo di dati a cui fanno riferimento. In particolare, p1, q1 e r1 fanno tutti riferimento all'indirizzo ipotetico 0AFC16, ma l'area di memoria che considerano è diversa, pertanto *p1, *q1 e *r1 sono tra loro «variabili» differenti, anche se si sovrappongono parzialmente.

confronto tra puntatori

L'esempio seguente rappresenta un programma completo che ha lo scopo di determinare se l'architettura dell'elaboratore è di tipo big endian o di tipo little endian. Per capirlo si dichiara una variabile di tipo long int che si intende debba essere di rango superiore rispetto al tipo char, assegnandole un valore abbastanza basso da poter essere rappresentato anche in un tipo char senza segno. Con un puntatore di tipo char * si vuole accedere all'inizio della variabile contenente il numero intero long int: se già nella porzione letta attraverso il puntatore al primo «carattere» si trova il valore assegnato alla variabile di tipo intero, vuol dire che i byte sono invertiti e si ha un'architettura little endian, mentre diversamente si presume che sia un'architettura big endian.

:-)

#include <stdio.h>
int main (void)
{
    long int i = 123;
    char *p = (char *) &i;
    if (*p == 123)
      {
        printf ("little endian\n");
      }
    else
      {
        printf ("big endian\n");
      }
    return 0;
}

Figura 577.12. Schematizzazione dell'operato del programma di esempio, per determinare l'ordine dei byte usato nella propria architettura.

big endian little endian

Il linguaggio C utilizza il passaggio degli argomenti alle funzioni per valore; per ottenere il passaggio per riferimento occorre utilizzare dei puntatori. Si immagini di volere realizzare una funzione banale che modifica la variabile utilizzata nella chiamata, sommandovi una quantità fissa. Invece di passare il valore della variabile da modificare, si può passare il suo puntatore; in questo modo la funzione (che comunque deve essere stata realizzata appositamente per questo scopo) agisce nell'area di memoria a cui punta questo puntatore.

...
void funzione_stupida (int *x)
{
    (*x)++;
}
...
int main (void)
{
    int y = 10;
    ...
    funzione_stupida (&y);
    ...
    return 0;
}    

L'esempio mostra la dichiarazione e descrizione di una funzione che non restituisce alcun valore e ha un parametro costituito da un puntatore a un intero. Il lavoro della funzione è solo quello di incrementare il valore contenuto nell'area di memoria a cui si riferisce tale puntatore.

Poco dopo, nella funzione main() inizia il programma vero e proprio; viene dichiarata la variabile y corrispondente a un intero normale inizializzato a 10, poi, a un certo punto viene chiamata la funzione vista prima, passando il puntatore a y.

Il risultato è che dopo la chiamata, la variabile y contiene il valore precedente incrementato di un'unità.

Quando si usano i puntatori, invece delle variabili comuni, occorre considerare che se la vita della variabile a cui un puntatore fa riferimento si è esaurita, il puntatore relativo diventa privo di valore. Questo significa che il fatto di avere conservato il puntatore a una certa area di memoria non implica automaticamente la garanzia che tale zona contenga dati validi o che sia ancora raggiungibile.

577.3   Array

Nel linguaggio C, l'array è una sequenza ordinata di elementi dello stesso tipo nella rappresentazione ideale di memoria di cui si dispone. In questo senso, quando si dichiara un array, quello che il programmatore ottiene in pratica è il riferimento alla posizione iniziale di questo, mentre gli elementi successivi si raggiungono tenendo conto della lunghezza di ogni elemento.

Questo ragionamento vale in senso generale ed è un po' approssimativo. In contesti particolari, il riferimento a un array restituisce qualcosa di diverso dal puntatore al primo elemento.

Visto in questi termini, si può intendere che l'array in C è sempre a una sola dimensione, tutti gli elementi devono essere dello stesso tipo in modo da avere la stessa lunghezza e la quantità degli elementi, una volta definita, è fissa.

È compito del programmatore ricordare la quantità di elementi che compone l'array, perché determinarlo diversamente è complicato e a volte non è possibile. Inoltre, quando un programma tenta di accedere a una posizione oltre il limite degli elementi esistenti, c'è il rischio che non si verifichi alcun errore, arrivando però a dei risultati imprevedibili.

Lo standard prescrive che sia consentito raggiungere l'indirizzo successivo all'ultimo elemento, anche se tale contenuto diventa privo di significato. Ciò serve a garantire che non si provochino errori nell'accesso alla memoria, se l'indice va oltre il limite di un array, ma per una sola posizione, per leggere un contenuto privo di utilità. In pratica, ciò significa che dopo un array ci deve essere qualunque altra variabile, o al limite uno spazio inutilizzato. Ma questo è compito del compilatore.

La dichiarazione di un array avviene in modo intuitivo, definendo il tipo degli elementi e la loro quantità. L'esempio seguente mostra la dichiarazione dell'array a di sette elementi di tipo int:

int a[7];

Per accedere agli elementi dell'array si utilizza un indice, il cui valore iniziale è sempre zero e, di conseguenza, quello con cui si raggiunge l'elemento n-esimo deve avere il valore n-1. L'esempio seguente mostra l'assegnamento del valore 123 al secondo elemento:

a[1] = 123;

In presenza di array monodimensionali che hanno una quantità ridotta di elementi, può essere sensato attribuire un insieme di valori iniziale all'atto della dichiarazione.

Alcuni compilatori consentono l'inizializzazione degli array solo quando questi sono dichiarati all'esterno delle funzioni, con un campo di azione globale, oppure all'interno delle funzioni, ma dichiarati come «statici», nel senso che continuano a esistere all'uscita della funzione.

int a[] = {123, 453, 2, 67};

L'esempio mostrato dovrebbe chiarire in che modo si possono dichiarare gli elementi dell'array, tra parentesi graffe, togliendo così la necessità di specificare la quantità di elementi. Tuttavia, le due cose possono coesistere:

int a[10] = {123, 453, 2, 67};

In tal caso, l'array si compone di 10 elementi, di cui i primi quattro con valori prestabiliti, mentre gli altri ottengono il valore zero. Si osservi però che il contrario non può essere fatto:

:-(

int a[5] = {123, 453, 2, 67, 32, 56, 78};     // Non si può!

Gli standard recenti del linguaggio C consentono anche la dichiarazione di array per i quali il compilatore non può sapere subito la quantità di elementi da predisporre, purché ciò avvenga nel campo di azione delle funzioni (o di blocchi inferiori). In pratica, in questi casi è possibile indicare la quantità di elementi attraverso un'espressione che si traduca in un numero intero, come nell'esempio seguente, dove la quantità di elementi è data dal prodotto tra la variabile s e la costante 3:

int s = 33;
...
int a[s * 3];

Gli array dichiarati al di fuori delle funzioni (quelli il cui campo di azione è legato al file) e quelli che, pur essendo dichiarati nelle funzioni, continuano a esistere per tutto il tempo di esecuzione del programma (in quanto «statici»), possono avere soltanto una quantità di elementi già stabilita in fase di compilazione. Per fare riferimento a array definiti in altri file, oppure in posizioni più avanzate dello stesso file, è possibile usare una dichiarazione «esterna», nella quale è bene specificare la quantità di elementi, ma questa deve essere coerente con quella della dichiarazione a cui si fa riferimento:

extern int i[3];
...
int i[3];

In alternativa si può fare una dichiarazione esterna di un array senza specificarne la quantità di elementi, ma questo implica che, fino a quando non appare la dichiarazione completa, l'array sia di tipo incompleto e non si possa determinare la sua dimensione con l'aiuto dell'operatore sizeof:

extern int i[];         // Tipo incompleto.
...
int i[3];

La scansione di un array avviene generalmente attraverso un'iterazione enumerativa, in pratica con un ciclo for che si presta particolarmente per questo scopo. Si osservi l'esempio seguente:

int a[7];
int i;
...
for (i = 0; i < 7; i++)
  {
    ...
    a[i] = ...;
    ...
  }

L'indice i viene inizializzato a zero, in modo da cominciare dal primo elemento dell'array; il ciclo può continuare fino a che i continua a essere inferiore a sette, infatti l'ultimo elemento dell'array ha indice sei; alla fine di ogni ciclo, prima che riprenda il successivo, viene incrementato l'indice di un'unità.

Per scandire un array in senso opposto, si può agire in modo analogo, come nell'esempio seguente:

int a[7];
int i;
...
for (i = 6; i >= 0; i--)
  {
    ...
    a[i] = ...;
    ...
  }

Questa volta l'indice viene inizializzato in modo da puntare alla posizione finale; il ciclo viene ripetuto fino a che l'indice è maggiore o uguale a zero; alla fine di ogni ciclo, l'indice viene decrementato di un'unità.

Se non si può conoscere la dimensione dell'array, questa deve essere calcolata con l'ausilio dell'operatore sizeof, come nell'esempio seguente, ammesso che il contesto sia tale da consentire all'operatore di restituire un valore valido:

// Da qualche parte si dichiara il valore di «x» come numero intero.
...
int a[7 * x];
int i;
...
int s = (sizeof a) / (sizeof (a[0]));
for (i = 0; i < s; i++)
  {
    ...
    a[i] = ...;
    ...
  }

Il calcolo della quantità di elementi è ottenuto determinando la dimensione dell'array in byte e dividendo tale valore per la dimensione in byte di un intero, ovvero per la dimensione di ogni elemento dell'array stesso.

Quando un array è argomento dell'operatore sizeof, si ottiene la dimensione complessiva dell'array stesso (nell'unità gestita da sizeof. Tuttavia occorre considerare che, se l'array non è ancora stato definito nella sua dimensione, non si può avere il risultato atteso.

577.4   Array multidimensionali

Gli array in C sono monodimensionali, però nulla vieta di creare un array i cui elementi siano array tutti uguali. Per esempio, nel modo seguente, si dichiara un array di cinque elementi che a loro volta sono insiemi di sette elementi di tipo int. Nello stesso modo si possono definire array con più di due dimensioni.

int a[5][7];

L'esempio seguente mostra il modo normale di scandire un array a due dimensioni:

int a[5][7];
int i;
int j;
...
for (i = 0; i < 5; i++)
  {
    ...
    for  (j = 0; j < 7; j++)
      {
        ...
        a[i][j] = ...;
        ...
      }
    ...
  }

Anche se in pratica un array a più dimensioni è solo un array «normale» in cui si individuano dei sottogruppi di elementi, la scansione deve avvenire sempre indicando formalmente lo stesso numero di elementi prestabiliti per le dimensioni rispettive, anche se dovrebbe essere possibile attuare qualche trucco. Per esempio, tornando al listato mostrato, se si vuole scandire in modo continuo l'array, ma usando un solo indice, bisogna farlo gestendo l'ultimo:

int a[5][7][9];
int j;
...
for (j = 0; j < (5 * 7 * 9); j++)
  {
    ...
    a[0][0][j] = ...;
    ...
  }

Rimane comunque da osservare il fatto che questo non sia un bel modo di programmare.

Anche gli array a più dimensioni possono essere inizializzati, secondo una modalità analoga a quella usata per una sola dimensione, con la differenza che l'informazione sulla quantità di elementi per dimensione non può essere omessa. L'esempio seguente è un programma completo, in cui si dichiara e inizializza un array a due dimensioni, per poi mostrarne il contenuto:

:-)

#include <stdio.h>

int main (int argc, char *argv[])
{

    int a[3][4] = {{1,  2,  3,  4},
                   {5,  6,  7,  8},
                   {9, 10, 11, 12}};
    int i, j;

    for (i = 0; i < 3; i++)
      {
        for (j = 0; j < 4; j++)
          {
            printf ("a[%d][%d]=%d\t", i, j, a[i][j]);
          }
        printf ("\n");
      }

    return 0;
}

Il programma dovrebbe mostrare il testo seguente:

a[0][0]=1       a[0][1]=2       a[0][2]=3       a[0][3]=4       
a[1][0]=5       a[1][1]=6       a[1][2]=7       a[1][3]=8       
a[2][0]=9       a[2][1]=10      a[2][2]=11      a[2][3]=12      

Anche nell'inizializzazione di un array a più dimensioni si possono omettere degli elementi, come nell'estratto seguente:

...
    int a[3][4] = {{1, 2},
                   {5, 6, 7, 8}};
...

In tal caso, il programma si mostrerebbe così:

a[0][0]=1       a[0][1]=2       a[0][2]=0       a[0][3]=0       
a[1][0]=5       a[1][1]=6       a[1][2]=7       a[1][3]=8       
a[2][0]=0       a[2][1]=0       a[2][2]=0       a[2][3]=0       

Di certo, pur sapendo di voler utilizzare un array a più dimensioni, si potrebbe pretendere di inizializzarlo come se fosse a una sola, come nell'esempio seguente, ma il compilatore dovrebbe avvisare del fatto:

:-(

...
    int a[3][4] = {1, 2, 3, 4, 5, 6,            // Così non è
                   7, 8, 9, 10, 11, 12};        // grazioso.
...

577.5   Natura dell'array

Inizialmente si è accennato al fatto che quando si crea un array, quello che viene restituito in pratica è un puntatore alla sua posizione iniziale, ovvero all'indirizzo del primo elemento di questo. Si può intuire che non sia possibile assegnare a un array un altro array, anche se ciò potrebbe avere significato. Al massimo si può assegnare elemento per elemento.

Per evitare errori del programmatore, la variabile che contiene l'indirizzo iniziale dell'array, quella che in pratica rappresenta l'array stesso, è in sola lettura. Quindi, nel caso dell'array già visto, la variabile a non può essere modificata, mentre i singoli elementi a[i] sì:

int a[7];

Data la filosofia del linguaggio C, se fosse possibile assegnare un valore alla variabile a, si modificherebbe il puntatore, facendo in modo che questo punti a un array differente. Ma per raggiungere questo risultato vanno usati i puntatori in modo esplicito. Si osservi l'esempio seguente:

:-)

#include <stdio.h>

int main (void)
{
    int a[3];
    int *p;

    p = a;        // «p» diventa un alias dell'array «a».

    p[0] = 10;    // Si può fare solo con gli array
    p[1] = 100;   // a una sola dimensione.
    p[2] = 1000;  //

    printf ("%d %d %d \n",  a[0], a[1], a[2]);
    
    return 0;
}

Viene creato un array, a, di tre elementi di tipo int, e subito dopo una variabile puntatore, p, al tipo int. Si assegna quindi alla variabile p il puntatore rappresentato da a; da quel momento si può fare riferimento all'array indifferentemente con il nome a o p.

Si può osservare anche che l'operatore &, seguito dal nome di un array, produce ugualmente l'indirizzo dell'array che è equivalente a quello fornito senza l'operatore stesso, con la differenza che riguarda l'array nel suo complesso:

:-(

    ...
    p = &a;       // I due puntatori non sono dello stesso tipo!
    ...

Pertanto, in questo caso si pone il problema di compatibilità del tipo di puntatore che si può risolvere con un cast esplicito:

    ...
    p = (int *)&a;       // «p» diventa un alias dell'array «a».
    ...

In modo analogo, si può estrapolare l'indice che rappresenta l'array dal primo elemento, cosa che si ottiene senza incorrere in problemi di compatibilità tra i puntatori. Si veda la trasformazione dell'esempio nel modo seguente:

:-(

#include <stdio.h>

int main (void)
{
    int a[3];
    int *p;

    p = &a[0];    // «p» diventa un alias dell'array «a».

    p[0] = 10;    // Si può fare solo con gli array
    p[1] = 100;   // a una sola dimensione.
    p[2] = 1000;  //

    printf ("%d %d %d \n",  a[0], a[1], a[2]);
    
    return 0;
}

Anche se si può usare un puntatore come se fosse un array, va osservato che la variabile p, in quanto dichiarata come puntatore, viene considerata in modo differente dal compilatore; per esempio non è possibile determinare la dimensione dell'array a cui punta attraverso l'operatore sizeof, perché si otterrebbe semplicemente la quantità di byte che costituisce la variabile puntatore.

Quando si opera con array a più dimensioni, il riferimento a una porzione di array restituisce l'indirizzo della porzione considerata. Per esempio, si supponga di avere dichiarato un array a due dimensioni, nel modo seguente:

int a[3][2];

Se a un certo punto, in riferimento allo stesso array, si scrivesse a[2], si otterrebbe l'indirizzo del terzo gruppo di due interi:

a[3][2]

Tenendo d'occhio lo schema appena mostrato, considerato che si sta facendo riferimento all'array a di 3×2 elementi di tipo int, va osservato che:

Pertanto, se questa volta si volesse assegnare a una variabile puntatore di tipo int * l'indirizzo iniziale dell'array, nell'esempio seguente si creerebbe un problema di compatibilità:

    ...
    int a[3][2];
    int *p;
    p = a;      // I due puntatori non sono dello stesso tipo!
    ...

Pertanto, occorrerebbe riferirsi all'inizio dell'array in modo differente oppure attraverso un cast.

577.6   Puntatori costanti

Si può far sì che un puntatore funzioni in modo più simile a quello di un array a una sola dimensione, dichiarando il puntatore come costante, nel senso che il puntatore in sé non può essere cambiato:

...
    int a[3];
    int *const p = a;  // Puntatore in sola lettura.
    p[1] = 9;
    p = a;              // Questo non si può!
...

L'esempio seguente, invece, fa sì che la memoria a cui si vuole accedere tramite il puntatore sia protetta in sola lettura:

...
    int a[3];
    const int *p = a;   // Qui è la memoria a essere in sola lettura.
    p[1] = 9;           // Questo non si può!
    p = a;
...

Anche se si può bloccare il puntatore, così da farlo funzionare in modo equivalente a un array vero e proprio, rimane però il fatto che sizeof, usato per «misurare» un puntatore, restituisce comunque la grandezza della variabile che costituisce il puntatore stesso. Inoltre ci sono altre questioni che riguardano i puntatori, affrontate in una sezione separata, a proposito dell'aritmetica dei puntatori.

577.7   Array e funzioni

Si è visto che le funzioni possono accettare solo parametri composti da tipi di dati elementari, compresi i puntatori. In questa situazione, l'unico modo per trasmettere a una funzione un array attraverso i parametri, è quello di inviarne il puntatore iniziale. Di conseguenza, le modifiche che vengono poi apportate da parte della funzione si riflettono nell'array di origine. Si osservi l'esempio seguente:

#include <stdio.h>

void elabora (int *p)
{
    p[0] = 10;
    p[1] = 100;
    p[2] = 1000;
}

int main (void)
{
    int a[3];

    elabora (a);
    printf ("%d %d %d \n",  a[0], a[1], a[2]);
    
    return 0;
}

La funzione elabora() utilizza un solo parametro, rappresentato da un puntatore a un tipo int. La funzione presume che il puntatore si riferisca all'inizio di un array di interi e così assegna alcuni valori ai primi tre elementi (anche il numero degli elementi non può essere determinato dalla funzione).

All'interno della funzione main() viene dichiarato l'array a di tre elementi interi e subito dopo viene passato come argomento alla funzione elabora(). Così facendo, in realtà si passa il puntatore al primo elemento dell'array.

Infine, la funzione altera gli elementi come è già stato descritto e gli effetti si possono osservare così:

10 100 1000

L'esempio potrebbe essere modificato per presentare la gestione dell'array in modo più elegante. Per la precisione si tratta di ritoccare la funzione elabora:

:-)

void elabora (int a[])
{
    a[0] = 10;
    a[1] = 100;
    a[2] = 1000;
}

Si tratta sostanzialmente della stessa cosa, solo che si pone l'accento sul fatto che l'argomento è un array di interi, benché di tipo incompleto.

In entrambi i casi, se all'interno della funzione si tenta di misurare la dimensione dell'array con l'operatore sizeof, si ottiene solo la grandezza della variabile usata per contenere il puntatore relativo. Sarebbe anche possibile specificare la dimensione dell'array, senza però che questo fatto abbia delle conseguenze significative e senza che sizeof la consideri:

:-(

void elabora (int a[3])         // Anche così sizeof restituisce
{                               // solo la grandezza del puntatore.
    a[0] = 10;
    a[1] = 100;
    a[2] = 1000;
}

577.8   Aritmetica dei puntatori

Con le variabili puntatore è possibile eseguire delle operazioni elementari: possono essere incrementate e decrementate. Il risultato che si ottiene è il riferimento a una zona di memoria adiacente, in funzione della dimensione del tipo di dati per il quale è stato creato il puntatore. Si osservi l'esempio seguente:

:-(

int i = 10;
int j;
int *p = &i;
p++;
j = *p;         // Attenzione!

In questo caso viene creato un puntatore al tipo int che inizialmente contiene l'indirizzo della variabile i. Subito dopo questo puntatore viene incrementato di una unità e ciò comporta che si riferisca a un'area di memoria adiacente, immediatamente successiva a quella occupata dalla variabile i (molto probabilmente si tratta dell'area occupata dalla variabile j). Quindi si tenta di copiare il valore di tale area di memoria, interpretato come int, all'interno della variabile j.

Se un programma del genere funziona sotto il controllo di un sistema operativo che controlla l'utilizzo della memoria, se l'area che si tenta di raggiungere incrementando il puntatore non è stata allocata, si ottiene un «errore di segmentazione» e l'arresto del programma stesso. L'errore si verifica quando si tenta l'accesso, mentre la modifica del puntatore è sempre lecita.

Lo stesso meccanismo riguarda tutti i tipi di dati che non sono array, perché per gli array, l'incremento o il decremento di un puntatore riguarda i componenti dell'array stesso. In pratica, quando si gestiscono tramite puntatori, gli array sono da intendere come una serie di elementi dello stesso tipo e dimensione, dove, nella maggior parte dei casi, il nome dell'array si traduce nell'indirizzo del primo elemento:

int i[3] = { 1, 3, 5 };
int *p;
...
p = i;

Nell'esempio si vede che il puntatore p punta all'inizio dell'array di interi i[].

*p = 10; // Equivale a:  i[0] = 10.
p++;
*p = 30; // Equivale a:  i[1] = 30.
p++;
*p = 50; // Equivale a:  i[2] = 50.

Ecco che, incrementando il puntatore, si accede all'elemento adiacente successivo, in funzione della dimensione del tipo di dati. Decrementando il puntatore si ottiene l'effetto opposto, di accedere all'elemento precedente. La stessa cosa avrebbe potuto essere ottenuta così, senza alterare il valore contenuto nella variabile p:

*(p + 0) = 10; // Equivale a:  i[0] = 10.
*(p + 1) = 30; // Equivale a:  i[1] = 30.
*(p + 2) = 30; // Equivale a:  i[1] = 30.

Inoltre, come già visto in altre sezioni, si potrebbe usare il puntatore con la stessa notazione propria dell'array, ma ciò solo perché si opera a una sola dimensione:

p[0] = 10; // Equivale a:  i[0] = 10.
p[1] = 30; // Equivale a:  i[1] = 30.
p[2] = 30; // Equivale a:  i[1] = 30.

Questo lascia intuire che i[n] corrisponda in pratica a *(i + n), cosa che è vera per lo standard del linguaggio, ma potrebbe non essere accettabile dal compilatore che si usa effettivamente:

:-(

*(i + 0) = 10; // Equivale a:  i[0] = 10.
*(i + 1) = 30; // Equivale a:  i[1] = 30.
*(i + 2) = 30; // Equivale a:  i[1] = 30.

In presenza di più dimensioni, il ragionamento è analogo. Nel modello seguente, le lettere i e j rappresentano gli indici usati per la scansione, mentre le lettere I e J sono la quantità di elementi della dimensione corrispondente. Per esempio, secondo il modello seguente, in un array x[10][30], la lettera J corrisponde a 30.

x[i][j] == *(x + (i * J) + j)

In modo analogo si dovrebbe procedere per dimensioni maggiori:

x[i][j][k] == *(x + (i*J*K) + (j*K) + k)

Se il compilatore non accetta questo modo di gestire un array, il meccanismo vale per un puntatore dello stesso tipo degli elementi dell'array (che punti all'inizio dell'array stesso). L'esempio seguente mette in evidenza l'uso di un puntatore per scandire un array a due dimensioni:

:-)

#include <stdio.h>

int main (int argc, char *argv[])
{

    int a[3][4] = {{1,  2,  3,  4},
                   {5,  6,  7,  8},
                   {9, 10, 11, 12}};
    int i, j;
    const int *p = (int *) a;
    int x;

    for (i = 0; i < 3; i++)
      {
        for (j = 0; j < 4; j++)
          {
            x = *(p + i * 4 + j);
            //
            printf ("a[%d][%d]=%d\t", i, j, x);
            //
          }
        printf ("\n");
      }

    return 0;
}

I punti più importanti dell'esempio appaiono evidenziati: trattandosi di un array a più di una dimensione, la copia del puntatore avviene con l'ausilio di un cast; la scansione degli indirizzi, a partire dal puntatore p avviene attraverso una formula, mentre la forma seguente ha un significato diverso, descritto in un'altra sezione, a proposito dei puntatori a puntatori:

:-(

...
            x = p[i][j];        // Non è la stessa cosa!
...

La versione funzionante dell'esempio mostrato deve fare apparire il testo seguente:

a[0][0]=1       a[0][1]=2       a[0][2]=3       a[0][3]=4
a[1][0]=5       a[1][1]=6       a[1][2]=7       a[1][3]=8
a[2][0]=9       a[2][1]=10      a[2][2]=11      a[2][3]=12

Naturalmente, quando si usano direttamente i puntatori, è compito esclusivo del programmatore sapere quando l'incremento o il decremento di un puntatore ha significato. Diversamente si rischia di accedere a zone di memoria estranee al contesto di proprio interesse, con risultati imprevedibili.

Prima di concludere l'argomento, vale la pena di tradurre il problema dell'aritmetica dei puntatori in modo opposto, ovvero come indirizzi. Per esempio, dato l'array a[], a una sola dimensione, si può considerare equivalente la notazione &(a[i]) rispetto a (a + i).

:-)

577.9   Osservazioni sui puntatori

Ammesso che la variabile p sia un puntatore a qualcosa, la notazione *p equivale a p[0], così come *(p+n) corrisponde p[n]. Pertanto, l'uso delle parentesi quadre contenenti un indice, poste dopo il nome di una variabile puntatore, corrisponde alla dereferenziazione che si fa con l'asterisco.

Ammesso che la variabile p sia un puntatore a qualcosa, la notazione &*p corrisponde sempre a p, anche se si tratta di un puntatore nullo.

Ammesso che la variabile x sia tale da potervi assegnare un valore e che possa essere operando di &, la notazione *&x corrisponde sempre a x.

Ammesso che la variabile p sia un puntatore a qualcosa, la notazione *(tipo)p individua un'area di memoria che parte dalla posizione indicata dal puntatore e si estende per la dimensione del tipo indicato. In altre parole, si tratta di un cast con il quale si trasforma il tipo di puntatore al volo. Ma per questo occorre mostrare un esempio:

#include <stdio.h>
int main (int argc, char *argv[])
{
    int x = 10;
    void *p = &x;
    printf ("%d\n", *(int *)p);
    return 0;
}

In questo caso, il puntatore p è di tipo indefinito (void) e riceve l'indirizzo della variabile x. Successivamente, il valore a cui punta p viene usato all'interno della funzione printf(), ma prima di essere dereferenziato, viene convertito in un puntatore di tipo int *.

577.10   Stringhe

Le stringhe, nel linguaggio C, non sono un tipo di dati a sé stante; si tratta solo di array di caratteri con una particolarità: l'ultimo carattere è sempre zero, ovvero una sequenza di bit a zero, che si rappresenta simbolicamente come carattere con \0. In questo modo, si evita di dover accompagnare le stringhe con l'informazione della loro lunghezza.

Pertanto, va osservato che una stringa è sempre un array di caratteri, ma un array di caratteri non è necessariamente una stringa, in quanto per esserlo occorre che l'ultimo elemento sia il carattere \0. Seguono alcuni esempi che servono a comprendere questa distinzione.

char c[20];

L'esempio mostra la dichiarazione di un array di caratteri, senza specificare il suo contenuto. Per il momento non si può parlare di stringa, soprattutto perché per essere tale, la stringa deve contenere dei caratteri.

char c[] = {'c', 'i', 'a', 'o'};

Questo esempio mostra la dichiarazione di un array di quattro caratteri. All'interno delle parentesi quadre non è stata specificata la dimensione perché questa si determina dall'inizializzazione. Anche in questo caso non si può ancora parlare di stringa, perché manca la terminazione.

char z[] = {'c', 'i', 'a', 'o', '\0'};

Questo esempio mostra la dichiarazione di un array di cinque caratteri corrispondente a una stringa vera e propria. L'esempio seguente è tecnicamente equivalente, solo che utilizza una rappresentazione più semplice:

:-)

char z[] = "ciao";

Pertanto, la stringa rappresentata dalla costante "ciao" è un array di cinque caratteri, perché, pur senza mostrarlo, include implicitamente anche la terminazione.

L'indicazione letterale di una stringa può avvenire attraverso sequenze separate, senza l'indicazione di alcun operatore di concatenamento. Per esempio, "ciao amore\n" è perfettamente uguale a "ciao " "amore" "\n" che viene inteso come una costante unica.

In un sorgente C ci sono varie occasioni di utilizzare delle stringhe letterali (delimitate attraverso gli apici doppi), senza la necessità di dichiarare l'array corrispondente. Però è importante tenere presente la natura delle stringhe per sapere come comportarsi con loro. Per prima cosa, bisogna rammentare che la stringa, anche se espressa in forma letterale, è un array di caratteri; come tale restituisce semplicemente il puntatore del primo di questi caratteri (salvo le stesse eccezioni che riguardano tutti i tipi di array).

:-)

char *p;
...
p = "ciao";
...

L'esempio mostra il senso di quanto affermato: non esistendo un tipo di dati «stringa», si può assegnare una stringa solo a un puntatore al tipo char (ovvero a un puntatore di tipo char *). L'esempio seguente non è valido, perché non si può assegnare un valore alla variabile che rappresenta un array, dal momento che il puntatore relativo è un valore costante:

:-(

char z[];
...
z = "ciao";     // Non si può.
...

Quando si utilizza una stringa tra gli argomenti della chiamata di una funzione, questa riceve il puntatore all'inizio della stringa. In pratica, si ripete la stessa situazione già vista per gli array in generale.

#include <stdio.h>

void elabora (char *z)
{
    printf (z);
}

int main (void)
{
    elabora ("ciao\n");
    return 0;
}

L'esempio mostra una funzione banale che si occupa semplicemente di emettere la stringa ricevuta come parametro, utilizzando printf(). La variabile utilizzata per ricevere la stringa è stata dichiarata come puntatore al tipo char (ovvero come puntatore di tipo char *), poi tale puntatore è stato utilizzato come parametro per la funzione printf(). Volendo scrivere il codice in modo più elegante si potrebbe dichiarare apertamente la variabile ricevente come array di caratteri di dimensione indefinita. Il risultato è lo stesso.

#include <stdio.h>

void elabora (char z[])
{
    printf (z);
}

int main (void)
{
    elabora ("ciao\n");
    return 0;
}

Tabella 577.65. Funzioni comuni per la gestione delle stringhe, definite nel file string.h (il modificatore restrict viene descritto in una sezione apposita).

Funzione Descrizione
char *strcpy (char *restrict dst,
              const char *restrict org);
char *strncpy (char *restrict dst,
               const char *restrictorg,
               size_t n);
La funzione strcpy() copia il contenuto della stringa org nella stringa dst, compreso il carattere di terminazione <NUL>. Perché l'operazione possa avvenire è necessario che le due stringhe non si sovrappongano e che per la stringa di destinazione ci sia abbastanza spazio per i caratteri da copiare. La funzione restituisce il puntatore all'inizio della stringa di destinazione.
La funzione strncpy() si comporta sostanzialmente come strcpy(), con la differenza che copia al massimo n caratteri, aggiungendo comunque il carattere di terminazione <NUL>.
char *strcat (char *restrict dst,
              const char *restrict org);
char *strncat (char *restrict dst,
               const char *restrict org,
               size_t n);
La funzione strcat() accoda alla stringa dst il contenuto della stringa org, sovrascrivendo il carattere <NUL> che concludeva la prima stringa e aggiungendolo comunque alla fine della copia. Perché l'operazione possa avvenire è necessario che le due stringhe non si sovrappongano e, soprattutto, che ci sia abbastanza spazio disponibile dopo la prima stringa da estendere. La funzione restituisce il puntatore alla prima stringa.
La funzione strncat() si comporta sostanzialmente come strcat(), con la differenza che copia al massimo n caratteri dalla seconda stringa, aggiungendo comunque il carattere di terminazione <NUL>.
int strcmp (const char *str_1, const char *str_2);
int strcoll (const char *str_1, const char *str_2);
int strncmp (const char *str_1, const char *str_2,
             size_t n);
La funzione strcmp() confronta due stringhe e restituisce zero nel caso siano uguali, oppure un valore minore di zero se la prima stringa è minore della seconda, oppure un valore maggiore di zero se la prima stringa è maggiore della seconda.
La funzione strcoll() funziona sostanzialmente come strcmp(), con la differenza che il confronto ha luogo tenendo conto della configurazione locale (precisamente la categoria LC_COLLATE).
La funzione strncmp() si comporta sostanzialmente come strcmp(), con la differenza che confronta al massimo n caratteri.
char *strchr (const char *str, int c);
char *strrchr (const char *str, int c);
La funzione strchr() cerca nella stringa str il carattere c (il carattere che si ottiene riducendo il valore di c a quello di un tipo char), includendo nella ricerca anche il carattere di terminazione <NUL>. La funzione restituisce un puntatore al carattere trovato, oppure restituisce il puntatore nullo se questo non c'è.
La funzione strrchr() si comporta sostanzialmente come strchr(), con la differenza che cerca l'ultima corrispondenza disponibile nella stringa.
char *strpbrk (const char *str_1,
               const char *str_2);
La funzione strpbrk() cerca nella stringa str_1 la prima corrispondenza con uno qualsiasi dei caratteri contenuti nella stringa str_2. Restituisce il puntatore al carattere trovato nella stringa str_1 che soddisfi la condizione; se non trova alcuna corrispondenza restituisce il puntatore nullo.
size_t strspn (const char *str_1,
               const char *str_2);
size_t strcspn (const char *str_1,
               const char *str_2);
La funzione strspn() conta la lunghezza massima della sottostringa iniziale di str_1 che contiene soltanto caratteri dell'insieme contenuto nella stringa str_2.
La funzione strcspn() svolge il compito opposto, di contare la lunghezza massima della sottostringa iniziale di str_2, contenente solo caratteri che non fanno parte dell'insieme contenuto in str_2.
size_t strlen (const char *str);
La funzione strlen() restituisce la quantità di caratteri contenuta nella stringa, escluso il carattere di terminazione <NUL>.

Nel capitolo introduttivo, in occasione della descrizione delle costanti letterali per i tipi di dati primitivi, è già stato descritto il modo con cui si possono rappresentare alcuni caratteri speciali attraverso delle sequenze di escape che vengono annotate qui, nuovamente, per maggiore comodità del lettore, in quanto quelle sequenze sono valide anche nelle stringhe letterali.

Tabella 577.66. Elenco dei modi di rappresentazione delle costanti carattere attraverso codici di escape.

Codice di escape Descrizione
\ooo
Notazione ottale.
\xhh
Notazione esadecimale.
\\
Una singola barra obliqua inversa (\).
\'
Un apice singolo destro.
\"
Un apice doppio.
\?
Un punto interrogativo. Si usa in quanto le sequenze trigraph sono formate da un prefisso di due punti interrogativi.
\0
Il codice <NUL>.
\a
Il codice <BEL> (bell).
\b
Il codice <BS> (backspace).
\f
Il codice <FF> (formfeed).
\n
Il codice <LF> (linefeed).
\r
Il codice <CR> (carriage return).
\t
Una tabulazione orizzontale (<HT>).
\v
Una tabulazione verticale (<VT>).

577.11   Parametri della funzione main()

La funzione main(), se viene dichiarata con i suoi parametri tradizionali, permette di acquisire la riga di comando utilizzata per avviare il programma. La dichiarazione completa è la seguente:

int main (int argc, char *argv[])
{
    ...
}

Gli argomenti della riga di comando vengono convertiti in un array di stringhe (cioè di puntatori a char), in cui il primo elemento è il nome utilizzato per avviare il programma e gli elementi successivi sono gli altri argomenti. Il primo parametro, argc, serve a contenere la quantità di elementi del secondo, argv[], il quale è array di stringhe da scandire. È il caso di annotare che questo array dovrebbe avere sempre almeno un elemento: il nome utilizzato per avviare il programma e, di conseguenza, argc è sempre maggiore o uguale a uno.(3)

L'esempio seguente mostra in che modo gestire tale array, con la semplice riemissione degli argomenti attraverso lo standard output.

:-)

#include <stdio.h>

int main (int argc, char *argv[])
{
    int i;

    printf ("Il programma si chiama %s\n", argv[0]);

    for (i = 1; i < argc; i++)
      {
        printf ("argomento n. %d: %s\n", i, argv[i]);
      }
}

In alternativa, ma con lo stesso effetto, l'array di puntatori a stringhe può essere definito nel modo seguente, come puntatore di puntatori a caratteri:

:-(

int main (int argc, char **argv)
{
    ...
}

Figura 577.70. Schematizzazione di ciò che accade alla chiamata della funzione main(), con un esempio.

ls -l /home/tizio

Chi è abituato a utilizzare linguaggi di programmazione più evoluti del C, può trovare strano che non si possa scrivere main (int argc, char argv[][]) e usare di conseguenza l'array. Il motivo per cui ciò non è possibile dipende dal fatto che gli array a più dimensioni sono ottenuti attraverso sottoinsiemi uniformi del tipo dichiarato, così, in questo caso le stringhe dovrebbero essere della stessa dimensione, ma evidentemente ciò non corrisponde alla realtà. Inoltre, la dichiarazione della funzione dovrebbe contenere le dimensioni dell'array che non possono essere note. Pertanto, un array formato da stringhe diseguali, può essere ottenuto solo come array di puntatori al tipo char.

577.12   Puntatori a puntatori

Una variabile puntatore potrebbe fare riferimento a un'area di memoria contenente a sua volta un puntatore per un'altra area. Per dichiarare una cosa del genere, si possono usare più asterischi, come nell'esempio seguente:

int i = 123;
int *p = &i;       // Puntatore al tipo "int".
int **pp = &p;     // Puntatore di puntatore al tipo "int".
int ***ppp = &pp;  // Puntatore di puntatore di puntatore al tipo "int".

Il risultato si potrebbe rappresentare graficamente come nello schema seguente:

puntatore di puntatore

Per dimostrare in pratica il funzionamento di questo meccanismo di riferimenti successivi, si può provare con il programma seguente:

#include <stdio.h>
int main (void)
{
    int i = 123;
    int *p = &i;       // Puntatore al tipo "int".
    int **pp = &p;     // Puntatore di puntatore al tipo "int".
    int ***ppp = &pp;  // Puntatore di puntatore di puntatore al tipo "int".

    printf ("i, p, pp, ppp: %d, %u, %u, %u\n",
             i, (unsigned int) p, (unsigned int) pp, (unsigned int) ppp);

    printf ("i, p, pp, *ppp: %d, %u, %u, %u\n",
             i, (unsigned int) p, (unsigned int) pp, (unsigned int) *ppp);

    printf ("i, p, *pp, **ppp: %d, %u, %u, %u\n",
             i, (unsigned int) p, (unsigned int) *pp, (unsigned int) **ppp);

    printf ("i, *p, **pp, ***ppp: %d, %d, %d, %d\n",
             i, *p, **pp, ***ppp);

    return 0;
}

Eseguendo il programma si dovrebbe ottenere un risultato simile a quello seguente, dove si può verificare l'effetto delle dereferenziazioni applicate alle variabili puntatore:

i, p, pp, ppp: 123, 3217933736, 3217933732, 3217933728
i, p, pp, *ppp: 123, 3217933736, 3217933732, 3217933732
i, p, *pp, **ppp: 123, 3217933736, 3217933736, 3217933736
i, *p, **pp, ***ppp: 123, 123, 123, 123

Pertanto si può ricostruire la disposizione in memoria delle variabili:

puntatore di puntatore

Come si può comprendere facilmente, la gestione di puntatori a puntatore è difficile e va usata con prudenza e solo quando ne esiste effettivamente l'utilità. Va notato anche che si ottiene la dereferenziazione (la traduzione di un puntatore nel contenuto di ciò a cui punta) usando la notazione tipica degli array, ma questo fatto viene descritto nella sezione successiva.

577.13   Puntatori a più dimensioni

Un array di puntatori consente di realizzare delle strutture di dati ad albero, non più uniformi come invece devono essere gli array a più dimensioni consueti. L'esempio seguente mostra la dichiarazione di tre array di interi, con una quantità di elementi disomogenea, e la successiva dichiarazione di un array di puntatori di tipo int *, a cui si assegnano i riferimenti ai tre array precedenti. Nell'esempio appare poi un tipo di notazione per accedere ai dati terminali che dovrebbe risultare intuitiva, ma se ne possono usare delle altre:

#include <stdio.h>

int main (void)
{
    int a[] = {1, 2, 3, 4};
    int b[] = {5, 6,};
    int c[] = {7, 8, 9};
    int *x[] = {a, b, c};

    printf ("*x[0] = {%d, %d, %d, %d}\n", *x[0], *(x[0]+1), *(x[0]+2),
                                          *(x[0]+3));
    printf ("*x[1] = {%d, %d}\n", *x[1], *(x[1]+1));
    printf ("*x[2] = {%d, %d, %d}\n", *x[2], *(x[2]+1), *(x[2]+2));

    return 0;
}

La figura successiva dovrebbe facilitare la comprensione del senso dell'array di puntatori. Come si può osservare, per accedere agli elementi degli array a cui puntano quelli di x è necessario dereferenziare gli elementi. Pertanto, *x[0] corrisponde al contenuto del primo elemento del primo sotto-array, *(x[0]+1) corrisponde al contenuto del secondo elemento del primo sotto-array e così di seguito. Dal momento che i sotto-array non hanno una quantità uniforme di elementi, non è semplice la loro scansione.

Figura 577.77. Schematizzazione del significato dell'array di puntatori definito nell'esempio.

array di puntatori

Si potrebbe obbiettare che la scansione di questo array di puntatori a array può avvenire ugualmente in modo sequenziale, come se fosse un array «normale» a una sola dimensione. Molto probabilmente ciò è possibile effettivamente, dal momento che è probabile che il compilatore disponga le variabili in memoria in sequenza, come si vede nella figura successiva, ma ciò non può essere garantito.

Figura 577.78. La disposizione più probabile delle variabili dell'esempio.

array di puntatori

Se invece di un array di puntatori si ha un puntatore di puntatori, il meccanismo per l'accesso agli elementi terminali è lo stesso. L'esempio seguente contiene la dichiarazione di un puntatore a puntatori di tipo intero, a cui viene assegnato l'indirizzo dell'array già descritto. La scansione può avvenire nello stesso modo, ma ne viene proposto uno alternativo e più chiaro, con il quale si comprende cosa si intende per puntatore a più dimensioni:

#include <stdio.h>

int main (void)
{
    int a[] = {1, 2, 3, 4};
    int b[] = {5, 6,};
    int c[] = {7, 8, 9};
    int *x[] = {a, b, c};
    int **y = x;

    printf ("*x[0] = {%d, %d, %d, %d}\n", y[0][0], y[0][1],
                                          y[0][2], y[0][3]);
    printf ("*x[1] = {%d, %d}\n", y[1][0], y[1][1]);
    printf ("*x[2] = {%d, %d, %d}\n", y[2][0], y[2][1], y[2][2]);

    return 0;
}

Come si vede, la variabile y viene usata come se fosse un array a due dimensioni, ma lo stesso sarebbe valso per la variabile x, in qualità di array di puntatori.

Per capire cosa succede, occorre fare mente locale al fatto che il nome di una variabile puntatore seguito da un numero tra parentesi quadre corrisponde alla dereferenziazione dell'n-esimo elemento successivo alla posizione a cui punta tale variabile, mentre il valore puntato in sé corrisponde all'elemento zero (ciò è come dire che *p equivale a p[0]). Quindi, scrivere *(p+n) è esattamente uguale a scrivere p[n]. Se il valore a cui punta una variabile puntatore è a sua volta un puntatore, per dereferenziarlo occorrono due fasi: per esempio **p è il valore che si ottiene dereferenziando il primo puntatore e quello che si trova nella prima destinazione (quindi **p equivale a *p[0] e a p[0][0]). Volendo gestire gli indici si possono considerare equivalenti i puntatori: *(*(p+m)+n), *(p[m]+n), (p[m])[n] e p[m][n].

Figura 577.80. Tanti modi alternativi per raggiungere lo stesso elemento.

array di puntatori

Seguendo lo stesso ragionamento si possono gestire strutture ad albero più complesse, con più livelli di puntatori, ma qui non vengono proposti esempi di questo tipo.

Sia l'array di puntatori, sia il puntatore a puntatori, possono essere gestiti con gli indici come se si trattasse di un array a più dimensioni. Pertanto, la notazione a[m][n] può rappresentare l'elemento m,n di un array a ottenuto secondo la rappresentazione «normale» a matrice, oppure secondo uno schema ad albero attraverso dei puntatori: la differenza sta solo nella presenza o meno di elementi costituiti da puntatori.

577.14   Puntatori e funzioni

Nello standard del linguaggio C, la dichiarazione di una funzione è in pratica la definizione di un puntatore al codice della stessa, un po' come accade con gli array. In generale, è possibile dichiarare dei puntatori a un tipo di funzione definito in base al valore restituito e ai tipi di parametri richiesti, attraverso una forma che richiama quella del prototipo di funzione. Il modello seguente è quello della dichiarazione del prototipo:

tipo nome_funzione (tipo_parametro[ nome_parametro][,...]);

Questo è invece il modello della dichiarazione del puntatore:

tipo (*nome_puntatore) (tipo_parametro[ nome_parametro][,...]);

L'esempio seguente mostra la dichiarazione di un puntatore a una funzione che restituisce un valore di tipo int e utilizza due parametri di tipo int:

int (*f) (int, int);

L'esempio seguente è equivalente, con la differenza che si nominano i parametri, anche se ciò è perfettamente inutile, esattamente come nei prototipi delle funzioni:

int (*f) (int i, int j);

L'assegnamento del puntatore avviene nel modo più semplice possibile, trattando il nome della funzione nello stesso modo in cui si fa con gli array: come un puntatore.

int (*f) (int, int);     // Puntatore a funzione.
int prodotto (int, int); // Prototipo di funzione descritta più avanti.
...
f = prodotto; // Il puntatore «f» contiene il riferimento alla funzione.

Una volta assegnato il puntatore, si può eseguire una chiamata di funzione semplicemente utilizzando il puntatore, per cui, i due esempi seguenti sono equivalenti:

i = f (2, 3);
i = prodotto (2, 3);

Nel linguaggio C precedente allo standard ANSI, perché il puntatore potesse essere utilizzato in una chiamata di funzione, occorreva indicare l'asterisco, in modo da dereferenziarlo:

i = (*f) (2, 3);         // Non serve più.

Per concludere viene mostrato un esempio completo, anche se banalizzato: la funzione f() restituisce un numero intero ottenuto incrementando di una unità l'argomento ricevuto. Questa funzione viene chiamata attraverso un puntatore denominato pf.

#include <stdio.h>

int f (int i)
{
    return (i + 1);
}

int main (void)
{
    int x = 4;
    int y;
    int (*pf) (int i);
    pf = f;
    y = pf (x);
    printf ("%d + 1 = %d\n", x, y);
    return 0;
}

Riquadro 577.88. Confusione tra le dichiarazioni.

L'interpretazione umana del linguaggio, a proposito dei puntatori, può essere complicata, pertanto l'uso dei puntatori deve essere fatto con criterio, senza abusarne. Gli esempi seguenti sono solo i più semplici:

int f (...); /* dichiarazione della funzione f() che restituisce un valore intero; */

int *f (...); /* dichiarazione della funzione f() che restituisce un puntatore a un intero; */

int (*f) (...); /* dichiarazione del puntatore f a una funzione che restituisce un intero; */

int *(*f) (...); /* dichiarazione del puntatore f a una funzione che restituisce un puntatore a un intero. */

Ancora più difficile sarebbe dichiarare una funzione che restituisce un array, o peggio, un puntatore a un array.

577.14.1   Puntatori a funzione, membri di una struttura

Le strutture sono descritte in un altro capitolo (579), tuttavia è opportuno annotare qui in che modo possa essere utilizzato un puntatore a una funzione, quando è un membro di una struttura:

struttura.membro (argomenti);
(*struttura.membro) (argomenti);

I due modelli sono equivalenti e si riferiscono alla chiamata di una funzione, il cui puntatore è costituito dalla variabile struttura.membro. È evidente che risulta più comprensibile la prima delle due modalità. A titolo di esempio, ipotizzando la struttura totale e il membro sottrai, per una funzione che riceve un argomento di tipo intero (precisamente il numero 7), la chiamata potrebbe essere scritta indifferentemente nei due modi successivi:

...
totale.sottrai (7);
...
...
(*totale.sottrai) (7);
...

577.15   Puntatori a variabili distrutte

L'esempio seguente potrebbe funzionare, ma contiene un errore di principio:

:-(

#include <stdio.h>

double *f (void)
{
    double x = 1234.5678;
    return &x;                  // Orrore!
}    

int main (int argc, char *argv[])
{
    double *p;
    p = f ();
    printf ("x = %f\n", *p);
    return 0;
}

La funzione f() dichiara localmente una variabile che inizializza al valore 1 234,567 8, quindi restituisce il puntatore a questa variabile. A parte il fatto che il compilatore possa segnalare o meno la cosa, non si può utilizzare un puntatore rivolto a un'area di memoria che, almeno teoricamente, non è più allocata. In altri termini, se si costruisce un puntatore a qualcosa, occorre tenere sempre presente il ciclo di vita della sua destinazione e non solo della variabile che contiene tale riferimento.

Purtroppo questa attenzione non viene imposta e, generalmente, il compilatore consente di usare un puntatore a variabili che, formalmente, sono già state distrutte.

577.16   Puntatore nullo

Il linguaggio C prescrive che si possa assegnare a una variabile puntatore il valore zero, in qualità di numero intero:

...
double *p = 0;
...

Il puntatore che contiene il valore zero è indefinito, nel senso che punta a un'area di memoria irraggiungibile. Un puntatore di questo tipo è noto come puntatore nullo o null pointer; inoltre, due puntatori nulli, qualunque sia il tipo di dati a cui si riferiscono, sono uguali in una comparazione. Pertanto si potrebbe verificare la validità di un puntatore nel modo seguente:

...
char *p = 0;
...
if (p == 0)
  {
    // Null pointer.
    ...
  }
...

A ogni modo, lo standard prescrive che nel file stddef.h sia definita la macro-variabile NULL, a rappresentare formalmente un puntatore nullo:

#include <stddef.h>
...
char *p = NULL;
...
if (p == NULL)
  {
    // Null pointer.
    ...
  }
...

Va osservato che la variabili puntatore, quando acquisiscono un indirizzo in base al verificarsi di certe condizioni, vanno inizializzate opportunamente al valore nullo (come già apparso negli esempi), in modo da poter poi verificare se hanno ottenuto o meno un tale indirizzo.

577.17   Utilizzo della memoria in modo dinamico

L'allocazione dinamica della memoria avviene generalmente attraverso la funzione malloc(), oppure calloc(), definite nella libreria standard stdlib.h. Se queste riescono a eseguire l'operazione, restituiscono il puntatore alla memoria allocata, altrimenti restituiscono il valore NULL.

void *malloc (size_t dimensione);
void *calloc (size_t quantità, size_t dimensione);

La differenza tra le due funzioni sta nel fatto che la prima, malloc(), viene utilizzata per allocare un'area di una certa dimensione, espressa generalmente in byte, mentre la seconda, calloc(), permette di indicare una quantità di elementi e si presta per l'allocazione di array.

Dovendo utilizzare queste funzioni per allocare della memoria, è necessario conoscere la dimensione dei tipi primitivi di dati, ma per evitare incompatibilità conviene farsi aiutare dall'operatore sizeof.

Il valore restituito da queste funzioni è di tipo void * cioè una specie di puntatore neutro, indipendente dal tipo di dati da utilizzare. Per questo, in linea di principio, prima di assegnare a un puntatore il risultato dell'esecuzione di queste funzioni di allocazione, è opportuno eseguire un cast.

int *pi = NULL;
...
pi = (int *) malloc (sizeof (int));

if (pi != NULL)
  {
    // Il puntatore è valido e allora procede.
    ...
  }
else
  {
    // La memoria non è stata allocata e si fa qualcosa
    // di alternativo.
    ...
  }

Come si può osservare dall'esempio, il cast viene eseguito con la notazione (int *) che richiede la conversione esplicita in un puntatore a int. Lo standard C non richiede l'utilizzo di questo cast, quindi l'esempio si può ridurre al modo seguente:

...
pi = malloc (sizeof (int));
...

La memoria allocata dinamicamente deve essere liberata in modo esplicito quando non serve più. Infatti, il linguaggio C non offre alcun meccanismo di raccolta della spazzatura o garbage collector. Per questo si utilizza la funzione free() che richiede semplicemente il puntatore e non restituisce alcunché.

void free (void *puntatore);

È necessario evitare di deallocare più di una volta la stessa area di memoria, perché ciò potrebbe provocare effetti imprevedibili.

int *pi = NULL;
...
pi = (int *) malloc (sizeof (int));

if (pi != NULL)
  {
    // Il puntatore è valido e allora procede.
    ...
    free (pi); // Libera la memoria
    pi = NULL; // e per sicurezza azzera il puntatore.
    ...
  }
else
  {
    // La memoria non è stata allocata e si fa qualcosa
    // di alternativo.
    ...
  }

Lo standard prevede una funzione ulteriore, per la riallocazione di memoria: realloc(). Questa funzione si usa per ridefinire l'area di memoria con una dimensione differente:

void *realloc (void *puntatore, size_t dimensione);

In pratica, la riallocazione deve rendere disponibili gli stessi contenuti già utilizzati, salvo la possibilità che questi siano stati ridotti nella parte terminale. Se invece la dimensione richiesta nella riallocazione è maggiore di quella precedente, lo spazio aggiunto può contenere dati casuali. Il funzionamento di realloc() non è garantito, pertanto occorre verificare nuovamente, dopo il suo utilizzo, che il puntatore ottenuto sia ancora valido.

577.18   Puntatori «ristretti»

Lo standard del linguaggio C prevede il modificatore restrict per le variabili puntatore, da usare come nell'esempio seguente:

...
int *restrict p;
...

L'utilizzo di tale modificatore equivale a una dichiarazione di intenti (ovvero una promessa) che il programmatore fa al compilatore, nei riguardi del puntatore. Precisamente si dichiara che il puntatore viene usato per accedere ad aree di memoria in modo esclusivo, nel senso che nell'ambito del contesto a cui si fa riferimento, non esistono altri accessi alle stesse aree per mezzo di altri puntatori o di altre variabili. Partendo da questo presupposto, il compilatore può ottimizzare il risultato della compilazione semplificando il codice finale.

La definizione formale del significato di questo modificatore è molto complessa e il compilatore non è in grado di segnalarne un uso improprio. Ciò significa che va usata questa possibilità con prudenza, solo quando si ritiene di averne capito il senso e l'utilità.

Come esempio iniziale si può osservare il prototipo della funzione standard strcpy():

char *strcpy (char *restrict dst, const char *restrict org);

Ci sono due parametri costituiti da stringhe che non devono risultare sovrapposte e in questo caso, il vincolo restrict è appropriato per esprimere il concetto: se entrambi i puntatori delle stringhe sono dichiarati con il modificatore restrict, è evidente che le stringhe rispettive non devono sovrapporsi.

L'impegno che il programmatore prende utilizzando il modificatore restrict è finalizzato solo al favorire l'ottimizzazione della compilazione.

La promessa che un programmatore fa dichiarando un puntatore restrict è limitata al campo di azione del puntatore stesso. Per esempio, tornando all'esempio del prototipo della funzione strcpy(), lì si intende che i parametri vengono usati nella funzione senza sovrapposizioni, ma, dato il contesto, rimane il fatto che le stringhe fornite come argomento della chiamata debbano già rispettare il vincolo di non essere sovrapposte.

Esempio 577.99. Viene allocata un'area di memoria composta da 100 elementi della grandezza di un intero normale. I primi 50 elementi vengono scanditi con il puntatore r1 mentre quelli restanti con il puntatore r2. Nell'esempio, agli elementi r1[i] viene assegnato il valore di r2[i]+1, anche se il fatto in sé non ha una grande importanza.

int *restrict r1, *restrict r2;
int *m = malloc (100 * sizeof (int));
int i;
 
r1 = m;         // r1 viene usato per i primi 50 elementi.
r2 = m + 50;    // r2 viene usato per i 50 elementi successivi.

for (i = 0; i < 50; i++)
  {
    r1[i] = r2[i] + 1;
  }

Esempio 577.100. Viene allocata un'area di memoria composta da 100 elementi della grandezza di un intero normale. Gli elementi pari vengono scanditi con il puntatore r1 mentre quelli dispari con il puntatore r2. Nell'esempio, agli elementi r1[j] viene assegnato il valore di r2[j]+1, anche se il fatto in sé non ha una grande importanza.

int *restrict r1, *restrict r2;
int *m = malloc (100 * sizeof (int));
int i;
int j;
     
r1 = m;         // r1 viene usato per gli elementi con indice pari.
r2 = m + 1;     // r2 viene usato per gli elementi con indice dispari.

for (i = 0; i < 50; i++)
  {
    j = i * 2;
    r1[j] = r2[j] + 1;
  }

Se il compilatore non riconosce il modificatore restrict significa solo che non è in grado di ottimizzare il codice in un certo modo, ma non è necessario modificare il proprio programma per togliere la parola chiave relativa, perché è sufficiente sfruttare una macro-variabile del precompilatore, a cui non si assegna alcun valore:

...
#define restrict
...

577.19   Riferimenti


1) Una variabile potrebbe rappresentare un registro del microprocessore e in tal caso non si potrebbe costruire un puntatore alla stessa. Pertanto, l'argomento sui puntatori parte dal presupposto che le variabili a cui eventualmente si vuole fare riferimento tramite un puntatore siano allocate in memoria.

2) Per dereferenziare un puntatore si usa generalmente l'asterisco davanti al nome, pertanto il valore a cui punta la variabile p è accessibile attraverso l'espressione *p. Tuttavia esiste un altro modo che viene chiarito a proposito dell'aritmetica dei puntatori, per cui lo stesso valore si raggiunge con l'espressione p[0].

3) In contesti particolari è ammissibile che argc sia pari a zero, a indicare che non viene fornita alcuna informazioni; oppure, se gli argomenti vengono forniti ma il nome del programma è assente, argv[0][0] deve essere pari a <NUL>, ovvero al carattere nullo.


Appunti di informatica libera 2008 --- Copyright © 2000-2008 Daniele Giacomini -- <appunti2 (ad) gmail·com>


Dovrebbe essere possibile fare riferimento a questa pagina anche con il nome c_puntatori_array_stringhe_e_allocazione_dinamica_della_memo.htm

[successivo] [precedente] [inizio] [fine] [indice generale] [indice ridotto] [indice analitico] [home]

Valid ISO-HTML!

CSS validator!

Gjlg Metamotore e Web Directory