L’altra faccia della medaglia • Puntatori II

L

 

L’altra faccia della medaglia

Per quanto visto fin’ora sulle variabili puntatore, esse possono sembrare solo inutili e pericolose. Vedremo invece un elevato numero di situazioni nelle quali l’uso dei puntatori è imprescindibile per un corretto ed efficiente funzionamento del programma. In questo articolo tratteremo del passaggio dei parametri a una funzione per riferimento e analizzaremo l’uso del qualificatore “const”.

 

PASSAGGIO DEI PARAMETRI PER RIFERIMENTO

Premetto che una conoscenza, almeno basilare, riguardante sia l’argomento “scope delle variabili” che l’argomento “funzioni” è data per scontata nel lettore. Inoltre vi ricordo che qualsiasi passaggio di parametri in una chiamata a funzione viene effettuato tramite copia.
Per questo motivo, in automatico, nel momento in cui passiamo una variabile a una funzione, il suo valore viene copiato nel parametro corrispondente della funzione stessa. 
Qualsiasi modifica effettuata non avrà alcun effetto sulla variabile originaria.

#include <iostream>

void function(int number)
{
    /* ricordo che la variabile number presente nello scope di questa funzione,
     * sebbene abbia lo stesso identificatore di quella presente nel main,
     * non ha nulla a che fare con essa 
     * semplicemente il valore della number del main viene copiato in questa number
     * durante la chiamata a funzione */
    
    number = 0;
}

int main(int argc, char *argv[])
{
    int number = 2017;

    // alla funzione verrà passata una copia del valore contenuto in number
    function(number);

    // il valore stampato sarà 2017
    std::cout << number << std::endl;
}

Non è possibile andare contro questo comportamento, ma possiamo sfruttarlo a nostro favore: passeremo una copia dell’indirizzo della nostra variabile. L’argomento della nostra funzione non sarà una variabile ordinaria, ma una puntatore, in modo che tramite la dereferenziazione agiremo direttamente sulla variabile interessata.

#include <iostream>

void function(int * pointerToInteger)
{
    // pointerToInteger ha memorizzato l'indirizzo della variabile number
    
    // dereferenzio l'indirizzo, agendo quindi direttamente sulla variabile puntata
    *pointerToInteger = 0; 
} 

int main(int argc, char *argv[]) 
{ 
    int number = 2017;
    
    // alla funzione verrà passata una copia dell'indirizzo di number
    // notare l'uso dell'operatore & per estrapolarlo
    function(&number);
    
    // il valore stampato sarà 0
    std::cout << number << std::endl;
}

Questa soluzione prende il nome di passaggio dei parametri per riferimento, poiché sebbene venga passata pur sempre una copia di un valore, quel valore corrisponde ad un indirizzo.

Non utilizzando il passaggio per riferimento, nel caso in cui volessimo modificare una variabile tramite una funzione, saremmo costretti ad assistere a una doppia operazione di copia.

#include <iostream>

int function(int number)
{
    /* il valore della number del main viene copiato nella number
     * presente in questo scope durante la chiamata a funzione */    
    number = number - 10;
    return number;
} 

int main(int argc, char *argv[]) 
{ 
    int number = 2017;
    
    // alla funzione verrà passata una copia del valore contenuto in number
    // il valore restituito dalla funzione verrà a sua volta copiato in number
    number = function(number);
    
    // il valore stampato sarà 2007
    std::cout << number << std::endl;
}

Nel caso di variabili di piccole dimensioni non è semplice afferrare il reale problema: l’operazione di copia è più lenta quanto più aumenta il numero di byte da copiare. Perciò sia che una funzione esterna debba modificare il valore di una variabile, sia che debba semplicemente analizzarla (in questo caso avverrebbe una sola operazione di copia), è più efficiente passare alla funzione solamente l’indirizzo della variabile in questione.
Dato che in entrambi i linguaggi (C/C++) è possibile definire tipi di dato personalizzati tramite l’uso di strutture e classi, potendo facilmente raggiungere dimensioni considerevoli, è indispensabile utilizzare i puntatori nella loro gestione.

 

USO DEL QUALIFICATORE CONST

Esaminiamo quindi il caso in cui una funzione debba analizzare una variabile di dimensioni considerevoli, senza però modificarla. Questo ci permette di introdurre il qualificatore “const”, ovviamente utilizzato nell’ambito dei puntatori.
Utilizzeremo una semplice classe per definire un tipo di dato personalizzato. Non ha importanza che abbiate o meno affrontato l’argomento “classi”, poiché il poco necessario per comprendere il loro uso in questo articolo verrà dovutamente spiegato.

class MyClass
{
  public:
    void setValue(int _value)
    { 
        value = _value;
    }
    int getValue() const
    {
        return value;
    }

  private: 
    float something[2000];
    long double somethingElse[4000];
    int value = 0;
};

Le classi sono uno dei modi con cui il C++ permette al programmatore di definire dei tipi personalizzati.
La classe MyClass possiede tre dati membro: un consistente array di float, un consistente array di long double e un intero con segno. Essendo questi dati dichiarati come privati, sarà possibile leggerli e/o modificarli solo tramite delle funzioni messe a disposizione dalla classe stessa. Le funzioni dichiarate internamente a una classe prendono il nome di metodi.
La classe MyClass mette quindi a disposizione due metodi pubblici per leggere e scrivere nella sua variabile privata “value”:

  • setValue() ha come parametro un numero intero, il quale sarà il nuovo valore della variabile “value”.
  • getValue() non accetta parametri, ma restituisce il valore della variabile interna “value”. In particolare questo metodo non modifica in alcun modo nessuno dei dati membro, potendo essere identificato quindi come metodo costante.

Altri quattro metodi per leggere e scrivere nei due array possono venire definiti in maniera simile. Non è importante ai fini di questo articolo, perciò sorvoliamo il problema.

Una variabile creata utilizzando questa classe necessita di più di 70kbyte per essere memorizzata. Il nostro scopo è creare una funzione che esamini il valore del dato membro “value”. Nel caso esso sia diverso da 0, la funzione eseguirà una semplice azione.

È evidente, innanzitutto, che non è assolutamente efficiente passare l’intera variabile alla funzione, ma è conveniente inviare solamente una copia del suo indirizzo in memoria. Da una parte avremmo decine di kilobyte da copiare, dall’altra al massimo 8 byte. Nella funzione sfrutteremo il metodo getValue() fornito dalla classe e agiremo di conseguenza.

void function(const MyClass * pointerToMyClass)
{
    //errore di compilazione: vietato modificare un oggetto costante
    pointerToMyClass->setValue(5);

    if(!pointerToMyClass->getValue()) std::cout << "ERROR" << std::endl;
}

int main()
{
    // creo una variabile oggetto di tipo MyClass
    MyClass testClass;

    function(&testClass);
}

La variabile testClass, essendo di tipo MyClass, viene definita un’istanza della classe MyClass. Un altro termine con il quale ci si riferisce abitualmente a questo tipo di variabili è oggetti. Essa viene creata nel “main”, ed è una copia del suo indirizzo ad essere passata alla funzione “function” tramite l’uso dell’operatore ‘&’.
La funzione “function” accetta come argomento un puntatore a oggetti di tipo MyClass. Avete notato l’uso del qualificatore “const” prima del tipo? Esso non indica che il puntatore sia costante, ovvero legato a puntare sempre e solo il medesimo oggetto, bensì che è l’oggetto puntato ad essere costante (che sia una variabile di tipo nativo o meno, come in questo caso).

Mentre nel “main” nulla ci impediva di modificare uno dei dati membro di testClass (utilizzando i metodi opportuni poiché essi sono dichiarati privati), non possiamo fare altrettanto nella funzione “function”, poiché essa considera l’indirizzo passatole come l’indirizzo di un oggetto costante. Questo uso del qualificatore “const” è quindi una protezione da eventuali modifiche accidentali, decisamente comuni in programmi di grosse dimensioni, le quali non produrrebbero un errore di compilazione.

Su oggetti costanti possono operare solo ed esclusivamente metodi dichiarati come costanti. E un metodo può essere dichiarato come costante solo se non modifica alcun dato membro. Nel nostro caso getValue() può essere dichiarato costante poiché si limita alla lettura del valore di “value”. Perciò può essere chiamato su un qualsiasi oggetto di tipo MyClass. Invece setValue() modifica un dato, ovvero il dato “value”, perciò non può essere dichiarato come metodo costante. L’oggetto testClass nel “main”, non essendo dichiarato come costante, permette l’uso di entrambi i metodi. Invece nella funzione “function”, essendo considerato costante, non permette l’uso di setValue(), mentre permette l’invocazione del metodo getValue().

Da notare infine l’uso dell’operatore ‘->’. Ricordiamo che pointerToMyClass non è un oggetto, ma un puntatore ad un oggetto. Perciò per richiamare il metodo getValue() saremmo stati costretti ad utilizzare la seguente notazione, decisamente scomoda: (*pointerToMyClass).getValue();.
Ovvero prima si dereferenzia l’indirizzo contenuto in pointerToMyClass in modo da interagire direttamente con l’oggetto di tipo MyClass, e poi si invoca, tramite l’operatore punto (.), il metodo getValue() di quell’oggetto.
L’operatore ‘->’ è stato creato per allegerire questo tipo di sintassi, eliminando l’uso delle parentesi e della dereferenziazione tramite l’uso dell’operatore ‘*’. Ovviamente l’operatore ‘->’ potrà venire utilizzato solo su puntatori a oggetti o su puntatori a strutture: se tentassimo di utilizzarlo su un oggetto otterremo un errore di compilazione.
Sintassi: <puntatore> -> <membro interno>;.

Ci si potrebbe chiedere se inviare solo una copia del valore della variabile intera, ottenuto tramite il metodo getValue(), possa essere più consono. Potrebbe sembrare una buona soluzione perché eviteremmo anche che il valore originale venga accidentalmente modificato, rendendo quindi superfluo l’uso del qualificatore “const”:

void function(int value)
{
    if(!value) std::cout << "ERROR" << std::endl;
}

int main()
{
    MyClass testClass;

    // testClass è un oggetto, quindi uso l'operatore punto '.' per invocare il metodo
    function(testClass.getValue());
}

In effetti se necessitiamo di analizzare un solo valore, ed esso è di un tipo nativo, possiamo scegliere questa strada. Ma che dire se abbiamo da analizzare un dato membro a sua volta di tipo non nativo? O che dire se la nostra funzione deve analizzare più dati membro nativi? Ovviamente è più efficiente passare alla funzione una copia dell’indirizzo del nostro oggetto, dichiarando l’oggetto puntato costante, anziché le copie di tutti i valori necessari.
Inoltre un determinato dato membro potrebbe non essere accessibile nemmeno tramite un metodo, e solo una funzione esterna dichiarata come “friend” alla classe, dopo aver ricevuto l’indirizzo dell’oggetto sul quale operare, potrà interagire con esso. Infine citiamo anche l’overload dei metodi operatore, metodi che permettono di ridefinire il significato degli operatori (come +,-,*,>>,++,ecc…) per gli oggetti di un determinato tipo da noi creato. Essi operano sull’intero oggetto, sebbene spesso e volentieri necessitano di analizzare solo uno o pochi dati membro senza modificarli.

Per concludere l’argomentazione del qualificatore “const”, citiamo la possibilità di creare non solo puntatori a oggetti/variabili costanti, ma anche puntatori costanti a oggetti/variabili costanti e puntatori costanti a oggetti/variabili non costanti.
In entrambi i casi, dopo l’inizializzazione obbligatoria in fase di dichiarazione, non sarà più possibile modificare l’indirizzo contenuto in questo particolare tipo di puntatore. Nel primo caso non sarà nemmeno possibile modificare l’oggetto o la variabile puntata, mentre nel secondo caso questa operazione è permessa.

Vediamo brevemente la sintassi utilizzata:

// puntatore costante a oggetto/variabile costante
const <tipo> * const <identificatore>

// puntatore costante a oggetto/variabile non costante
<tipo> * const <identificatore>

 

conclusione

Iniziate ad apprezzare l’utilità dei puntatori? Essi permettono di guadagnare in efficienza dal punto di vista della memoria utilizzata e di risparmiare tempo prezioso durante il passaggio dei parametri attuali. Nel prossimo articolo tratteremo l’aritmetica dei puntatori e in che modo essa è utile nella gestione degli array.

 

[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