Memorizzare Testo Codificato UTF-8 con Stringhe
Abbiamo parlato delle stringhe nel Capitolo 4, ma ora le analizzeremo più approfonditamente. I nuovi Rustacean spesso si bloccano sulle stringhe per una combinazione di tre ragioni: la propensione di Rust a esporre possibili errori, il fatto che le stringhe siano una struttura dati più complicata di quanto molti programmatori credano e la codifica UTF-8. Questi fattori si combinano in un modo che può sembrare difficile per chi proviene da altri linguaggi di programmazione.
Parleremo delle stringhe nel contesto delle collezioni perché le stringhe sono
implementate come una collezione di byte, oltre ad alcuni metodi per fornire
funzionalità utili quando tali byte vengono interpretati come testo. In questa
sezione, parleremo delle operazioni su String
che ogni tipo di collezione
prevede, come creazione, aggiornamento e lettura. Discuteremo anche le
differenze tra String
e le altre collezioni, in particolare come
l’indicizzazione in una String
sia complicata dalle differenze tra il modo in
cui le persone e i computer interpretano i dati String
.
Definire le Stringhe
Definiremo innanzitutto cosa intendiamo con il termine stringa. Rust ha un
solo type di stringa nel linguaggio principale, ovvero la slice di stringa
str
che viene principalmente utilizzata come reference &str
. Nel Capitolo
4 abbiamo parlato delle slice di stringa, che sono reference ad alcuni dati
stringa codificati in UTF-8 memorizzati altrove. I letterali stringa, ad
esempio, sono memorizzati nel binario del programma e sono quindi slice di
stringa.
Il type String
, fornito dalla libreria standard di Rust anziché esser
definito nel linguaggio principale, è un type di stringa codificato in UTF-8,
con ownership, espandibile e modificabile. Quando i Rustacean fanno
riferimento alle “stringhe” in Rust, potrebbero riferirsi sia al type String
che alla slice di stringa &str
, e non solo a uno di questi type. Sebbene
questa sezione tratterà principalmente String
, entrambe le tipologie sono
ampiamente utilizzate nella libreria standard di Rust, e sia String
che le
slice sono codificate in UTF-8.
Creare una Nuova String
Molte delle operazioni disponibili con Vec<T>
sono disponibili anche con
String
perché String
è in realtà implementata come wrapper (involucro)
attorno a un vettore di byte con alcune garanzie, restrizioni e funzionalità
aggiuntive. Ad esempio, la funzione new
per creare un’istanza, funziona allo
stesso modo sia con Vec<T>
che con String
. Eccola mostrata nel Listato 8-11.
fn main() { let mut s = String::new(); }
String
vuotaQuesta riga crea una nuova stringa vuota chiamata s
, in cui possiamo quindi
caricare i dati. Spesso, avremo dei dati iniziali con cui vogliamo inizializzare
la stringa. Per questo, utilizziamo il metodo to_string
, disponibile su
qualsiasi type che implementi il trait Display
, come fanno i letterali
stringa. Il Listato 8-12 mostra due esempi.
fn main() { let data = "contenuto iniziale"; let s = data.to_string(); // Il metodo funziona anche direttamente sul letterale: let s = "contenuto iniziale".to_string(); }
to_string
per creare una String
da un letterale stringaQuesto codice crea una stringa contenente contenuto iniziale
.
Possiamo anche utilizzare la funzione String::from
per creare una String
da
un letterale stringa. Il codice nel Listato 8-13 è equivalente al codice nel
Listato 8-12 che utilizza to_string
.
fn main() { let s = String::from("contenuto iniziale"); }
String::from
per creare una String
da un letterale stringaPoiché le stringhe vengono utilizzate per così tante cose, possiamo utilizzare
diverse API generiche per le stringhe, offrendoci numerose opzioni. Alcune
possono sembrare ridondanti, ma hanno tutte la loro importanza! In questo caso,
String::from
e to_string
svolgono la stessa funzione, quindi la scelta è una
questione di stile e leggibilità.
Ricorda che le stringhe sono codificate in UTF-8, quindi possiamo includere qualsiasi dato codificato correttamente, come mostrato nel Listato 8-14.
fn main() { let saluto = String::from("السلام عليكم"); let saluto = String::from("Dobrý den"); let saluto = String::from("Hello"); let saluto = String::from("שלום"); let saluto = String::from("नमस्ते"); let saluto = String::from("こんにちは"); let saluto = String::from("안녕하세요"); let saluto = String::from("你好"); let saluto = String::from("Olá"); let saluto = String::from("Здравствуйте"); let saluto = String::from("Hola"); }
Tutti questi sono valori String
validi.
Aggiornare una String
Una String
può crescere di dimensione e il suo contenuto può cambiare, proprio
come il contenuto di un Vec<T>
, se vi si inseriscono più dati. Inoltre, è
possibile utilizzare comodamente l’operatore +
o la macro format!
per
concatenare valori String
.
Aggiungere con push_str
e push
Possiamo far crescere una String
utilizzando il metodo push_str
per
aggiungere una slice di stringa, come mostrato nel Listato 8-15.
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
String
utilizzando il metodo push_str
Dopo queste due righe, s
conterrà foobar
. Il metodo push_str
accetta una
slice di stringa perché non vogliamo necessariamente prendere ownership del
parametro. Ad esempio, nel codice del Listato 8-16, vogliamo poter utilizzare
s2
dopo averne aggiunto il contenuto a s1
.
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 è {s2}"); }
String
Se il metodo push_str
prendesse ownership di s2
, non saremmo in grado di
stamparne il valore sull’ultima riga. Tuttavia, questo codice funziona come
previsto!
Il metodo push
prende un singolo carattere come parametro e lo aggiunge alla
String
. Il Listato 8-17 aggiunge la lettera l a una String
utilizzando il
metodo push
.
fn main() { let mut s = String::from("lo"); s.push('l'); }
String
utilizzando push
Di conseguenza, s
conterrà lol
.
Concatenare con +
o format!
Spesso, si desidera combinare due stringhe esistenti. Un modo per farlo è
utilizzare l’operatore +
, come mostrato nel Listato 8-18.
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // nota che s1 è stato spostato qui e non può più essere utilizzato }
+
per combinare due valori String
in un nuovo valore String
La stringa s3
conterrà Hello, world!
. Il motivo per cui s1
non è più
valido dopo l’aggiunta, e il motivo per cui abbiamo utilizzato un reference a
s2
, ha a che fare con la firma del metodo chiamato quando utilizziamo
l’operatore +
. L’operatore +
utilizza il metodo add
, la cui firma è simile
a questa:
fn add(self, s: &str) -> String {
Nella libreria standard, add
è definito utilizzando type generici e type
associati. Qui, abbiamo sostituito type concreti, che è ciò che accade quando
chiamiamo questo metodo con valori String
. Parleremo dei generici nel Capitolo
10. Questa firma ci fornisce gli indizi necessari per comprendere le parti più
complesse dell’operatore +
.
Innanzitutto, s2
ha un &
, il che significa che stiamo aggiungendo un
reference della seconda stringa alla prima stringa. Questo è dovuto al
parametro s
nella funzione add
: possiamo solo aggiungere una slice di
stringa a una String
; non possiamo aggiungere due valori String
insieme. Ma
aspetta: il type di &s2
è &String
, non &str
, come specificato nel
secondo parametro di add
. Quindi perché il Listato 8-18 si compila?
Il motivo per cui possiamo usare &s2
nella chiamata a add
è che il
compilatore può costringere l’argomento &String
in un &str
. Quando
chiamiamo il metodo add
, Rust usa una deref coercion (de-referenziazione
forzata), che qui trasforma &s2
in &s2[..]
. Discuteremo la deref coercion
più approfonditamente nel Capitolo 15. Poiché add
non prende ownership del
parametro s
, s2
sarà comunque una String
valida dopo questa operazione.
In secondo luogo, possiamo vedere nella firma che add
prende ownership di
self
perché self
non ha un &
. Ciò significa che s1
nel Listato 8-18
verrà spostato nella chiamata add
e non sarà più valido da quel momento in
poi. Quindi, sebbene let s3 = s1 + &s2;
sembri copiare entrambe le stringhe e
crearne una nuova, questa istruzione in realtà prende ownership di s1
,
aggiunge una copia del contenuto di s2
per poi restituire la ownership del
risultato. In altre parole, sembra che stia facendo molte copie, ma non è così;
l’implementazione è più efficiente della semplice copia.
Se dobbiamo concatenare più stringhe, il comportamento dell’operatore +
diventa poco pratico:
fn main() { let s1 = String::from("uno"); let s2 = String::from("due"); let s3 = String::from("tre"); let s = s1 + "-" + &s2 + "-" + &s3; }
A questo punto, s
diventerà uno-due-tre
. Con tutti i caratteri +
e "
, è
difficile capire cosa sta succedendo. Per combinare stringhe in modi più
complessi, possiamo invece usare la macro format!
:
fn main() { let s1 = String::from("uno"); let s2 = String::from("due"); let s3 = String::from("tre"); let s = format!("{s1}-{s2}-{s3}"); }
Anche questo codice risulterà in s
che contiene uno-due-tre
. La macro
format!
funziona come println!
, ma invece di visualizzare l’output sullo
schermo, restituisce una String
con il contenuto. La versione del codice che
utilizza format!
è molto più facile da leggere e il codice generato dalla
macro format!
utilizza reference in modo che questa chiamata non assuma
ownership di nessuno dei suoi parametri.
Indicizzazione in String
In molti altri linguaggi di programmazione, l’accesso a singoli caratteri in una
stringa facendovi riferimento tramite indice è un’operazione valida e comune.
Tuttavia, se si tenta di accedere a parti di una String
utilizzando la
sintassi di indicizzazione in Rust, si otterrà un errore. Considera il codice
non valido nel Listato 8-19.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
String
Questo codice genererà il seguente errore:
$ cargo run
Compiling collections v0.1.0 (file:///progetti/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
but trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
L’errore spiega la situazione: le stringhe Rust non supportano l’indicizzazione. Ma perché no? Per rispondere a questa domanda, dobbiamo discutere come Rust memorizza le stringhe in memoria.
Rappresentazione Interna
Una String
è un wrapper di un Vec<u8>
. Diamo un’occhiata ad alcune delle
nostre stringhe di esempio correttamente codificate UTF-8 dal Listato 8-14.
Innanzitutto, questo:
fn main() { let saluto = String::from("السلام عليكم"); let saluto = String::from("Dobrý den"); let saluto = String::from("Hello"); let saluto = String::from("שלום"); let saluto = String::from("नमस्ते"); let saluto = String::from("こんにちは"); let saluto = String::from("안녕하세요"); let saluto = String::from("你好"); let saluto = String::from("Olá"); let saluto = String::from("Здравствуйте"); let saluto = String::from("Hola"); }
In questo caso, len
sarà 4
, il che significa che il vettore che memorizza la
stringa "Hola"
è lungo 4 byte. Ognuna di queste lettere occupa 1 byte se
codificata in UTF-8. La riga seguente, tuttavia, potrebbe sorprendervi (nota che
questa stringa inizia con la lettera maiuscola cirillica Ze, non con il numero
3):
fn main() { let saluto = String::from("السلام عليكم"); let saluto = String::from("Dobrý den"); let saluto = String::from("Hello"); let saluto = String::from("שלום"); let saluto = String::from("नमस्ते"); let saluto = String::from("こんにちは"); let saluto = String::from("안녕하세요"); let saluto = String::from("你好"); let saluto = String::from("Olá"); let saluto = String::from("Здравствуйте"); let saluto = String::from("Hola"); }
Se vi chiedessero quanto è lunga la stringa, potreste dire 12. In realtà, la risposta di Rust è 24: questo è il numero di byte necessari per codificare “Здравствуйте” in UTF-8, perché ogni valore scalare Unicode in quella stringa occupa 2 byte di spazio. Pertanto, un indice nei byte della stringa non sarà sempre correlato a un valore scalare Unicode valido. Per dimostrarlo, consideriamo questo codice Rust non valido:
let saluto = "Здравствуйте";
let risposta = &hello[0];
Sapete già che risposta
non sarà З
, la prima lettera. Quando codificato in
UTF-8, il primo byte di З
è 208
e il secondo è 151
, quindi sembrerebbe che
risposta
dovrebbe in effetti essere 208
, ma 208
non è un carattere valido
da solo. Restituire 208
probabilmente non è ciò che un utente vorrebbe se
chiedesse la prima lettera di questa stringa; Tuttavia, questo è l’unico dato
che Rust ha all’indice di byte 0. Gli utenti generalmente non vogliono che venga
restituito il valore in byte, anche se la stringa contiene solo lettere latine:
se &"hi"[0]
fosse codice valido che restituisce il valore in byte,
restituirebbe 104
, non h
.
La risposta, quindi, è che per evitare di restituire un valore inaspettato e causare bug che potrebbero non essere scoperti immediatamente, Rust non compila affatto questo codice e previene malintesi fin dalle prime fasi del processo di sviluppo.
Byte, Valori Scalari e Cluster di Grafemi!
Un altro punto su UTF-8 è che in realtà ci sono tre modi rilevanti per vedere le stringhe dalla prospettiva di Rust: come byte, valori scalari e cluster di grafemi (la cosa più vicina a ciò che chiameremmo lettere).
Se consideriamo la parola hindi “नमस्ते” scritta in alfabeto Devanagari, essa è
memorizzata come un vettore di valori u8
che appare così:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Sono 18 byte ed è così che i computer memorizzano questi dati. Se li
consideriamo come valori scalari Unicode, che corrispondono al type char
di
Rust, quei byte appaiono così:
['न', 'म', 'स', '्', 'त', 'े']
Ci sono sei valori char
qui, ma il quarto e il sesto non sono lettere: sono
segni diacritici che da soli non hanno senso. Infine, se li consideriamo come
cluster di grafemi, otterremmo ciò che una persona chiamerebbe le quattro
lettere che compongono la parola hindi:
["न", "म", "स्", "ते"]
Rust fornisce diversi modi di interpretare i dati stringa grezzi che i computer memorizzano, in modo che ogni programma possa scegliere l’interpretazione di cui ha bisogno, indipendentemente dal linguaggio umano in cui sono espressi i dati.
Un ultimo motivo per cui Rust non ci consente di indicizzare una String
per
ottenere un carattere è che le operazioni di indicizzazione dovrebbero sempre
richiedere un tempo costante (O(1)). Ma non è possibile garantire tali
prestazioni con una String
, perché Rust dovrebbe esaminare il contenuto
dall’inizio fino all’indice per determinare quanti caratteri validi ci sono.
Slicing delle Stringhe
Indicizzare una stringa è spesso una cattiva idea perché non è chiaro quale debba essere il type di ritorno dell’operazione di indicizzazione della stringa: un valore byte, un carattere, un cluster di grafemi o una slice. Se proprio si ha bisogno di usare gli indici per creare slice di stringa, Rust chiede di essere più specifici.
Invece di indicizzare usando []
con un singolo numero, si può usare []
con
un intervallo per creare una slice di stringa contenente byte specifici:
#![allow(unused)] fn main() { let saluto = "Здравствуйте"; let s = &saluto[0..4]; }
Qui, s
sarà un &str
che contiene i primi 4 byte della stringa. In
precedenza, abbiamo accennato al fatto che ognuno di questi caratteri era
composto da due byte, il che significa che s
sarà Зд
.
Se provassimo a suddividere solo una parte dei byte di un carattere con qualcosa
come &saluto[0..1]
, Rust andrebbe in panic in fase di esecuzione, proprio
come se si accedesse a un indice non valido in un vettore:
$ cargo run
Compiling collections v0.1.0 (file:///progetti/collections)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
Running `target/debug/collections`
thread 'main' (6240) panicked at src/main.rs:4:20:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
È necessario prestare attenzione quando si creano slice di stringhe con intervalli, perché ciò potrebbe causare l’arresto anomalo del programma.
Iterare sulle Stringhe
Il modo migliore per operare su stringhe è specificare esplicitamente se si
desidera caratteri o byte. Per singoli valori scalari Unicode, utilizzare il
metodo chars
. Chiamando chars
su “Зд” si separano e si restituiscono due
valori di type char
, ed è possibile iterare sul risultato per accedere a
ciascun elemento:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
Questo codice stamperà quanto segue:
З
д
In alternativa, il metodo bytes
restituisce ogni byte grezzo, che potrebbe
essere appropriato per quello che ti serve:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
Questo codice stamperà i 4 byte che compongono questa stringa:
208
151
208
180
Ma ricorda che i valori scalari Unicode validi possono essere composti da più di 1 byte.
Ottenere cluster di grafemi dalle stringhe, come con l’alfabeto Devanagari, è complesso, quindi questa funzionalità non è fornita dalla libreria standard. I crate sono disponibili su crates.io se questa è la funzionalità di cui avete bisogno.
Gestire le Complessità delle Stringhe
In sintesi, le stringhe sono complicate. Diversi linguaggi di programmazione
fanno scelte diverse su come presentare questa complessità al programmatore.
Rust ha scelto di rendere la corretta gestione dei dati String
il
comportamento predefinito per tutti i programmi Rust, il che significa che i
programmatori devono dedicare da subito maggiore attenzione alla gestione dei
dati UTF-8. Questo compromesso rende più evidente la complessità delle stringhe
rispetto ad altri linguaggi di programmazione, ma evita di dover gestire errori
che coinvolgono caratteri non ASCII in una fase successiva del ciclo di
sviluppo.
La buona notizia è che la libreria standard offre numerose funzionalità basate
sui type String
e &str
per aiutare a gestire correttamente queste
situazioni complesse. Assicuratevi di consultare la documentazione per metodi
utili come contains
per la ricerca in una stringa e replace
per sostituire
parti di una stringa con un’altra stringa.
Passiamo a qualcosa di un po’ meno complesso: le mappe hash!