Stringhe in C++

Concetti Chiave
  • Le stringhe in C++ sono rappresentate dall'oggetto string della libreria standard.
  • Le stringhe possono essere inizializzate in diversi modi, tra cui l'inizializzazione di default, per copia e diretta.
  • Le operazioni comuni sulle stringhe includono la lettura, la scrittura, il controllo della lunghezza e l'accesso ai singoli caratteri.
  • Le stringhe in C++ gestiscono automaticamente la memoria, rendendole più sicure e facili da usare rispetto alle stringhe in C.

Il tipo string

Una stringa è una sequenza di caratteri di lunghezza variabile.

In C++ esiste un oggetto string che rappresenta una stringa. Questo tipo è definito nella libreria standard C++. La libreria standard C++ fornisce molti tipi e funzioni che estendono il linguaggio di base. Questi tipi e funzioni sono definiti in una serie di file header.

Per utilizzare il tipo string, dobbiamo includere l'header string. Poiché fa parte della libreria, string è definita nel namespace std. I nostri esempi presuppongono che abbiamo incluso il seguente codice:

#include <string>
using std::string;

Questa sezione descrive le operazioni più comuni sulle stringhe; le prossime lezioni tratteranno operazioni aggiuntive.

Nota

Il tipo string del C++ non è la stessa cosa delle stringhe in C

Chi viene dal linguaggio C deve fare attenzione a non confondere il tipo string del C++ con le stringhe in C.

Le stringhe in C sono rappresentate come array di caratteri terminati da un carattere null ('\0'). Queste stringhe richiedono una gestione manuale della memoria e possono essere soggette a errori come il buffer overflow. Al contrario, il tipo string del C++ gestisce automaticamente la memoria e offre molte funzionalità utili per lavorare con le stringhe in modo sicuro ed efficiente.

Dato che il C++ è un'estensione del linguaggio C, si possono comunque adoperare le stringhe C anche all'interno di programmi C++. Tuttavia, è consigliabile utilizzare il tipo string del C++ per la maggior parte delle applicazioni moderne, a meno che non si abbia una ragione specifica per utilizzare le stringhe C.

Inoltre, spesso chi proviene dal linguaggio C è spesso restio ad adoperare oggetti definiti nella libreria standard C++ pensando, erroneamente, che si tratti di qualcosa di inefficiente. In realtà, gli oggetti della libreria standard C++ sono progettati per essere efficienti e facili da usare.

Infatti, oltre a specificare le operazioni fornite dai tipi della libreria, lo standard del C++ impone anche requisiti di efficienza agli implementatori. Di conseguenza, i tipi della libreria sono abbastanza efficienti per l'uso generale.

Definire e Inizializzare Stringhe

Ogni classe definisce come possono essere inizializzati gli oggetti del suo tipo.

Una classe può definire molti modi diversi per inizializzare oggetti del suo tipo. Ogni modo deve essere distinto dagli altri sia per il numero di inizializzatori che forniamo, sia per i tipi di tali inizializzatori. La Tabella 3.1 elenca i modi più comuni per inizializzare le string.

Ecco alcuni esempi:

// Inizializzazione di default: s1 è una stringa vuota
string s1;

// Inizializzazione per copia: s2 è una copia di s1
string s2 = s1;

// Inizializzazione per copia: s3 è una copia del letterale stringa
string s3 = "ciao";

// Inizializzazione diretta: s4 contiene 10 copie del carattere 'c'
string s4(10, 'c'); // s4 è cccccccccc

Come si può osservare, possiamo inizializzare una string in modo predefinito, ossia creando una stringa vuota senza caratteri.

Quando forniamo un letterale stringa come inizializzatore, i caratteri di quel letterale, fino al carattere nullo \0 alla fine del letterale ma escluso, vengono copiati nella stringa appena creata.

Quando forniamo un conteggio e un carattere, la stringa contiene quel numero di copie del carattere dato.

Inizializzazione Diretta e per Copia

Nella lezione sulle variabili abbiamo visto che il C++ ha diverse forme di inizializzazione.

Usando le string come esempio, possiamo iniziare a capire come queste forme differiscono l'una dall'altra. Quando inizializziamo una variabile usando l'operatore di assegnamento =, stiamo chiedendo al compilatore di inizializzare per copia l'oggetto copiando l'inizializzatore sul lato destro nell'oggetto che viene creato. Altrimenti, quando omettiamo =, usiamo l'inizializzazione diretta.

Quando abbiamo un singolo inizializzatore, possiamo usare sia la forma diretta che quella di copia dell'inizializzazione. Quando inizializziamo una variabile da più di un valore, come nell'inizializzazione di s4 sopra, dobbiamo usare la forma diretta di inizializzazione:

// inizializzazione per copia
string s5 = "ciao";

// inizializzazione diretta
string s6("ciao");

// i
string s7(10, 'c'); // inizializzazione diretta; s7 è cccccccccc

Quando vogliamo usare diversi valori, possiamo usare indirettamente la forma di copia dell'inizializzazione creando esplicitamente un oggetto (temporaneo) da copiare:

// inizializzazione per copia; s8 è cccccccccc
string s8 = string(10, 'c');

L'inizializzatore di s8, ossia string(10, 'c'), crea una stringa della dimensione e del valore carattere dati e poi copia quel valore in s8. È come se avessimo scritto:

string temp(10, 'c'); // temp è cccccccccc
string s8 = temp; // copia temp in s8

Sebbene il codice usato per inizializzare s8 sia legale, è meno leggibile e non offre alcun vantaggio compensativo rispetto al modo in cui abbiamo inizializzato s7.

Ricapitolando, quindi, i modi per inizializzare una string sono:

  1. Inizializzazione di Default:

    string s1;
    

    In questo modo otteniamo una stringa vuota.

  2. Inizializzazione per Copia:

    string s2(s1);
    string s2 = s1;
    

    Le due inizializzazioni di sopra sono equivalenti e copiano il valore di s1 in s2.

  3. Inizializzazione per Copia da un Letterale Stringa:

    string s3("ciao");
    string s3 = "ciao";
    

    Le due inizializzazioni di sopra sono equivalenti e copiano i caratteri del letterale stringa in s3.

  4. Inizializzazione diretta:

    string s4(10, 'c');
    

    In questo modo otteniamo una stringa che contiene 10 copie del carattere 'c'.

Operazioni sulle stringhe

Oltre a definire come gli oggetti vengono creati e inizializzati, una classe definisce anche le operazioni che gli oggetti del tipo classe possono eseguire.

Una classe può definire operazioni che vengono chiamate per nome. Una classe può anche definire cosa significano vari simboli operatore, come << o +, quando applicati a oggetti del tipo della classe. La che segue elenca le operazioni più comuni sulle string:

Operazione Descrizione
os << s Scrive s sul flusso di output os. Restituisce os.
is >> s Legge una stringa separata da spazi bianchi da is in s. Restituisce is.
getline(is, s) Legge una riga di input da is in s. Restituisce is.
s.empty() Restituisce true se s è vuota; altrimenti restituisce false.
s.size() Restituisce il numero di caratteri in s.
s[n] Restituisce un riferimento al char alla posizione n in s; le posizioni iniziano da 0.
s1 + s2 Restituisce una string che è la concatenazione di s1 e s2.
s1 = s2 Sostituisce i caratteri in s1 con una copia di s2.
s1 == s2 Le stringhe s1 e s2 sono uguali se contengono gli stessi caratteri.
s1 != s2 L'uguaglianza è sensibile alle maiuscole/minuscole.
<, <=, >, >= I confronti sono sensibili alle maiuscole/minuscole e usano l'ordinamento del dizionario.
Tabella 1: Operazioni comuni sulle stringhe

Lettura e Scrittura di stringhe

Come abbiamo visto nelle lezioni precedenti, usiamo la libreria iostream per leggere e scrivere valori di tipi built-in come int, double, e così via. Usiamo gli stessi operatori I/O per leggere e scrivere string:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    // stringa vuota
    string s;

    // legge una stringa separata da spazi bianchi in s
    cin >> s;

    // scrive s sull'output
    cout << s << endl;

    return 0;
}

Questo programma inizia definendo una stringa vuota chiamata s. La riga successiva legge l'input standard, memorizzando ciò che viene letto in s. L'operatore di input per string legge e scarta tutti gli spazi bianchi iniziali (ad esempio, spazi, nuove righe, tabulazioni). Quindi legge i caratteri fino a quando non incontra il successivo carattere di spazio bianco.

Quindi, se l'input di questo programma è:

   Ciao Mondo!   

(nota gli spazi iniziali e finali), l'output sarà Ciao senza spazi extra.

Come le operazioni di input e output sui tipi built-in, gli operatori string restituiscono il loro operando sinistro come risultato. Pertanto, possiamo concatenare più letture o scritture:

string s1, s2;

// legge il primo input in s1, il secondo in s2
cin >> s1 >> s2;

// scrive entrambe le stringhe
cout << s1 << s2 << endl;

Se diamo a questa versione del programma lo stesso input:

   Ciao Mondo!   

il nostro output sarebbe "CiaoMondo!"

Lettura di un Numero Sconosciuto di stringhe

Nella lezione sul flusso di controllo abbiamo scritto un programma che leggeva un numero sconosciuto di valori int. Possiamo scrivere un programma simile che legge string invece:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    string parola;

    // legge fino alla fine del file
    while (cin >> parola)
        // scrive ogni parola seguita da una nuova riga
         cout << parola << endl;
    return 0;
}

In questo programma, leggiamo in una string, non in un int. Altrimenti, la condizione while si esegue in modo simile a quella del nostro programma precedente. La condizione testa il flusso dopo che la lettura è completa. Se il flusso è valido, ossia non ha raggiunto la fine del file o incontrato un input non valido, allora il corpo del while viene eseguito. Il corpo stampa il valore che abbiamo letto sull'output standard. Una volta raggiunta la fine del file (o un input non valido), usciamo dal while.

Usare getline per Leggere un'Intera Riga

A volte non vogliamo ignorare gli spazi bianchi nel nostro input. In tali casi, possiamo usare la funzione getline invece dell'operatore >>. La funzione getline prende un flusso di input e una string. Questa funzione legge il flusso dato fino alla prima nuova riga inclusa e memorizza ciò che ha letto, esclusa il carattere di nuova riga, nel suo argomento string. Dopo che getline vede una nuova riga, anche se è il primo carattere nell'input, smette di leggere e ritorna. Se il primo carattere nell'input è una nuova riga, allora la stringa risultante è la stringa vuota.

Come l'operatore di input, getline restituisce il suo argomento istream. Di conseguenza, possiamo usare getline come condizione proprio come possiamo usare l'operatore di input come condizione. Ad esempio, possiamo riscrivere il programma precedente che scriveva una parola per riga per scrivere invece una riga alla volta:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    string riga;
    // legge l'input una riga alla volta fino alla fine del file
    while (getline(cin, riga))
         cout << riga << endl;
    return 0;
}

Poiché riga non contiene un carattere di nuova riga, dobbiamo aggiungerlo direttamente. Come al solito, usiamo endl per terminare la riga corrente e svuotare il buffer.

Il carattere di nuova riga che causa il ritorno di getline viene scartato; esso non viene memorizzato nella string.

Le Operazioni empty e size di string

La funzione empty fa ciò che ci si aspetterebbe: restituisce un bool che indica se la stringa è vuota. empty è una funzione membro di string. Per chiamare questa funzione, usiamo l'operatore punto per specificare l'oggetto su cui vogliamo eseguire la funzione empty.

Possiamo rivedere il programma precedente per stampare solo le righe che non sono vuote:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    string riga;

    // legge l'input una riga alla volta e scarta le righe vuote
    while (getline(cin, riga))
        if (!riga.empty())
             cout << riga << endl;
    return 0;
}

La condizione usa l'operatore NOT logico (l'operatore !). Questo operatore restituisce l'inverso del valore bool del suo operando. In questo caso, la condizione è vera se riga non è vuota.

Il membro size restituisce la lunghezza di una string (cioè, il numero di caratteri in essa). Possiamo usare size per stampare solo le righe più lunghe di 80 caratteri:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    string riga;

    // legge l'input una riga alla volta e
    // stampa le righe più lunghe di 80 caratteri
    while (getline(cin, riga))
        if (riga.size() > 80)
             cout << riga << endl;

    return 0;
}

Il Tipo string::size_type

Potrebbe sembrare logico aspettarsi che size restituisca un int o addirittura un unsigned. Invece, size restituisce un valore string::size_type. Questo tipo richiede un po' di spiegazione.

La classe string, e la maggior parte degli altri tipi della libreria, definisce diversi tipi accessori. Questi tipi accessori rendono possibile usare i tipi della libreria in modo indipendente dalla macchina. Il tipo size_type è uno di questi tipi accessori. Per usare il size_type definito da string, usiamo l'operatore di scope per dire che il nome size_type è definito nella classe string.

Sebbene non conosciamo il tipo preciso di string::size_type, sappiamo che è un tipo unsigned abbastanza grande da contenere la dimensione di qualsiasi string. Qualsiasi variabile utilizzata per memorizzare il risultato dell'operazione size di string dovrebbe essere di tipo string::size_type.

Vero è anche che può essere tedioso digitare string::size_type ogni volta. Ma a partire dallo standard C++11 possiamo chiedere al compilatore di fornire il tipo appropriato usando auto o decltype. Ad esempio, possiamo scrivere:

// lunghezza ha tipo string::size_type
auto lunghezza = riga.size();

Poiché size restituisce un tipo unsigned, è essenziale ricordare che le espressioni che mescolano dati signed e unsigned possono avere risultati sorprendenti. Ad esempio, se n è un int che contiene un valore negativo, allora lunghezza < n verrà quasi sicuramente valutato come true. Produce true perché il valore negativo in n verrà convertito in un grande valore unsigned.

Puoi evitare problemi dovuti alla conversione tra unsigned e int non usando int in espressioni che usano size().

Confrontare stringhe

La classe string definisce diversi operatori che confrontano le stringhe. Questi operatori funzionano confrontando i caratteri delle stringhe. I confronti sono sensibili alle maiuscole/minuscole, ossia le versioni maiuscole e minuscole di una lettera sono considerati caratteri diversi.

Gli operatori di uguaglianza (== e !=) verificano se due stringhe sono uguali o diverse, rispettivamente. Due stringhe sono uguali se hanno la stessa lunghezza e contengono gli stessi caratteri. Gli operatori relazionali <, <=, >, >= verificano se una stringa è minore di, minore o uguale a, maggiore di, o maggiore o uguale a un'altra. Questi operatori usano la stessa strategia di un dizionario (sensibile alle maiuscole/minuscole):

  1. Se due stringhe hanno lunghezze diverse e se ogni carattere nella stringa più corta è uguale al carattere corrispondente della stringa più lunga, allora la stringa più corta è minore di quella più lunga.
  2. Se qualsiasi carattere in posizioni corrispondenti nelle due stringhe differisce, allora il risultato del confronto di stringhe è il risultato del confronto del primo carattere in cui le stringhe differiscono.

Questo tipo di confronto è chiamato confronto lessicografico.

Come esempio, consideriamo le seguenti string:

string str = "Ciao";
string frase = "Ciao Mondo";
string saluto = "Salve";

Usando la regola 1, vediamo che str è minore di frase. Applicando la regola 2, vediamo che saluto è maggiore sia di str che di frase.

Assegnamento di stringhe

In generale, i tipi della libreria si sforzano di rendere l'uso di un tipo della libreria facile quanto l'uso di un tipo built-in. A tal fine, la maggior parte dei tipi della libreria supporta l'assegnamento. Nel caso delle string, possiamo assegnare un oggetto string a un altro:

// st1 è cccccccccc; st2 è una stringa vuota
string st1(10, 'c'), st2;

// assegnamento: sostituisce il contenuto di st1 con una copia di st2
// sia st1 che st2 sono ora la stringa vuota
st1 = st2;

Concatenare stringhe

La concatenazione di due string produce una nuova string che è la concatenazione dell'operando sinistro seguito dall'operando destro. Cioè, quando usiamo l'operatore più (+) sulle string, il risultato è una nuova string i cui caratteri sono una copia di quelli nell'operando sinistro seguiti da quelli dell'operando destro. L'operatore di assegnamento composto (+=) aggiunge l'operando destro alla string sinistra:

string s1 = "ciao, ";
string s2 = "mondo\n";

// s3 è ciao, mondo\n
string s3 = s1 + s2;

// equivalente a s1 = s1 + s2
s1 += s2;

Concatenare stringhe e letterali

Come abbiamo visto nelle lezioni precedenti, possiamo usare un tipo dove è atteso un altro tipo se esiste una conversione dal tipo dato al tipo atteso.

La libreria string ci permette di convertire sia i letterali carattere che i letterali stringa carattere in string. Poiché possiamo usare questi letterali dove è attesa una string, possiamo riscrivere il programma precedente come segue:

// nessuna punteggiatura in s1 o s2
string s1 = "ciao", s2 = "mondo";

// s3 è ciao, mondo\n
string s3 = s1 + ", " + s2 + '\n';

Quando mescoliamo string e letterali string o carattere, almeno un operando di ogni operatore + deve essere di tipo string:

// OK: aggiunta di una string e un letterale
string s4 = s1 + ", ";

// ERRORE: nessun operando string
string s5 = "ciao" + ", ";

// OK: ogni + ha un operando string
string s6 = s1 + ", " + "mondo";

// ERRORE: non si possono aggiungere letterali string
string s7 = "ciao" + ", " + s2;

Le inizializzazioni di s4 e s5 coinvolgono solo una singola operazione ciascuna, quindi è facile vedere se l'inizializzazione è legale. L'inizializzazione di s6 può sembrare sorprendente, ma funziona nello stesso modo di quando concateniamo espressioni di input o output. Questa inizializzazione raggruppa gli operandi come mostrato di seguito:

string s6 = (s1 + ", ") + "mondo";

La sottoespressione s1 + ", " restituisce una string, che forma l'operando sinistro del secondo operatore +. È come se avessimo scritto

// OK: + ha un operando string
string tmp = s1 + ", ";

// OK: + ha un operando string
s6 = tmp + "mondo";

D'altra parte, l'inizializzazione di s7 è illegale, cosa che possiamo vedere se mettiamo tra parentesi l'espressione:

// ERRORE: non si possono aggiungere letterali string
string s7 = ("ciao" + ", ") + s2;

Ora dovrebbe essere facile vedere che la prima sottoespressione aggiunge due letterali string. Non c'è modo di farlo, e quindi l'istruzione è errata.

Per ragioni storiche, e per compatibilità con il linguaggio C, i letterali string non sono string della libreria standard. È importante ricordare che questi tipi differiscono quando si usano letterali string e string della libreria.

Esercizi

  • Scrivi un programma per leggere l'input standard una riga alla volta. Modifica il tuo programma per leggere una parola alla volta.

    Soluzione:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string riga;
    
        // legge l'input una riga alla volta fino alla fine del file
        while (getline(cin, riga))
            // stampa la riga letta
            cout << riga << endl;
    
        return 0;
    }
    

    Modifica per leggere una parola alla volta:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string parola;
    
        // legge l'input una parola alla volta fino alla fine del file
        while (cin >> parola)
            // stampa la parola letta
            cout << parola << endl;
    
        return 0;
    }
    
  • Scrivi un programma per leggere due string e riportare se le string sono uguali. In caso contrario, riporta quale delle due è più grande. Inoltre riporta se le string hanno la stessa lunghezza, e in caso contrario, riporta quale è più lunga.

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string s1, s2;
    
        // legge due string dall'input
        cout << "Inserisci la prima string: ";
        getline(cin, s1);
        cout << "Inserisci la seconda string: ";
        getline(cin, s2);
    
        // confronta le string
        if (s1 == s2)
            cout << "Le string sono uguali." << endl;
        else if (s1 > s2)
            cout << "La prima string è più grande." << endl;
        else
            cout << "La seconda string è più grande." << endl;
    
        // confronta la lunghezza delle string
        if (s1.length() == s2.length())
            cout << "Le string hanno la stessa lunghezza." << endl;
        else if (s1.length() > s2.length())
            cout << "La prima string è più lunga." << endl;
        else
            cout << "La seconda string è più lunga." << endl;
    
        return 0;
    }
    
  • Scrivi un programma per leggere string dall'input standard, concatenando ciò che viene letto in un'unica grande string. Stampa la string concatenata. Successivamente, modifica il programma per separare le string di input adiacenti con uno spazio.

    Soluzione:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string parola;
        string risultato;
    
        // legge l'input una parola alla volta fino alla fine del file
        while (cin >> parola)
            // concatena la parola letta al risultato
            risultato += parola;
    
        // stampa la string concatenata
        cout << risultato << endl;
    
        return 0;
    }
    

    Modifica per separare le string di input adiacenti con uno spazio:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string parola;
        string risultato;
    
        // legge l'input una parola alla volta fino alla fine del file
        while (cin >> parola) {
            if (!risultato.empty())
                risultato += " "; // aggiunge uno spazio se non è la prima parola
            risultato += parola; // concatena la parola letta al risultato
        }
    
        // stampa la string concatenata
        cout << risultato << endl;
    
        return 0;
    }
    

Gestire i Caratteri in una stringa

Spesso abbiamo bisogno di gestire i singoli caratteri in una string. Potremmo voler verificare se una string contiene spazi bianchi, o modificare i caratteri in minuscolo, o vedere se un dato carattere è presente, e così via.

Una parte di questo tipo di elaborazione implica come otteniamo accesso ai caratteri stessi. A volte dobbiamo elaborare ogni carattere. Altre volte dobbiamo elaborare solo un carattere specifico, oppure possiamo smettere di elaborare una volta soddisfatta una certa condizione. Si scopre che il modo migliore per gestire questi casi implica diverse funzionalità del linguaggio e della libreria.

L'altra parte dell'elaborazione dei caratteri consiste nel conoscere e/o modificare le caratteristiche di un carattere. Questa parte del lavoro è gestita da un insieme di funzioni della libreria, descritte nella tabella che segue. Queste funzioni sono definite nell'header cctype.

Funzione Descrizione
isalnum(c) true se c è una lettera o una cifra.
isalpha(c) true se c è una lettera.
iscntrl(c) true se c è un carattere di controllo.
isdigit(c) true se c è una cifra.
isgraph(c) true se c non è uno spazio ma è stampabile.
islower(c) true se c è una lettera minuscola.
isprint(c) true se c è un carattere stampabile (cioè, uno spazio o un carattere che ha una rappresentazione visibile).
ispunct(c) true se c è un carattere di punteggiatura (cioè, un carattere che non è un controllo, una cifra, una lettera, o uno spazio bianco stampabile).
isspace(c) true se c è uno spazio bianco (cioè, uno spazio, tab, tab verticale, ritorno, nuova riga, o avanzamento modulo).
isupper(c) true se c è una lettera maiuscola.
isxdigit(c) true se c è una cifra esadecimale.
tolower(c) Se c è una lettera maiuscola, restituisce il suo equivalente minuscolo; altrimenti restituisce c invariato.
toupper(c) Se c è una lettera minuscola, restituisce il suo equivalente maiuscolo; altrimenti restituisce c invariato.
Tabella 2: Funzioni per Gestire i Caratteri
Consiglio

Utilizzare sempre gli Header C++ rispetto agli Header C

Oltre alle funzionalità definite specificamente per C++, la libreria C++ incorpora la libreria C.

Gli header in C hanno nomi della forma nome.h. Le versioni C++ di questi header sono chiamate cnome, ossia rimuovono il suffisso .h e precedono il nome con la lettera c. La c indica che l'header fa parte della libreria C.

Quindi, cctype ha lo stesso contenuto di ctype.h, ma in una forma appropriata per i programmi C++. In particolare, i nomi definiti negli header cnome sono definiti all'interno del namespace std, mentre quelli definiti nelle versioni .h non lo sono.

Normalmente, i programmi C++ dovrebbero usare le versioni cnome degli header e non le versioni nome.h. In questo modo i nomi della libreria standard si trovano costantemente nel namespace std. L'uso degli header .h mette l'onere sul programmatore di ricordare quali nomi della libreria sono ereditati da C e quali sono unici per C++.

Elaborare Ogni Carattere usando un Range-Based for

Se vogliamo fare qualcosa a ogni carattere in una string, di gran lunga l'approccio migliore è usare un'istruzione introdotta dallo standard C++11: l'istruzione range for. Questa istruzione itera attraverso gli elementi in una data sequenza ed esegue qualche operazione su ogni valore in quella sequenza. La forma sintattica è

for (dichiarazione : espressione)
     istruzione

dove espressione è un oggetto di un tipo che rappresenta una sequenza, e dichiarazione definisce la variabile che useremo per accedere agli elementi sottostanti nella sequenza. Su ogni iterazione, la variabile in dichiarazione viene inizializzata dal valore dell'elemento successivo in espressione.

Una string rappresenta una sequenza di caratteri, quindi possiamo usare una string come espressione in un range for. Come esempio semplice, possiamo usare un range for per stampare ogni carattere da una string sulla propria riga di output:

string str("Ciao, Mondo!");
// stampa i caratteri in str un carattere per riga
for (auto c : str) // per ogni char in str
    // stampa il carattere corrente seguito da una nuova riga
    cout << c << endl;

Il ciclo for associa la variabile c con str. Definiamo la variabile di controllo del ciclo allo stesso modo in cui facciamo con qualsiasi altra variabile. In questo caso, usiamo auto per lasciare che il compilatore determini il tipo di c, che in questo caso sarà char. Su ogni iterazione, il carattere successivo in str verrà copiato in c. Quindi, possiamo leggere questo ciclo come se dicesse, "Per ogni carattere c nella string str," fai qualcosa. Il "qualcosa" in questo caso è stampare il carattere seguito da una nuova riga.

Come esempio un po' più complicato, useremo un range for e la funzione ispunct per contare il numero di caratteri di punteggiatura in una string:

#include <iostream>
#include <string>
#include <cctype> // per ispunct

using namespace std;

int main()
{
    string s("Ciao Mondo!!!");

    // conteggio_punt ha lo stesso tipo restituito da s.size;
    decltype(s.size()) conteggio_punt = 0;

    // conta il numero di caratteri di punteggiatura in s
    for (auto c : s) // per ogni char in s
        if (ispunct(c)) // se il carattere è punteggiatura
            ++conteggio_punt; // incrementa il contatore di punteggiatura

    // stampa il risultato
    cout << conteggio_punt
        << " caratteri di punteggiatura in " << s << endl;
}

L'output di questo programma è

3 caratteri di punteggiatura in Ciao Mondo!!!

Qui usiamo decltype per dichiarare il nostro contatore, conteggio_punt. Il suo tipo è il tipo restituito dalla chiamata s.size(), che è string::size_type. Usiamo un range for per elaborare ogni carattere nella string. Questa volta verifichiamo se ogni carattere è punteggiatura. Se lo è, usiamo l'operatore di incremento per aggiungere 1 al contatore. Quando il range for è completo, stampiamo il risultato.

Usare un Range for per Modificare i Caratteri in una stringa

Se vogliamo modificare il valore dei caratteri in una string, dobbiamo definire la variabile del ciclo come un tipo di riferimento. Ricorda che un riferimento è solo un altro nome per un dato oggetto. Quando usiamo un riferimento come variabile di controllo, quella variabile è legata a ogni elemento nella sequenza a turno. Usando il riferimento, possiamo modificare il carattere a cui il riferimento è legato.

Supponiamo che invece di contare la punteggiatura, volessimo convertire una string in tutte lettere maiuscole. Per farlo possiamo usare la funzione della libreria toupper, che prende un carattere e restituisce la versione maiuscola di quel carattere. Per convertire l'intera string dobbiamo chiamare toupper su ogni carattere e rimettere il risultato in quel carattere:

#include <iostream>
#include <string>
#include <cctype> // per toupper

using namespace std;

int main()
{
    string s("Ciao Mondo!!!");
    // converte s in maiuscolo
    for (auto &c : s) // per ogni char in s (nota: c è un riferimento)
        // c è un riferimento, quindi l'assegnamento cambia il char in s
        c = toupper(c);
    cout << s << endl;
}

L'output di questo codice è

CIAO MONDO!!!

Su ogni iterazione, c si riferisce al carattere successivo in s. Quando assegniamo a c, stiamo modificando il carattere sottostante in s. Quindi, quando eseguiamo

// c è un riferimento, quindi l'assegnamento cambia il char in s
c = toupper(c);

stiamo modificando il valore del carattere a cui c è legato. Quando questo ciclo è completo, tutti i caratteri in s saranno maiuscoli.

Accedere ai Singoli Caratteri in una stringa

Un range for funziona bene quando dobbiamo elaborare ogni carattere. Tuttavia, a volte dobbiamo accedere solo a un singolo carattere o accedere ai caratteri fino a quando non viene raggiunta una certa condizione. Ad esempio, potremmo voler mettere in maiuscolo solo il primo carattere o solo la prima parola in una string.

Ci sono due modi per accedere ai singoli caratteri in una string: possiamo usare un pedice o un iteratore. Avremo più da dire sugli iteratori nelle prossime lezioni.

L'operatore di pedice (l'operatore []) prende un valore string::size_type che indica la posizione del carattere a cui vogliamo accedere. L'operatore restituisce un riferimento al carattere nella posizione data.

I pedici per le string iniziano da zero; se s è una string con almeno due caratteri, allora s[0] è il primo carattere, s[1] è il secondo, e l'ultimo carattere è in s[s.size() - 1].

I valori che usiamo per indicizzare una string devono essere compresi tra 0 e il valore restituito da size() - 1, ossia la lunghezza della stringa meno 1. Il risultato dell'uso di un indice al di fuori di questo intervallo è indefinito. Per implicazione, l'indicizzazione di una string vuota è indefinita.

Il valore nel pedice è chiamato "pedice" o "indice". L'indice che forniamo può essere qualsiasi espressione che produce un valore integrale. Tuttavia, se il nostro indice ha un tipo signed, il suo valore verrà convertito nel tipo unsigned che string::size_type rappresenta.

L'esempio seguente usa l'operatore di pedice per stampare il primo carattere in una string:

// Si assicura che la string non sia vuota
if (!s.empty())
    // stampa il primo carattere in s
    cout << s[0] << endl;

Prima di accedere al carattere, verifichiamo che s non sia vuota. Ogni volta che usiamo un pedice, dobbiamo assicurarci che ci sia un valore nella posizione data. Se s è vuota, allora s[0] è indefinito.

Finché la string non è const, possiamo assegnare un nuovo valore al carattere restituito dall'operatore di pedice. Ad esempio, possiamo mettere in maiuscolo la prima lettera come segue:

string s("una stringa");

// Si assicura che ci sia un carattere in s[0]
if (!s.empty())
    // assegna un nuovo valore al primo carattere in s
    s[0] = toupper(s[0]);

L'output di questo programma è

Una stringa

Iterare una stringa accedendo tramite indice

Oltre all'iterazione tramite il range for, possiamo anche iterare una string usando un ciclo for tradizionale e un indice. Questo approccio è utile quando dobbiamo elaborare i caratteri in una string fino a quando non viene soddisfatta una certa condizione.

Come esempio, scriviamo un programma che modifica solo la prima parola in s convertendone le lettere in tutte maiuscole:

#include <iostream>
#include <string>
#include <cctype> // per toupper e isspace

using namespace std;

int main()
{
    string s("una stringa");

    // elabora i caratteri in s fino a quando
    // non esauriamo i caratteri o incontriamo uno spazio bianco
    for (decltype(s.size()) indice = 0;
        indice != s.size() && !isspace(s[indice]); ++indice)
            // metti in maiuscolo il carattere corrente
            s[indice] = toupper(s[indice]);

    cout << s << endl;

    return 0;
}

Questo programma genera il seguente output:

UNA stringa

Il nostro ciclo for usa indice per indicizzare s. Usiamo decltype per dare a indice il tipo appropriato. Inizializziamo indice a 0 in modo che la prima iterazione inizi sul primo carattere in s. Su ogni iterazione incrementiamo indice per guardare il carattere successivo in s. Nel corpo del ciclo mettiamo in maiuscolo la lettera corrente.

La parte nuova in questo ciclo è la condizione nel for. Quella condizione usa l'operatore AND logico (l'operatore &&). Questo operatore produce true se entrambi gli operandi sono true e false altrimenti. La parte importante di questo operatore è che abbiamo la garanzia che valuta il suo operando destro solo se l'operando sinistro è true. In questo caso, abbiamo la garanzia che non indicizzeremo s a meno che non sappiamo che indice è nell'intervallo. Cioè, s[indice] viene eseguito solo se indice non è uguale a s.size(). Poiché indice non viene mai incrementato oltre il valore di s.size(), sappiamo che indice sarà sempre minore di s.size().

Questo comportamento degli operatori logici prende il nome di corto circuito ed è ereditato dal linguaggio C. Gli operatori logici && e || sono gli unici operatori in C++ che garantiscono il corto circuito.

Nota

L'accesso con indice ad una stringa non è controllato

Quando usiamo un indice, dobbiamo assicurarci che il indice sia nell'intervallo. Cioè, l'indice deve essere compreso tra 0 e il valore di size() - 1.

Un modo per semplificare il codice che usa gli indici è sempre usare una variabile di tipo string::size_type come indice. Poiché quel tipo è unsigned, assicuriamo che l'indice non possa essere minore di zero. Quando usiamo un valore size_type come indice, dobbiamo solo verificare che il nostro indice sia minore del valore restituito da size().

Inoltre, la libreria standard del C++ non è obbligata a controllare il valore di un indice. Il risultato dell'uso di un indice fuori intervallo è indefinito.

Usare un indice per l'accesso casuale alle stringhe

Nell'esempio precedente abbiamo avanzato il nostro indice di una posizione alla volta per mettere in maiuscolo ogni carattere in sequenza. Possiamo anche calcolare un indice e recuperare direttamente il carattere indicato. Non c'è bisogno di accedere ai caratteri in sequenza.

Come esempio, supponiamo di avere un numero tra 0 e 15 e vogliamo generare la rappresentazione esadecimale di quel numero. Possiamo farlo usando una string inizializzata per contenere le 16 "cifre" esadecimali:

#include <iostream>
#include <string>

using namespace std;

int main()
{
    // possibili cifre esadecimali
    const string cifre_esadecimali = "0123456789ABCDEF";

    cout << "Inserisci una serie di numeri tra 0 e 15"
         << " separati da spazi. Premi INVIO quando hai finito: "
         << endl;

    // conterrà la stringa esadecimale risultante
    string risultato;

    // contiene i numeri dall'input
    string::size_type n;

    while (cin >> n)
        // ignora input non valido
        if (n < cifre_esadecimali.size())
            // recupera la cifra esadecimale indicata
            risultato += cifre_esadecimali[n];

    cout << "Il tuo numero esadecimale è: " << risultato << endl;

    return 0;
}

Se diamo a questo programma l'input

12 0 5 15 8 15

l'output sarà

Il tuo numero esadecimale è: C05F8F

Iniziamo inizializzando cifre_esadecimali per contenere le cifre esadecimali da 0 a F. Rendiamo quella string const perché non vogliamo che questi valori cambino. All'interno del ciclo usiamo il valore di input n per indicizzare cifre_esadecimali. Il valore di cifre_esadecimali[n] è il char che appare alla posizione n in cifre_esadecimali. Ad esempio, se n è 15, il risultato è F; se è 12, il risultato è C; e così via. Aggiungiamo quella cifra a risultato, che stampiamo una volta letto tutto l'input.

Ogni volta che usiamo un indice, dovremmo pensare a come sappiamo che è nell'intervallo. In questo programma, il nostro indice, n, è un string::size_type, che come sappiamo è un tipo unsigned. Di conseguenza, sappiamo che n è garantito essere maggiore o uguale a 0. Prima di usare n per indicizzare cifre_esadecimali, verifichiamo che sia minore della dimensione di cifre_esadecimali.

Esercizi

  • Usa un range for per cambiare tutti i caratteri in una string nel carattere x.

    Soluzione:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string s("Ciao Mondo!");
    
        // cambia tutti i caratteri in 'x'
        for (auto &c : s) // per ogni char in s (nota: c è un riferimento)
            c = 'x'; // assegna 'x' al carattere corrente
    
        cout << s << endl; // stampa la string modificata
    
        return 0;
    }
    
  • Cosa succederebbe se definissi la variabile di controllo del ciclo nell'esercizio precedente come tipo char?

    Risposta: Se definissi la variabile di controllo del ciclo come tipo char senza il riferimento (ossia for (auto c : s)), allora c sarebbe una copia del carattere corrente in ogni iterazione. Modificare c non influenzerebbe i caratteri nella stringa originale s, quindi la stringa rimarrebbe invariata.

  • Riscrivi il programma nel primo esercizio, prima usando un while e di nuovo usando un ciclo for tradizionale. Qual'è la differenza tra i tre approcci?

    Soluzione con while:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string s("Ciao Mondo!");
        auto it = s.begin(); // iteratore all'inizio della stringa
    
        // cambia tutti i caratteri in 'x' usando while
        while (it != s.end()) {
            *it = 'x'; // assegna 'x' al carattere corrente
            ++it; // avanza l'iteratore
        }
    
        cout << s << endl; // stampa la string modificata
    
        return 0;
    }
    

    Soluzione con ciclo for tradizionale:

    #include <iostream>
    #include <string>
    
    using namespace std;
    
    int main()
    {
        string s("Ciao Mondo!");
    
        // cambia tutti i caratteri in 'x' usando for tradizionale
        for (decltype(s.size()) i = 0; i < s.size(); ++i)
            s[i] = 'x'; // assegna 'x' al carattere corrente
    
        cout << s << endl; // stampa la string modificata
    
        return 0;
    }
    

    Differenza tra i tre approcci: - Il range for è il più conciso e leggibile, ideale per iterare su ogni elemento di una collezione senza preoccuparsi degli indici o degli iteratori. - Il while con iteratori offre un controllo più esplicito sull'iterazione, utile quando si lavora con collezioni complesse o quando si desidera manipolare gli iteratori direttamente. - Il ciclo for tradizionale con indici è più verboso e richiede la gestione manuale degli indici, ma può essere utile quando si ha bisogno di accedere agli elementi in modo non sequenziale o quando si lavora con array.

  • Cosa fa il seguente programma? È valido? Se no, perché no?

    string s;
    cout << s[0] << endl;
    

    Risposta: Il programma tenta di accedere al primo carattere di una stringa vuota s. Poiché s è vuota, l'accesso a s[0] è indefinito e può causare un comportamento imprevisto. Quindi, il programma non è valido perché non verifica se la stringa contiene almeno un carattere prima di accedere a s[0].

  • Scrivi un programma che legge una string di caratteri inclusa la punteggiatura e scrive ciò che è stato letto ma con la punteggiatura rimossa.

    Soluzione:

    #include <iostream>
    #include <string>
    #include <cctype> // per ispunct
    
    using namespace std;
    
    int main()
    {
        string input;
        string output;
    
        cout << "Inserisci una stringa (inclusa la punteggiatura): ";
        getline(cin, input); // legge una riga di input
    
        // rimuove la punteggiatura
        for (auto c : input) {
            if (!ispunct(c)) // se il carattere non è punteggiatura
                output += c; // aggiungi il carattere a output
        }
    
        cout << "Stringa senza punteggiatura: " << output << endl;
    
        return 0;
    }
    
  • Il seguente range for è legale? Se sì, qual è il tipo di c?

    const string s = "Vietato entrare!";
    for (auto &c : s) { /* ... */ }
    

    Risposta: No, il range for non è legale. La variabile s è dichiarata come const string, il che significa che i suoi caratteri non possono essere modificati. Tuttavia, la variabile di controllo del ciclo c è dichiarata come un riferimento non const (auto &c), il che implica che si potrebbe tentare di modificare i caratteri di s. Per rendere il ciclo legale, c dovrebbe essere dichiarato come un riferimento const, cioè const auto &c.