Uso dei puntatori

I puntatori sono utilizzati sostanzialmente per tre scopi:

  1. Realizzazione di strutture dati dinamiche (es. liste linkate);
  2. Realizzazione di funzioni con effetti laterali sui parametri attuali;
  3. Ottimizzare il passaggio di parametri di grosse dimensioni.
Il primo caso e` tipico di applicazioni che necessitano di strutture dati che si espandano e si comprimano dinamicamente durante l'esecuzione, ad esempio un editor di testo.
Ecco un esempio:
    #include < iostream.h >

    // Una lista e` composta da tante celle linkate
    // tra di loro; ogni cella contiene un valore
    // e un puntatore alla cella successiva.

    struct TCell {
        float AFloat;  // per memorizzare un valore
        TCell * Next;  // puntatore alla cella successiva
    };

    // La lista viene realizzata tramite questa
    // struttura contenente il numero di celle
    // della lista e il puntatore alla prima cella

    struct TList {
        unsigned Size;  // Dimensione lista
        TCell * First;  // Puntatore al primo elemento
    };

    void main() {
        TList List;        // Dichiara una lista
        List.Size = 0;     // inizialmente vuota
        int FloatToRead;
        cout << "Quanti valori vuoi immettere? " ;
        cin >> FloatToRead;
        cout << endl;
      
        // questo ciclo richiede e memorizza
        // nella lista valori reali

        for(int i=0; i < FloatToRead; ++i) {
            TCell * Temp = List.First;
            cout << "Creazione di una nuova cella..." << endl;
            List.First = new TCell;    // new vuole il tipo di
                                       // variabile da creare
            cout << "Immettere un valore reale " ;
            cin >> List.First -> AFloat;
            cout << endl;
            List.First -> Next = Temp; // aggiunge la cella in testa alla lista
            ++List.Size;               // Aggiorna la dimensione della lista
        }
      
        // il seguente ciclo calcola la somma
        // dei valori contenuti nella lista;
        // via via che recupera i valori,
        // distrugge le relative celle

        float Total = 0.0;
        for(int j=0; j < List.Size; ++j) {
            Total += List.First -> AFloat;
            TCell * Temp = List.First;       // estrae la cella in
            List.First = List.First -> Next; // testa alla lista
            cout << "Distruzione della cella in testa alla lista..." << endl;
            delete Temp;                                    // distrugge la cella estratta
        }
        cout << "Totale = " << Total << endl;
      }
L'esempio mostra come creare e distruggere oggetti dinamicamente.
Il programma memorizza in una lista un certo numero di valori reali, aggiungendo per ogni valore una nuova cella; in seguito li estrae uno ad uno, distruggendo le relative celle, e li somma restituendo il totale. Il codice e` ampiamente commentato e non dovrebbe essere difficile capire come funziona. La creazione di un nuovo oggetto avviene allocando un nuovo blocco di memoria (sufficientemente grande) dalla heap-memory, mentre la distruzione avviene deallocando tale blocco (che ritorna a far parte della heap-memory); l'allocazione viene eseguita tramite l'operatore new cui va specificato il tipo di oggetto da creare (per sapere quanta ram allocare), la deallocazione avviene invece tramite l'operatore delete, che richiede come argomento un puntatore all'aggetto da deallocare (la quantita` di ram da deallocare viene calcolata automaticamente).
In alcuni casi e` necessario allocare e deallocare interi array, in questi casi si ricorre agli operatori new [ ] e delete [ ]:
    // alloca un array di 10 interi
    int * ArrayOfInt = new int [10];

    // ora eseguiamo la deallocazione
    delete [] ArrayOfInt;  
Si noti inoltre che gli oggetti allocati nella heap-memory non ubbidiscono alle regole di scoping statico valide per le variabili ordinarie (tuttavia i puntatori a tali oggetti sono sempre soggetti a tali regole), la loro creazione e distruzione e` compito del programmatore.
Consideriamo ora il secondo uso che si fa dei puntatori.
Esso corrisponde a quello che in Pascal si chiama "passaggio di parametri per variabile" e consente la realizzazione di funzioni con effetti laterali sui parametri:
    void Change(int * IntPtr) {
        *IntPtr = 5;
    }
La funzione Change riceve come unico parametro un puntatore a int, ovvero un indirizzo di una cella di memoria; anche se l'indirizzo viene copiato in una locazione di memoria visibile solo alla funzione, la dereferenzazione di tale copia consente comunque la modifica dell'oggetto puntato:
    int A = 10;

    cout << " A = " << A << endl;
    cout << " Chiamata della funzione Change(&A)... " << endl;
    Change(&A);
    cout << " Ora A = " << A << endl;
l'output che il precedente codice produce e`:
    A = 10
    Chiamata della funzione Change(&A)...
    Ora A = 5
Quello che nell'esempio accade e` che la funzione Change riceve l'indirizzo della variabile A e tramite esso e` in grado di agire sulla variabile stessa.
L'uso dei puntatori come parametri di funzione non e` comunque utilizzato solo per consentire effetti laterali, spesso un funzione riceve parametri di dimensioni notevoli e l'operazione di copia del parametro attuale in un'area privata della funzione ha effetti deleterei sui tempi di esecuzione della funzione stessa; in questi casi e` molto piu` conveniente passare un puntatore:
    void Func(BigParam parametro);

    // funziona, ma e` meglio quest'altra dichiarazione

    void Func(const BigParam * parametro);
Il secondo prototipo e` piu` efficiente perche` evita l'overhead imposto dal passaggio per valore, inoltre l'uso di const previene ogni tentativo di modificare l'oggetto puntato e allo stesso tempo comunica al programmatore che usa la funzione che non esiste tale rischio.
Infine quando l'argomento di una funzione e` un array, il compilatore passa sempre un puntatore, mai una copia dell'argomento; in questo caso inoltre l'unico modo che la funzione ha per conoscere la dimensione dell'array e` quello di ricorrere ad un parametro aggiuntivo, esattamente come accade con la funzione main() (vedi capitolo precedente).
Ovviamente una funzione puo` restituire un tipo puntatore, in questo caso bisogna pero` prestare attenzione a cio` che si restituisce, non e` raro infatti che un principiante scriva qualcosa del tipo:
    int * Sum(int a, int b) {
        int Result = a + b;
        return &Result;
    }
Apparentemente e` tutto corretto e un compilatore potrebbe anche non segnalare niente, tuttavia esiste un grave errore: si ritorna l'indirizzo di una variabile locale. L'errore e` dovuto al fatto che la variabile locale viene distrutta quando la funzione termina e riferire ad essa diviene quindi illecito. Una soluzione corretta sarebbe stata quella di allocare Result nello heap e restituire l'indirizzo di tale oggetto (in questo caso e` cura di chi usa la funzione occuparsi della eventuale deallocazione dell'oggetto).



Reference

I reference (riferimenti) sono un costrutto a meta` tra puntatori e variabili: come i puntatori essi sono contenitori di indirizzi, ma non e` necessario dereferenziarli per accedere all'oggetto puntato (si usano come se fossero variabili). In pratica possiamo vedere i reference come un meccanismo per creare alias di variabili, anche se in effetti questa e` una definizione non del tutto esatta.
Cosi` come un puntatore viene indicato nelle dichiarazioni dal simbolo *, cosi` un reference viene indicato dal simbolo &:
    int Var = 5;
    float f = 0.5;

    int * IntPtr = &Var;
    int & IntRef = Var;   // nei reference non e` necessario
    float & FloatRef = f; // usare & a destra di = 
Le ultime due righe dichiarano rispettivamente un riferimento di tipo int e uno di tipo float che vengono subito inizializzati usando le due variabili dichiarate prima; un riferimento va inizializzato immediatamente, e dopo l'inizializzazione non puo` essere piu` cambiato; si noti che non e` necessario utilizzare l'operatore & (indirizzo di) per eseguire l'inizializzazione. Dopo l'inizializzazione il riferimento potra` essere utilizzato in luogo della variabile cui e` legato, utilizzare l'uno o l'altro sara` indifferente:
    cout << "Var = " << Var << endl;
    cout << "IntRef = " << IntRef << endl;
    cout << "Assegnamento a IntRef..." << endl;
    IntRef = 8;
    cout << "Var = " << Var << endl;
    cout << "IntRef = " << IntRef << endl;
    cout << "Assegnamento a Var..." << endl;
    Var = 15;
    cout << "Var = " << Var << endl;
    cout << "IntRef = " << IntRef << endl;
Ecco l'output del precedente codice:
    Var = 5
    IntRef = 5
    Assegnamento a IntRef...
    Var = 8
    IntRef = 8;
    Assegnamento a Var...
    Var = 15
    IntRef = 15
Dall'esempio si capisce perche`, dopo l'inizializzazione, un riferimento non possa essere piu` associato ad un nuovo oggetto: ogni assegnamento al riferimento si traduce in un assegnamento all'oggetto riferito.
Un riferimento puo` essere inizializzato anche tramite un puntatore:
    int * IntPtr = new int(5);
    // il valore tra parentesi specifica il valore cui
    // inizializzare l'oggetto allocato. Per adesso il
    // metodo funziona solo con i tipi primitivi.
                
    int & IntRef = *IntPtr;
Si noti che il puntatore va dereferenziato, altrimenti si legherebbe il riferimento al puntatore (in questo caso l'uso del riferimento comporta implicitamente un conversione da int * a int).
Ovviamente il metodo puo` essere utilizzato anche con l'operatore new:
    double & DoubleRef = *new Double;

    // Ora si puo` accedere all'oggetto allocato
    // tramite il riferimento.

    DoubleRef = 7.3;

    // Di nuovo, e` compito del programmatore
    // distruggere l'oggetto crato con new

    delete &DoubleRef;
        
    // Si noti che va usato l'operatore &, per
    // indicare l'intenzione di deallocare
    // l'oggetto riferito, non il riferimento!
L'uso dei riferimenti per accedere a oggetti dinamici e` sicuramente molto comodo perche` e` possibile uniformare tali oggetti alle comuni variabili, tuttavia e` una pratica che bisognerebbe evitare perche` puo` generare confusione e di conseguenza errori assai insidiosi.



Uso dei reference

Nel paragrafo precedente sono stati mostrati alcuni possibili usi dei riferimenti. In verita` comunque i riferimenti sono stati introdotti nel C++ come ulteriore meccanismo di passaggio di parametri (per riferimento).
Una funzione che debba modificare i parametri attuali puo` ora essere dichiarata in due modi diversi:
    void Esempio(Tipo * Parametro);
oppure in modo del tutto equivalente
    void Esempio(Tipo & Parametro);
Naturalmente cambierebbe il modo in cui chiamare la funzione:
    long double Var = 0.0;
    long double * Ptr = &Var;
        
    // nel primo caso avremmo
    Esempio(&Var);

    // oppure
    Esempio(Ptr);

    // nel caso di passaggio per riferimento
    Esempio(Var);
In modo del tutto analogo a quanto visto con i puntatori e` anche possibile ritornare un riferimento:
    double & Esempio(float Param1, float Param2) {
        /* ... */
        double * X = new double;
        /* ... */
        return *X;
    }
Puntatori e reference possono essere liberamente scambiati, non esiste differenza eccetto che non e` necessario dereferenziare un riferimento.
Probabilmente vi starete chiedendo che motivo c'era dunque di introdurre questa caratteristica, dato che i puntatori erano gia` sufficienti? Il problema in effetti non nasce con le funzioni, ma con gli operatori; il C++ consente anche l'overloading degli operatori e sarebbe spiacevole dover scrivere qualcosa del tipo:
    &A + &B
non si riuscirebbe a capire se si desidera sommare due indirizzi oppure i due oggetti (che potrebbero essere troppo grossi per passarli per valore). I riferimenti invece risolvono il problema eliminando ogni possibile ambiguita` e consentendo una sintassi piu` chiara.



Puntatori vs reference

Visto che per le funzioni e` possibile scegliere tra puntatori e riferimenti, come decidere quale metodo scegliere? I riferimenti hanno un vantaggio sui puntatori, dato che nella chiamata di una funzione non c'e` differenza tra passaggio per valore o per riferimento, e` possibile cambiare meccanismo senza dover modificare ne` il codice che chiama la funzione ne` il corpo della funzione stessa. Tuttavia il meccanismo dei reference nasconde all'utente il fatto che si passa un indirizzo e non una copia, e cio` puo` creare grossi problemi in fase di debugging.
Quando e` necessario passare un indirizzo e` quindi meglio usare i puntatori, che consentono un maggior controllo sugli accessi (tramite la keyword const) e rendono esplicito il modo in cui il parametro viene passato. Esiste comunque una eccezione nel caso dei tipi definiti dall'utente tramite il meccanismo delle classi. In questo caso vedremo che l'incapsulamento garantisce che l'oggetto passato possa essere modificato solo da particolari funzioni (funzioni membro e funzioni amiche), e quindi usare i riferimenti e` piu`conveniente perche` non e` necessario dereferenziarli, migliorando cosi` la chiarezza del codice; le funzioni membro e le funzioni amiche, in quanto tali, sono invece autorizzate a modificare l'oggetto e quindi quando vengono usate l'utente sa gia` che potrebbero esserci effetti laterali.


Pagina precedente - Pagina successiva



C++, una panoramica sul linguaggio - © Copyright 1997, Paolo Marotta