SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI
Memorizzazione di Testo Codificato in UTF-8 con le Stringhe
Le stringhe sono un argomento importante in Rust e possiamo capirle meglio ora. Molte persone nuove a Rust trovano difficile lavorare con le stringhe per tre motivi principali: Rust può rivelare errori potenziali in modo diretto, le stringhe sono più complesse di quanto molti programmatori credano e c’è la questione dell’UTF-8. Questi fattori si combinano in modo che le stringhe possano sembrare difficili se si proviene da altri linguaggi di programmazione.
Parliamo delle stringhe nel contesto delle collezioni, perché le stringhe sono implementate come una collezione di byte, con alcune funzioni utili quando quei byte vengono interpretati come testo. In questa sezione parleremo delle operazioni sulle Stringhe che ogni tipo di collezione ha, come la creazione, l’aggiornamento e la lettura. Discuteremo anche le differenze tra le Stringhe e le altre collezioni, in particolare la complessità dell’indicizzazione in una Stringa.
Cos’è una Stringa?
Prima di tutto, definiamo il termine stringa. Rust ha solo un tipo di stringa nel linguaggio di base, che è il slice di stringa str che di solito viene visto nella sua forma presa in prestito &str. Nel capitolo 4, abbiamo parlato di slice di stringa, che sono riferimenti a dati di stringa codificati in UTF-8 memorizzati altrove. Le stringhe letterali, ad esempio, sono memorizzate nel binario del programma e quindi sono slice di stringhe.
Il tipo Stringa, che è fornito dalla libreria standard di Rust anziché essere codificato nel linguaggio di base, è un tipo di stringa UTF-8 codificato, crescibile, mutabile e di proprietà. Quando ci riferiamo a “stringhe” in Rust, potremmo fare riferimento sia al tipo Stringa che al slice di stringa &str, non solo a uno di questi tipi. Anche se questa sezione tratta principalmente della Stringa, entrambi i tipi sono ampiamente utilizzati nella libreria standard di Rust, e sia la Stringa che gli slice di stringa sono UTF-8 codificati.
Creazione di una Nuova Stringa
Molte delle stesse operazioni disponibili con Vec<T> sono disponibili anche con le Stringhe, poiché la Stringa è implementata come un wrapper intorno a un vettore di byte con alcune garanzie, restrizioni e capacità aggiuntive. Un esempio di funzione che funziona allo stesso modo con Vec<T> e Stringa è la funzione new per creare un’istanza, mostrata nel Listato 8-11.
rust
let mut s = String::new();
Questo codice crea una nuova stringa vuota chiamata s, nella quale possiamo quindi caricare i dati. Spesso avremo alcuni dati iniziali con cui vogliamo iniziare la stringa. Per questo, usiamo il metodo to_string, che è disponibile su qualsiasi tipo che implementa il trait Display, come fanno le stringhe letterali. Il Listato 8-12 mostra due esempi.
rust
let data = "initial contents";
let s = data.to_string();
// Il metodo funziona anche direttamente su un letterale:
let s = "initial contents".to_string();
Questo codice crea una stringa contenente “initial contents”. Possiamo anche usare la funzione String::from per creare una Stringa da un letterale di stringa. Il codice nel Listato 8-13 è equivalente al codice dal Listato 8-12 che utilizza to_string.
rust
let s = String::from("initial contents");
Perché le stringhe sono utilizzate per molte cose, possiamo utilizzare molti API generici diversi per le stringhe, che ci forniscono molte opzioni. Alcuni di essi possono sembrare ridondanti, ma hanno tutti il loro posto! In questo caso, String::from e to_string fanno la stessa cosa, quindi la scelta dipende dallo stile e dalla leggibilità.
Ricordiamo che le stringhe sono codificate in UTF-8, quindi possiamo includere qualsiasi dato correttamente codificato in esse, come mostrato nel Listato 8-14.
rust
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
Tutti questi sono valori validi di Stringa.
Aggiornare una Stringa
Una Stringa può crescere di dimensione e il suo contenuto può cambiare, proprio come il contenuto di un Vec<T>, se vi inserisci più dati. Inoltre, puoi comodamente usare l’operatore + o la macro format! per concatenare i valori delle Stringhe. Aggiungere una Stringa con push_str e push
Possiamo far crescere una Stringa usando il metodo push_str per aggiungere uno slice di stringa, come mostrato nel Listato 8-15.
rust
let mut s = String::from("foo");
s.push_str("bar");
Listato 8-15: Aggiungere uno slice di stringa a una Stringa usando il metodo push_str
Dopo queste due righe, s conterrà foobar. Il metodo push_str prende uno slice di stringa perché non vogliamo necessariamente prendere il possesso del parametro. Ad esempio, nel codice del Listato 8-16, vogliamo essere in grado di usare s2 dopo averne aggiunto il contenuto a s1.
rust
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 è {s2}");
Listato 8-16: Usare uno slice di stringa dopo averne aggiunto il contenuto a una Stringa
Se il metodo push_str assumesse il possesso di s2, non potremmo stamparne il valore sull’ultima riga. Tuttavia, questo codice funziona come ci aspettiamo!
Il metodo push prende un singolo carattere come parametro e lo aggiunge alla Stringa. Il Listato 8-17 aggiunge la lettera “l” a una Stringa usando il metodo push.
rust
let mut s = String::from("lo");
s.push('l');
Listato 8-17: Aggiungere un carattere a un valore di Stringa usando push
Di conseguenza, s conterrà lol.
Concatenazione con l’operatore + o la macro format!
Spesso, vorrai combinare due stringhe esistenti. Un modo per farlo è utilizzare l’operatore +, come mostrato nel Listato 8-18.
rust
let s1 = String::from("Ciao, ");
let s2 = String::from("mondo!");
let s3 = s1 + &s2; // nota che s1 è stata spostata qui e non può più essere usata
Listato 8-18: Usare l’operatore + per combinare due valori di Stringa in un nuovo valore di Stringa
La stringa s3 conterrà Ciao, mondo!. La ragione per cui s1 non è più valida dopo l’aggiunta, e il motivo per cui abbiamo usato un riferimento a s2, ha a che fare con la firma del metodo che viene chiamato quando usiamo l’operatore +. L’operatore + usa il metodo add, la cui firma assomiglia a qualcosa del genere:
rust
fn add(self, s: &str) -> String {
Nella libreria standard, vedrai add definito usando i generici e i tipi associati. Qui, abbiamo sostituito tipi concreti, che è ciò che accade quando chiamiamo questo metodo con valori di Stringa. Discuteremo dei generici nel Capitolo 10. Questa firma ci dà gli indizi di cui abbiamo bisogno per comprendere i punti complicati dell’operatore +.
Innanzitutto, s2 ha un &, il che significa che stiamo aggiungendo un riferimento della seconda stringa alla prima stringa. Questo è dovuto al parametro s nella funzione add: possiamo aggiungere solo un &str a una Stringa; non possiamo aggiungere due valori di Stringa insieme. Ma aspetta—il tipo di &s2 è &String, non &str, come specificato nel secondo parametro di add. Allora perché il Listato 8-18 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 coercizione deref, che qui trasforma &s2 in &s2[..]. Discuteremo la coercizione deref più approfonditamente nel Capitolo 15. Poiché add non prende il possesso del parametro s, s2 sarà ancora una Stringa valida dopo questa operazione.
In secondo luogo, possiamo vedere nella firma che add prende il possesso di self, perché self non ha un &. Ciò significa che s1 nel Listato 8-18 sarà spostata nella chiamata a add e non sarà più valida dopo di quella. Quindi, anche se let s3 = s1 + &s2; sembra che copierà entrambe le stringhe e ne creerà una nuova, questa istruzione in realtà prende il possesso di s1, aggiunge una copia del contenuto di s2 e quindi restituisce il possesso del risultato. In altre parole, sembra che faccia molte copie ma non lo fa; l’implementazione è più efficiente della copia.
Se abbiamo bisogno di concatenare più stringhe, il comportamento dell’operatore + diventa ingestibile:
rust
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
A questo punto, s sarà tic-tac-toe. Con tutti i + e i caratteri “, è difficile capire cosa sta succedendo. Per combinazioni di stringhe più complesse, possiamo invece usare la macro format!:
rust
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
Anche questo codice imposta s su tic-tac-toe. La macro format! funziona come println!, ma anziché stampare l’output a schermo, restituisce una Stringa con il contenuto. La versione del codice che utilizza format! è molto più facile da leggere e il codice generato dalla macro format! utilizza i riferimenti in modo che questa chiamata non prenda il possesso di nessuno dei suoi parametri.
Indicizzazione nelle Stringhe
In molti altri linguaggi di programmazione, accedere ai singoli caratteri in una stringa facendo riferimento a essi per indice è un’operazione valida e comune. Tuttavia, se provi ad accedere a parti di una Stringa usando la sintassi di indicizzazione in Rust, otterrai un errore. Considera il codice non valido nel Listato 8-19.
rust
// Questo codice non compila!
let s1 = String::from("ciao");
let h = s1[0];
Listato 8-19: Tentativo di utilizzare la sintassi di indicizzazione con una Stringa
Questo codice produrrà il seguente errore:
rust
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: il tipo `String` non può essere indicizzato da `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` non può essere indicizzato da `{integer}`
|
= help: il trait `Index<{integer}>` non è implementato per `String`
= help: gli altri tipi seguaci implementano il trait `Index<Idx>`:
<String as Index<RangeFrom<usize>>>
<String as Index<RangeFull>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeToInclusive<usize>>>
<String as Index<std::ops::Range<usize>>>
Per ulteriori informazioni su questo errore, prova `rustc --explain E0277`.
error: could not compile `collections` due to previous error
L’errore e la nota raccontano la storia: le Stringhe di Rust non supportano l’indicizzazione. Ma perché no? Per rispondere a questa domanda, dobbiamo discutere di come Rust memorizza le stringhe in memoria.
Rappresentazione Interna
Una Stringa è un wrapper su un Vec<u8>. Esaminiamo alcune delle nostre stringhe di esempio UTF-8 correttamente codificate dal Listato 8-14. Innanzitutto, questa:
rust
let hello = String::from("Ciao");
In questo caso, la lunghezza sarà 4, il che significa che il vettore che memorizza la stringa “Ciao” è lungo 4 byte. Ogni lettera occupa 1 byte quando codificata in UTF-8. La riga seguente, tuttavia, potrebbe sorprenderti. (Nota che questa stringa inizia con la lettera cirillica Ze maiuscola, non il numero 3.)
rust
let hello = String::from("Здравствуйте");
Se chiedessimo quanti caratteri ci sono nella stringa, potresti dire 12. In realtà, la risposta di Rust è 24: è 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 nella sequenza di byte della stringa non sempre corrisponderà a un valore scalare Unicode valido. Per dimostrarlo, considera questo codice Rust non valido:
rust
// Questo codice non compila!
let hello = "Здравствуйте";
let answer = &hello[0];
Già sai che answer non sarà З, la prima lettera. Quando codificata in UTF-8, il primo byte di З è 208 e il secondo è 151, quindi sembrerebbe che answer dovrebbe essere 208, ma 208 non è un carattere valido da solo. Restituire 208 probabilmente non è quello che un utente vorrebbe se chiedesse la prima lettera di questa stringa; tuttavia, questi sono i dati che Rust ha all’indice di byte 0. Gli utenti generalmente non vogliono il valore del byte restituito, anche se la stringa contiene solo lettere latine: se &”hello”[0] fosse un codice valido che restituisse il valore del 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 fraintendimenti all’inizio del processo di sviluppo.
Byte, Valori Scalari e Cluster di Grafemi! Oh mio!
Un altro punto riguardo all’UTF-8 è che ci sono effettivamente tre modi rilevanti per guardare le stringhe dal punto di vista di Rust: come byte, valori scalari e cluster di grafemi (la cosa più simile a quello che chiameremmo lettere).
Se osserviamo la parola hindi “नमस्ते” scritta nell’alfabeto devanagari, è 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]
Questo è composto da 18 byte ed è come i computer memorizzano questi dati. Se li guardiamo come valori scalari Unicode, che è ciò che è il tipo char di Rust, quei byte appaiono così:
[‘न’, ‘म’, ‘स’, ‘्’, ‘त’, ‘े’]
Ci sono sei valori di char qui, ma il quarto e il sesto non sono lettere: sono segni diacritici che non hanno senso da soli. Infine, se li guardiamo come cluster di grafemi, otterremmo quello che una persona chiamerebbe le quattro lettere che compongono la parola hindi:
[“न”, “म”, “स्”, “ते”]
Rust fornisce modi diversi per interpretare i dati di stringa grezzi che i computer memorizzano in modo che ogni programma possa scegliere l’interpretazione di cui ha bisogno, indipendentemente dalla lingua umana in cui si trovano i dati.
Un motivo finale per cui Rust non ci permette di indicizzare una Stringa per ottenere un carattere è che le operazioni di indicizzazione dovrebbero sempre richiedere un tempo costante (O(1)). Ma non è possibile garantire quelle prestazioni con una Stringa, perché Rust dovrebbe attraversare il contenuto dall’inizio all’indice per determinare quanti caratteri validi ci sono.
Taglio delle Stringhe
Indicizzare una stringa è spesso una cattiva idea perché non è chiaro quale tipo di ritorno dovrebbe avere l’operazione di indicizzazione della stringa: un valore di byte, un carattere, un cluster di grafemi o una sottostringa. Se devi davvero usare gli indici per creare sottostringhe, Rust ti chiede di essere più specifico.
Invece di indicizzare usando [] con un singolo numero, puoi usare [] con un intervallo per creare una sottostringa contenente byte particolari:
rust
let hello = "Здравствуйте";
let s = &hello[0..4];
Qui, s sarà un &str che contiene i primi 4 byte della stringa. Prima abbiamo detto che ciascuno di questi caratteri era composto da 2 byte, il che significa che s sarà Зд.
Se dovessimo cercare di suddividere solo parte dei byte di un carattere con qualcosa come &hello[0..1], Rust genererebbe un errore di runtime allo stesso modo di come accade se si accede a un indice non valido in un vettore:
bash
$ cargo run
Compilazione di collections v0.1.0 (file:///projects/collections)
Terminato dev [non ottimizzato + informazioni di debug] target(s) in 0.43s
Esecuzione di `target/debug/collections`
il thread 'main' ha fatto panic su 'l'indice byte 1 non è un confine di carattere; è dentro a 'З' (byte 0..2) di `Здравствуйте`', src/main.rs:4:14
nota: esegui con la variabile di ambiente `RUST_BACKTRACE=1` per visualizzare una traccia di backtrace
Dovresti usare gli intervalli per creare sottostringhe con cautela, perché farlo può far bloccare il tuo programma.
Metodi per Iterare sulle Stringhe
Il modo migliore per operare su parti di stringhe è essere espliciti su se vuoi caratteri o byte. Per singoli valori scalari Unicode, usa il metodo chars. Chiamare chars su “Зд” separa e restituisce due valori di tipo char, e puoi iterare il risultato per accedere a ogni elemento:
rust
for c in "Зд".chars() {
println!("{c}");
}
Questo codice stamperà quanto segue:
bash
З
д
In alternativa, il metodo bytes restituisce ciascun byte grezzo, che potrebbe essere appropriato per il tuo dominio:
rust
for b in "Зд".bytes() {
println!("{b}");
}
Questo codice stamperà i quattro byte che compongono questa stringa:
bash
208
151
208
180
Ma assicurati di ricordare che i valori scalari Unicode validi possono essere composti da più di 1 byte.
Ottenere i cluster di grafemi dalle stringhe come con lo script Devanagari è complesso, quindi questa funzionalità non è fornita dalla libreria standard. Ci sono crate disponibili su crates.io se questa è la funzionalità di cui hai bisogno.
Le Stringhe Non Sono Così Semplici
Per riassumere, le stringhe sono complicate. Diversi linguaggi di programmazione fanno scelte diverse su come presentare questa complessità al programmatore. Rust ha scelto di rendere il corretto trattamento dei dati delle stringhe il comportamento predefinito per tutti i programmi Rust, il che significa che i programmatori devono riflettere di più sul trattamento dei dati UTF-8 all’inizio. Questo compromesso espone più della complessità delle stringhe rispetto a quanto appare in altri linguaggi di programmazione, ma impedisce di gestire errori che coinvolgono caratteri non ASCII più tardi nel ciclo di vita dello sviluppo.
La buona notizia è che la libreria standard offre molte funzionalità basate sui tipi String e &str per aiutare a gestire correttamente queste situazioni complesse. Assicurati di controllare 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 hash map!