Raddoppiamo la difficoltà: le stringhe • Puntatori IV

R

 

Raddoppiamo la difficoltà: le stringhe

Nel precedente articolo abbiamo più che sufficientemente esaminato l’aritmetica dei puntatori. Adesso faremo tesoro delle preziose opportunità che essa mette a disposizione per gestire al meglio le stringhe C-like.

 

INTRODUZIONE ALLE STRINGHE C-like

Il linguaggio C, a differenza del C++, non provvede un tipo predefinito per le stringhe. Esse sono semplicemente array di caratteri terminati, per convenzione, dal carattere terminatore ‘\0’. Nelle varie codifiche, come ad esempio la ASCII, esso corrisponde esattamente al valore numerico 0 (null byte = 00000000).

La maggior parte delle funzioni che operano su questo tipo di stringhe si basano sulla sopracitata convenzione: appena viene identificato il carattere terminatore, tramite un puntatore che scorre l’intera stringa, la funzione termina considerando la stringa conclusa.

Avanziamo per gradi.

 

ISTANZIAZIONE DELLE STRINGHE c-like

I metodi più comuni per creare una stringa, senza l’utilizzo dei puntatori, sono i seguenti:

// aggiungo manualmente il carattere terminatore
char firstString[] = "Hi!\0";

// il carattere terminatore viene aggiunto automaticamente
const char secondString[] = "Hello!";

Si utilizzano array di caratteri, lasciando al compilatore l’onere di aggiungere il carattere terminatore alla fine se non l’abbiamo già inserito e di stabilire la dimensione dell’array.
Ogni singolo carattere presente nel letterale stringa inizializzatore andrà a occupare una cella dell’array.

Nel secondo caso non sarà possibile modificare una o più celle dell’array poiché esso è dichiarato come costante. Nel primo caso avremo questa opportunità, ma non sarà comunque possibile modificare l’intero array con istruzioni come firstString = "HowRU?";. Il C/C++ permette di inizializzare un array, ma non di assegnarvi successivamente un valore.
Vediamo invece quali possibilità abbiamo se utilizziamo variabili puntatore a carattere.

Iniziamo collegando un letterale stringa a un puntatore:

int main()
{
    const char * pointerToChar = "How are you?";
}

Innanzitutto, come nell’inizializzazione di array di caratteri, se il carattere terminatore non viene inserito manualmente, il compilatore ne posiziona uno in automatico alla fine della stringa. Ad ogni modo, non avendo mai visto una dichiarazione simile, è ragionevole chiedersi perché il compilatore non solleva un errore. Stiamo in effetti assegnando a una variabile puntatore, che può contenere solo ed esclusivamente un indirizzo, un’intera stringa. Cosa accade dietro le quinte?

La stringa “How are you?\0” viene considerata esattamente come un array di caratteri. Il riferimento ad essa in questo caso è la stringa stessa. La conversione implicita da array a puntatore da parte del compilatore avviene anche in questo caso, generando quindi l’indirizzo del primo elemento (la lettera ‘H’). Questo implica aver prima memorizzato il letterale stringa in una locazione di memoria contigua per poi poterne ricavare l’indirizzo.

Dove viene memorizzata?
In una zona particolare della memoria a sola lettura, solitamente il data-segment. Per questo motivo il contenuto puntato dalla variabile puntatore “pointerToChar” è stato contrassegnato come costante:

#include <iostream>

int main()
{
    // non è obbligatorio far interpretare il contenuto della stringa come costante
    char * pointerToChar = "How are you?";

    // la seguente istruzione non verrebbe però segnalata come errata 
    pointerToChar[3] = 'S';

    // essendo la stringa memorizzata in una zona a sola lettura
    // l'istruzione precedente avrà una conseguenza non definita

    // dichiarare il contenuto del puntatore come costante
    const char * anotherPointerToChar = "Fine thanks."

    // impedirà al compilatore di copilare istruzioni come la seguente
    // evitando possibili errori a run time
    anotherPointerToChar[3] = 'S';
}

Da notare come è possibile interagire, anche se solo operazioni di lettura vengono permesse, con un singolo carattere della stringa attraverso il puntatore e l’operatore parentesi quadre “[]”, sfruttando l’aritmetica dei puntatori.

Questo comportamento dei letterali stringa non è un’eccezione presente solo quando vengono assegnati a dei puntatori. È la norma, poiché, ricordiamo, anch’essi seguono la regola generale la quale implica la conversione implicita da array a puntatore. È quasi ironico scoprire che è un’istruzione come char arrayOfChar[] = "Hi!"; l’eccezione a questo comportamento, eccezione presentata nello scorso articolo.
L’array così ottenuto risulterà idoneo ad operazioni sia di lettura che di scrittura.

È bene sapere che i letterali stringa vengono inizializzati una volta sola ed esistono per l’intera durata del programma, venendo considerati esattamente come variabili contrassegnate dal qualificatore “static”.
Perciò un listato come il seguente è valido:

#include <iostream>

//function() restituisce un puntatore a carattere
const char* function()
{
    // pointerToCharInFunction contiene l'indirizzo del letterale stringa "String!"
    const char * pointerToCharInFunction = "String!";
   
    // restituisco questo indirizzo al main
    return pointerToCharInFunction;
}

int main()
{
    // memorizzo l'indirizzo restituito dalla funzione 
    // in pointerToCharInMain
    const char * pointerToCharInMain = function();

    // il letterale "String!" non si comporta come una normale variabile
    // non viene distrutto al termine dela funzione function()
    // perciò l'indirizzo contenuto in pointerToCharInMain
    // è perfettamente valido

    // stampa: String!
    std::cout << pointerToCharInMain << std::endl;

    return 0;
}

Mentre non sarebbe assolutamente valido utilizzando gli array di caratteri, i quali vengono memorizzati nello stack:

#include <iostream>

//function() restituisce un puntatore a carattere
char* function()
{
    // il letterale stringa "String!" non viene memorizzato
    // ogni carattere presente nel letterale stringa inizializza una cella
    // dell'array charArray 
    char charArray[] = "String!";
   
    // restituisco l'array al main
    // la conversione implicita trasforma l'array in un puntatore al primo elemento
    return charArray;
}

int main()
{
    // memorizzo l'indirizzo restituito dalla funzione 
    // in pointerToChar
    const char * pointerToChar = function();

    // charArray era allocato nello stack, 
    // in una zona distrutta al termine dela funzione function()
    // perciò l'indirizzo contenuto in pointerToChar non è valido

    // ERRORE
    std::cout << pointerToChar << std::endl;

    return 0;
}

La distruzione di una zona di memoria implica semplicemente la sua deallocazione. Sebbene leggere e/o scrivere in quella determinata zona potrebbe non portare ad errori (potremmo addirittura ottenere “String!” stampato a video), rimane una pratica da evitare.

 

UTILIZZARE I PUNTATORI PRESENTA NOTEVOLI VANTAGGI

Uno dei vantaggi nell’uso dei puntatori è la possibilità di modificare l’indirizzo contenuto in essi in modo che puntino a un letterale stringa differente. Questo non solo supplisce allo svantaggio di non poter modificare la stringa puntata; permette di scrivere codice di gran lunga più efficiente.
Considerate il seguente listato nel quale sfruttiamo queste possibilità:

#include <iostream>

int main()
{
    // creo una variabile puntatore nulla 
    const char * pointer = nullptr;
    
    bool condition = false;
   
    // esamino la variabile booleana condition
    if(condition)
    {
        // se condition è true
        // memorizzo in pointer l'indirizzo del letterale stringa "ERROR"
        pointer = "ERROR";

    } else {

        // se condition è false
        // memorizzo in pointer l'indirizzo del letterale stringa "WARNING"
        pointer = "WARNING";
    }
    
    // ricordiamo che i letterali stringa sopravvivono alla fine del costrutto blocco if-else
    // stampa: WARNING
    std::cout << pointer << std::endl; 
}

La flessibilità delle variabili puntatore ci permette di modificarle agevolmente sfruttando l’avverarsi o meno di certe condizioni. Un listato simile è possibile crearlo anche attraverso gli array di caratteri, ma il codice prodotto non sarà altrettanto ottimizzato:

#include <iostream>

int main()
{
    bool condition = false;
  
    // esamino la variabile booleana condition
    if(condition)
    {
        // se condition è true
        // creo un array di caratteri e lo inizializzo con i caratteri contenuti in "ERROR"
        char arrayOfChars[] = "ERROR";
        
        // stampa: ERROR
         std::cout << arrayOfChars << std::endl;

    } else {

        // se condition è false
        // creo un array di caratteri e lo inizializzo con i caratteri contenuti in "WARNING"
        char arrayOfChars[] = "WARNING";
        
    // stampa: WARNING
         std::cout << arrayOfChars << std::endl;
    }
}

Utilizzare i puntatori a carattere ci permette di sfruttare il risultato del costrutto if-else al di fuori di esso, mentre utilizzare gli array di caratteri non prevede questa possibilità poiché le stringhe contenute in essi vengono distrutte.

 

ESEMPI DI GESTIONE DELLE STRINGHE

La motivazione per cui istruzioni come std::cout << pointerToChar << std::endl; oppure std::cout << arrayOfChar << std::endl; non stampano a video ne l’indirizzo contenuto in “pointerToChar” ne l’indirizzo risultante dalla conversione implicita di “arrayOfChar”, bensì l’intera stringa, è un caso particolare.
Questo accade perché quando uno stream di uscita interagisce con un puntatore a caratteri si presume che si voglia interagire con l’intera stringa puntata. L’istruzione “printf()” del C esegue la stessa operazione se lo specificatore di formato è “%s”.

Per comprendere meglio come è possibile interagire con una stringa, partendo dall’indirizzo del primo elemento, analizziamo il seguente listato, decisamente comune in molti programmi:

#include <iostream>

int main()
{
    const char * myString = "Ciao Dodoino! Io mi chiamo ... ";
    char myArray[] = "Come stai?";
    
    // creo un altra variabile puntatore la quale punta alla stringa
    // così facendo posso spostare liberamente movingPointer
    // senza correre il rischio di perdere l'indirizzo della stringa
    // indirizzo che è mantenuto dal puntatore myString 
    const char * movingPointer = myString;
    
    // questo ciclo viene eseguito fino a che il carattere puntato è diverso
    // dal carattere terminatore \0
    while(*movingPointer)
    {
        // stampo a video il carattere di volta in volta puntato,
        // dereferenziando il puntatore
        std::cout << *movingPointer;
        
        // sposto il puntatore incrementandolo 
        movingPointer++;
    }
    
    // adesso movingPointer punta al primo dei caratteri contenuti nell'array myArray
    movingPointer = myArray;
    
    // questo ciclo viene eseguito fino a che il carattere puntato è diverso
    // dal carattere terminatore \0
    while(*movingPointer)
    {
        // stampo a video il carattere di volta in volta puntato
        // dereferenziando il puntatore
        std::cout << *movingPointer;
        
        // sposto il puntatore incrementandolo 
        movingPointer++;
    }
    
    std::cout << std::endl;
}

Come si evince dal codice ben commentato, è sufficiente inizializzare un puntatore a carattere con l’indirizzo del primo carattere di una stringa, per poi incrementarlo tramite un ciclo (in questo caso è stato scelto il ciclo while) fino a che il carattere incontrato non è il carattere di terminazione \0. Essendo equivalente al valore numerico 0 è l’unico che viene valutato “false” in un’espressione condizionale.
Attenzione: è il carattere (*movingPointer) ad essere nullo, non il suo indirizzo (movingPointer).

Inoltre è possibile notare come un oggetto non costante può essere puntato da una variabile puntatore che considera l’oggetto puntato costante (movingPointer = myArray;) .
Ovviamente non è possibile il contrario, non permettendo quindi a un oggetto dichiarato come costante di essere modificato da un puntatore il quale non considera l’oggetto puntato come costante.

È quindi possibile creare una piccola funzione per svolgere l’operazione ripetuta nel listato precedente:

// dichiarando movingPointer come puntatore a dati costanti
// la funzione non può in alcun modo modificare il dato puntato
// perciò accetta indirizzi
// sia di un oggetto costante (letterale stringa)
// sia di un oggetto non costante (array di caratteri)

void printEachCharacter(const char * movingPointer)
{
    // questo ciclo viene eseguito fino a che il carattere puntato è diverso
    // dal carattere terminatore \0
    while(*movingPointer)
    {
        // stampo a video il carattere di volta in volta puntato,
        // dereferenziando il puntatore
        std::cout << *movingPointer;
        
        // sposto il puntatore incrementandolo 
        movingPointer++;
    }
}

 

Un altro esempio dell’uso delle variabili puntatore nella gestione delle stringhe è una possibile implementazione interna della funzione strlen(). Essa restituisce il numero dei caratteri presenti in una stringa, non considerando il carattere terminatore:

size_t myStrlen(const char *str)
{
    // inizializzo il puntatore end affinché 
    // punti l'inizio della stringa da esaminare
    const char *end = str;
  
    // finché il carattere puntato da end
    // non è il carattere terminatore
    // incremento il puntatore 
    while(*end)
    {
        end++;
    }

    // sfruttando l'aritmetica dei puntatori
    // si calcola la distanza tra il puntatore end
    // e il puntatore str
    // essa corrisponde esattamente al numero dei caratteri
    // presenti nella stringa
    return (end - str);
}

Al termine del ciclo while, il puntatore “end” punterà al carattere terminatore della stringa passata alla funzione myStrlen(). Dato che l’indirizzo puntato da esso non è considerato nell’intervallo dell’operazione differenza, il carattere terminatore non sarà considerato nella determinazione del risultato.

 

CONCLUSIONE

Questi sono solo alcuni esempi dell’utilità delle variabili puntatore nella gestione delle stringhe C-like. Lo scopo principale dell’articolo è quella di dare una solida base per la comprensione degli stessi.
Nel prossimo ed ultimo articolo di questa serie esamineremo l’uso delle variabili puntatore nell’allocazione dinamica della memoria.

 

[Scarica l’articolo in formato PDF]

 

A proposito di me

Andrea Simone Costa

Classe 1997, Toscano DOCP, Asimov oriented.
Si è diplomato come perito elettronico, ma ha ben presto tradito le origini per immergersi nell'universo JavaScript. Ama condividere ciò che ha imparato in modo semplice e pragmatico, senza mai ricadere nel banale, coltivando segretamente il sogno di insegnare.

Gli articoli più letti

Articoli recenti

Commenti recenti