Dal C al C++/Utilizzo basilare di librerie/L'uso delle stringhe

Indice del libro

Le "stringhe" del C

modifica

Nel linguaggio C non esiste un tipo dato "stringa". Esistono solo gli array di caratteri, ed esiste la convenzione di considerare stringa una sequenza di caratteri terminata dal carattere zero binario, noto anche come NUL o '\0', e che chiameremo terminatore di stringa. Queste stringhe vengono solitamente chiamate "stringhe del C" (in inglese, "C-string").

Si tratta di una convenzione che ha basi nel linguaggio e nella libreria standard. Da punto di vista linguistico le stringhe letterali, quelle racchiuse tra virgolette doppie, sono sequenze di caratteri statiche terminate dal carattere zero binario. Per esempio, sia in C che in C++, la seguente porzione di codice assegna 3 alla variabile "n" (utilizzando l'operatore sizeof che restituisce il numero di caratteri presenti nella stringa più il terminatore di stringa):

char s[] = "ab";
int n = sizeof s;

Dal punto di vista della libreria standard del C, inglobata in quella del C++, ci sono molte funzioni di libreria che ricevono come parametro un puntatore a carattere, ed elaborano il carattere puntato e i successivi caratteri fino a quando incontrano il terminatore di stringa. Per esempio, la seguente istruzione assegna 2 alla variabile "n" (al contrario di sizeof la funzione strlen() restituisce i caratteri presenti nella stringa escludendo il terminatore):

int n = strlen("ab");

La seguente porzione di codice ha invece comportamento indefinito:

char s[] = { 'a', 'b' };
int n = strlen(s);

Infatti l'array di caratteri "s" contiene solo due elementi, dopo i quali potrebbe non esserci un terminatore di stringa.

Le funzioni della libreria del C per manipolare le stringhe del C sono utilizzabili anche in C++, ma il file di intestazione da includere è "cstring", invece di "string.h". Ecco un programma d'esempio:

#include <cstring>
#include <cstdio>
int main() {
   char buffer[10];
   char dati[] = "abcd";
   strcpy(buffer, dati);
   strcat(buffer, "XYZ");
   printf("%s", strchr(buffer, 'c'));
}

Questo programma stampa "cdXYZ". Per chi conosce il linguaggio C e la sua libreria standard, non c'è niente da spiegare.

Come si vede, non è necessaria la direttiva "using", in quanto le funzioni del C vengono importate anche nel namespace globale, oltre che nel namespace "std".

La sicurezza della classe "string"

modifica

Notoriamente, le stringhe del C sono scomode da usare e soggette a errori. Consideriamo il seguente programma, equivalente al precedente:

#include <string>
#include <iostream>
using namespace std;
int main() {
   string buffer;
   string dati = "abcd";
   buffer = dati;
   buffer += "XYZ";
   try {
      cout << buffer.substr(buffer.find('c'));
   } catch (const out_of_range &) {
      cerr << "Errore: Carattere non trovato.";
   }
}

Dopo aver dichiarato le due variabili di tipo "string", viene fatto un apparentemente semplice assegnamento. In realtà, l'operatore "=", se alla sua sinistra si trova un oggetto di tipo "string", fa parecchie cose. In primo luogo, distrugge il precedente contenuto della stringa, poi alloca lo spazio sufficiente a contenere in nuovo contenuto, e infine copia il valore dell'espressione di stringa a destra nello spazio allocato. Tale procedimento è meno efficiente della funzione "strcpy", ma più leggibile e soprattutto più sicuro. Infatti, se la stringa sorgente fosse stata più lunga della stringa destinazione, ci sarebbe stato un comportamento indefinito, ottenendo tipicamente una sovrascrittura di altre aree di memoria del processo. Nella versione che usa gli oggetti di tipo "string", viene sempre allocata la memoria sufficiente a contenere il valore da copiare.

Dopo l'assegnamento, invece di chiamare la funzione "strcat", si usa l'operatore "+=". Questo operatore rialloca lo spazio per contenere il valore di "buffer" più il valore da concatenare, e poi copia tale valore. Anche qui la nuova tecnica è più sicura della vecchia.

Infine, anche l'istruzione contenente la chiamata a "printf" non è sicura a fronte di future modifiche nei dati. Infatti, se la lettera cercata non viene trovata nella stringa, la funzione "strchr" rende il puntatore nullo, e la funzione "printf" tenta di leggere la memoria a tale indirizzo. Nella seconda versione, si usano invece le funzioni "substr" e "find" della classe "string". La funzione "find" cerca il carattere nella stringa, e rende la prima posizione in cui viene trovato il carattere, oppure la costante "string::npos", se il carattere non viene trovato.

La costante "npos" è definita dentro la classe "string", è un numero intero senza segno, ed è più grande della lunghezza di qualunque stringa gestita dalla classe "string". Un valore tipico è 4294967295, cioè 232 - 1.

La funzione "substr", se chiamata con un solo parametro, estrae la sottostringa che va dalla posizione specificata con tale parametro, fino alla fine della stringa. Pertanto, se il carattere cercato appartiene alla stringa, la combinazione delle chiamate a "find" e a "substr" equivale alla chiamata a "strchr", con la differenza che la "strchr" lavora su un solo oggetto, mentre la funzione "substr" crea una nuova stringa a partire da un'altra.

Il parametro passato alla funzione "substr" è maggiore della lunghezza della stringa, il che succede quando il carattere cercato dalla chiamata a "find" non appartiene alla stringa, la funzione "substr" fallisce, sollevando un'eccezione di tipo "out_of_range". Il codice risultante è sicuro, senza bisogno di inserire controlli nei punti in cui può verificarsi l'anomalia.

Interazione tra oggetti "string" e stringhe del C

modifica

Si consideri il seguente programma:

#include <string>
#include <cstring>
#include <iostream>
using namespace std;
int main() {
   char dati[] = "abc";
   string s(dati);
   cout << s << strlen(s.c_str());
}

Questo programma stampa "abc3".

Nella dichiarazione della variabile "s", tale oggetto rappresentato da tale variabile viene inizializzato con un puntatore a carattere. Si assume che tale puntatore punti a una stringa del C. Infatti, il valore iniziale dell'oggetto "string" appena creato è proprio una copia della sequenza di caratteri che inizia da quello puntato e finisce con il terminatore di stringa. La conversione da puntatore a carattere a stringa non è implicita, ma molte funzione della classe "string" accettano sia parametri di tipo "string" che di tipo "char *".

Nell'ultima istruzione viene chiamata la funzione "strlen". Tale funzione non può ricevere come parametro un oggetto "string", e non esiste una conversione implicita da oggetti "string" a stringhe del C. Tuttavia, la funzione membro "c_str" della classe "string" converte esplicitamente un oggetto "string" in un oggetto "const char *", cioè in una stringa del C non modificabile. Tale stringa non è modificabile in quanto è interna all'oggetto "string"; se si permettesse di modificare il contenuto di tale stringa, si comprometterebbe l'oggetto "string" stesso.

Si consideri il seguente altro programma:

#include <string>
#include <iostream>
using namespace std;
int main() {
   string s("abcd");
   s[2] = '\0';
   cout << s << "," << s.c_str() << ".";
}

Questo programma stampa "ab d,ab.".

All'ultima riga del programma, la stringa rappresentata da "s" contiene i seguenti quattro caratteri: 'a', 'b', '\0', 'c'. A differenza delle stringhe del C, il carattere zero binario è un carattere ammissibile all'interno di un oggetto "string", in quanto non viene usato come terminatore. Stampando una stringa che contiene zeri binari questi vengono stampati come spazi. Chiamando la funzione "c_str" si genera una stringa identica all'originale, ma con l'aggiunta di un carattere zero binario in fondo. Tuttavia, se, come in questo caso, ci sono altri terminatori di stringa dall'interno della stringa, le funzioni che elaborano stringhe del C credono che la stringa sia più corta, fermandosi al primo terminatore di stringa che incontrano.

Funzionamento degli oggetti "string"

modifica

Una possibile implementazione di un oggetto "string" è la seguente:

struct string {
   unsigned long lunghezza;
   char *caratteri;
};

Dato che un qualunque carattere è ammesso all'interno di un oggetto "string", è necessario memorizzare a parte la lunghezza della stringa.

Questa implementazione deve riallocare il buffer puntato da "caratteri" ogni volta che la stringa viene modificata in modo da aumentarne la lunghezza.

Per ridurre la frequenza di tali costose riallocazioni, si può adottare la seguente implementazione.

struct string {
   unsigned long lunghezza;
   unsigned long capacita;
   char *caratteri;
};

Ogni volta che si deve allocare un buffer, si arrotonda la dimensione del buffer alla potenza di 2 successiva, e si memorizza tale valore nella variabile membro "capacita". Nella variabile membro "lunghezza" si memorizza invece il numero di caratteri effettivamente utilizzati, che sarà un numero non maggiore di "capacita".

Questa è l'implementazione di riferimento, tanto che la classe "string" fornisce la funzione membro "capacity" per ottenere l'attuale lunghezza del buffer allocato e la funzione membro "reserve" per assicurare che il buffer allocato sia abbastanza grande. Ecco un esempio:

#include <string>
#include <iostream>
using namespace std;
int main() {
   string s;
   cout << "size=" << s.size() << ", capacity=" << s.capacity() << "\n";
   s += "abc";
   cout << "size=" << s.size() << ", capacity=" << s.capacity() << "\n";
   s.reserve(40);
   cout << "size=" << s.size() << ", capacity=" << s.capacity() << "\n";
   s.reserve(2);
   cout << "size=" << s.size() << ", capacity=" << s.capacity() << "\n";
}

Quello che stampa questo programma dipende dall'implementazione, ma, usando come segnaposto le variabili A, B, e C, è garantito che il risultato avrà la seguente forma:

size=0, capacity=A
size=3, capacity=B
size=3, capacity=C
size=3, capacity=C

Appena creata, la stringa ha lunghezza zero, ma la capacità potrebbe già essere maggiore di zero (A >= 0).

Dopo aver aggiunto tre caratteri alla stringa vuota, la stringa ha lunghezza 3, e la capacità potrebbe essere aumentata, per far spazio ai tre caratteri, oppure rimasta uguale, se c'era già spazio a sufficienza (if (A < 3) B >= 3 else B == A).

Dopo aver assicurato che lo spazio riservato sia di almeno quaranta caratteri, la lunghezza della stringa non è variata, ma la sua capacità probabilmente è aumentata (C >= 40).

Dopo aver assicurato che lo spazio riservato sia di almeno due caratteri, sia la lunghezza che la capacità non sono variate, in quanto la nuova richiesta di spazio è minore dello spazio già garantito in precedenza.

Lo scopo della funzione membro "reserve" è di preallocare un buffer che potrà contenere il valore probabile della stringa, evitando ripetute riallocazioni. A differenza dei buffer del C che non devono assolutamente essere sfondati, il buffer allocato da "reserve" è solo una tecnica di ottimizzazione, ed eventuali superamenti della dimensione del buffer preallocato non comportano effetti disastrosi; questo perché la gestione interna della stringa provvede automaticamente a riallocare il buffer qualora vengano inseriti più caratteri di quelli "riservati".