Rust - Sequenze di caratteri
E' il momento di allacciare le cinture ed affrontare uno degli argomenti
cardine del linguaggio.
Cominciamo subito prendendo in esame la seguente
sequenza di byte UTF-8 valida
(che noi vediamo come normali caratteri) racchiusa all'interno di una coppia di doppia
apici. Ogni elemento è dotato di un suo indice sequenziale
di tipo usize che parte da
0 e prosegue fino all'ultimo elemento in modo incrementale. Da un punto di vista visivo
abbiamo:
| stringa | c | i | a | o |
| indice | 0 | 1 | 2 | 3 |
quindi la nostra sequenza compone
la parola "ciao". E fin qui tutto normale.
In molti linguaggi questa è semplicemente una stringa e può essere
utilizzata con tutte le sue peculiarità per quel linguaggio. Ad esempio
in Python potrete scrivere
x = "ciao"
o in C#
string x = "ciao";
e tutto funziona. Ma se in Rust provaste a scrivere un'istruzione come
a)
let x: str = "ciao";
confidando nel fatto che avete trovato str tra
le
keyword e pensate che essa individui normalmente una stringa,
ne ricavereste un errore "pesante". Tuttavia inizio proprio da qui
perchè si possono trarre importanti osservazioni dall'errore stesso,
veramente completo ed istruttivo, d'altronde, come ho già detto, il
compilatore è eccezionale sotto questo aspetto, la segnalazione degli errori:
error[E0308]: mismatched types
--> r105.rs:3:18
|
3 | let x: str = "ciao";
| ---
^^^^^^ expected `str`, found `&str`
| |
| expected due to this
error[E0277]: the size for values of type
`str` cannot be known at compilation time
--> r105.rs:3:9
|
3 | let x: str = "ciao";
| ^ doesn't have a size known at
compile-time
|
= help: the trait `Sized` is not implemented for `str`
= note: all local
variables must have a statically known size
= help: unsized locals are
gated as an unstable feature
help: consider borrowing here
|
3 | let x: &str = "ciao";
| +
invece la seguente definizione:
let x = "ciao";
lasciando quindi fare al compilatore via inferenza, funziona perfettamente.
Iniziamo con conoscere i vari pezzi che compongono questo puzzle:
"ciao" è una stringa letterale, tecnicamente si trova
nella memoria statica di sola lettura ovvero in un segmento statico del
programma e il suo tipo è:&'static str
in cui
'static con
l'apostrofo, garantisce la durata per tutto il periodo di vita del
programma. Questo concetto è correlato con quello di
lifetime che vedremo
più avanti. Questa definizione è la più completa: abbiamo un puntatore (&)
che si trova nello stack e, come da suo nome, punta
ad una entità la quale ha lifetime per tutta la durata del programma ('static) ed
è una sequenza di byte UTF-8 (str). Tuttavia se ne evidenziate il tipo ad
esempio tramite il seguente codice:let x: &'static str = "ciao";
println!("{}", std::any::type_name_of_val(&x));
ne otterrete
&str in quanto Rust nasconde il lifetime
perchè il ciclo di vita di quella specifica istanza è secondario, infatti
come detto la stringa di caratteri si trova in un'area sicura del programma,
e comunque il lifetime non fa parte del tipo a runtime ma solo in
compilazione, rispetto al tipo che resta comunque un puntatore ad una sequenza di
caratteri. Il nome che viene dato a &str è string slice,
nella dizione inglese o slice di stringa nella nostra.
E' importante notare e bisogna insistere su questo, che il fatto che
abbiamo un puntatore ad un'area di memoria significa in pratica
che ne abbiamo l'uso ma non il possesso.
In pratica otteniamo un prestito del
contenuto di quell'area di memoria ed è un meccanismo che prende il nome di
borrowing. Questo è centrale assolutamente in Rust e ne
costituisce uno dei pilastri per la gestione sicura della memoria.
In Rust ogni elemento può avere un solo proprietario (owner)
ma tramite il meccanismo del borrowing tale elemento può essere in pratica
condiviso. Con questo meccanismo garantiamo quindi l'accesso ad un oggetto
per riferimento. Il compilatore garantisce staticamente
(tramite il suo borrow checker) che i riferimenti
puntino sempre a oggetti validi. Quindi, finché esistono
riferimenti a un oggetto, questo non può essere distrutto. Il borrow checker
è parte fondamentale del compilatore e si occupa di verificare la legalità
di tutti gli accessi ai dati.
Proseguendo nella interessante analisi
dell'errore propostoci dal compilatore, noterete la riga in blu che ci dice
che str non ha dimensione definita. Questo perchè per sua
natura può contenere stringhe si qualsiasi tipo quindi l'informazione di
dimensione non fa parte della sua definizione ma può essere dedotta di volta
in volta. Il compilatore è molto preciso e ci dice che str non
implementa il trait Sized che è invece proprio di quei tipi la cui
dimensione (size) è nota a compile time.
Da un punto di vista logico si
può dire che, scrivere una espressione come : let x = ciao; prevede 3 fasi:
1) il compilatore alloca in memoria la stringa letterale
"ciao"
2) accede a tale locazione tramite puntatore
3) effettua il binding con la variabile x
Si tratta di un
sistema efficiente, in quanto chiama in causa lo stack, in cui come detto si
trova il puntatore e sicuro perchè anche
l'integrità dei dati è garantita. Concludendo, il nostro &str
internamente è costituito di due parti: un puntatore (per accedere alla
locazione di memoria dove sono conteniuti i dati) ed una lunghezza (che
indica quanti dati dovrà leggere). Pertanto possiamo dire che la dimensione
esatta di un &str nello stack può variare a seconda dell'architettura del
sistema su cui il codice è in esecuzione. Su sistemi a 32 bit, un puntatore
è generalmente di 4 byte e la lunghezza è anch'essa di 4 byte, quindi un
&str occuperebbe 8 byte nello stack. Su sistemi a 64 bit, un puntatore è
generalmente di 8 byte e la lunghezza è anch'essa di 8 byte, quindi un &str
occuperebbe 16 byte nello stack. Ovviamente possiamo anche rendere mutabile
la nostra x ed assegnare una valore diverso:
let mut x: &str = "ciao";
x = "hello";
questo non cambia il concetto di
immutabilità della stringa letterale "ciao", semplicemente il
puntatore di x si sposta verso l'area di memoria dove è contenuta la
stringa letterale "hello" mentre la sequenza "ciao" rimane nella sua
area fino al termine del programma.
Prima di proseguire, riassumiamo i modi per creare una string slice:
1) let s1 : &str = "ciao";
2) let s2
= "ciao";
3) let s3 : & 'static str = "ciao";
Le slice string hanno svariati metodi che ci permettono di lavorare su di
esse. Come al solito ne presento un assaggio di alcune che ritengo utili
nell'immediato, rimandandovi, lo so, sono un po' ripetitivo, al sito
ufficiale se volete studiarveli tutti dal momento che ce ne sono davvero
tanti.
len()
Restituisce la lunghezza della stringa in byte, non il
numero di caratteri, poiché Rust memorizza le stringhe come UTF-8 e alcuni
caratteri possono essere codificati con più byte.
is_empty()
Restituisce true se la
stringa è vuota (lunghezza zero).
contains(&self, pattern: &str) -> bool
Verifica se la stringa
contiene una certa sottostringa o pattern.
starts_with(&self, prefix: &str) -> bool
Determina se la stringa inizia con il prefisso specificato.
ends_with(&self, suffix: &str) -> bool
Determina se la stringa finisce con il suffisso specificato.
replace(&self, from: &str, to: &str) -> String
Crea una nuova String sostituendo tutte le occorrenze di una sottostringa
con un'altra. Da notare che il risultato è una String, non
un &str e di questo nuovo costrutto parleremo nel prossimo paragrafo.
split(&self, pattern: char) -> Split<char>
Restituisce un iteratore che permette di iterare attraverso le sottostringhe
di &str, separate da un pattern specificato. Split è un esempio di iteratore
specifico, e ci sono varianti come split_whitespace per casi d'uso comuni.
chars()
Restituisce un iteratore
sui caratteri della stringa. Dato che Rust codifica le stringhe in UTF-8,
questo iteratore decodifica i caratteri UTF-8 mentre itera. Ecco, questa
funzione secondo me va approfondita perchè molto utile nell'uso pratico, di
seguito vedremo un esempio.
bytes()
Restituisce un iteratore sui
byte grezzi della stringa, permettendo di lavorare a un livello più basso se
necessario.
Vediamo
un esempio:
Esempio 9.1
fn main() {
let x: &str = "ciao";
println!("{}", x.len());
println!("{}", x.ends_with("ao"));
}
il cui output ovviamente è:
| 4 true |
A proposito di len() però come abbiamo
detto bisogna fare attenzione al risultato che riporta. Dal momento che
conta i byte e non gli elementi, in caso di sequenze anche apparentemente
banali come "èèèè" il risultato non è quello che potremmo attenderci, ovvero
4 bensì 8 perchè la 'è' viene rappresentata tramite due byte e non da uno. E questo
vale anche per simboli più complessi. Se volete il numero preciso degli
elementi dovrete usare
chars().count()let x: &str = "èèèè";
println!("{}", x.chars().count());
e qui ricaveremo 4 come output.
Vediamo di affrontare qualche altra problematica di uso comune.
Potreste ad esempio essere interessati ad
estrarre un singolo carattere in una certa posizione nell'ambito di
una string slice. La funzione chars()
è ancora protagonista ed quello che fa al caso in quanto restituisce un
iteratore (già detto e anche ho già detto che gli iteratori verranno
trattati a parte)che può estrarre il carattere in una certa posizione
tramite il metodo nth()
proprio degli iteratori Ecco l'esempio (non considerate alcune parti
di codice oscure, per il momento):
Esempio 9.2
fn main() {
let x1 = "aaa";
let c1 = x1.chars().nth(1).unwrap();
let size_in_bytes = std::mem::size_of_val(&c1);
println!("l'ampiezza in memoria di {:?} vale {}", c1, size_in_bytes);
}
Poco più avanti torneremo ancora sulla questione del singolo carattere con un altro esempio approfondendo un po' la questione.
Altro problema comune è quello di dover concatenare due stringhe e
in questo ambito non è banalissimo. Una strada apparentemente semplice fa
uso della macro concat!. Questa non solo
vi permette di scrivere cose come:let s1 = concat!("ciao", "
mondo");
avremo "ciao mondo", senza apici ovviamente
ma anche
let s1 = concat!("aaa", "bbb", 'c', 10, 1.23);
s1
vale "aaabbbc101.23"
operando una conversione anche degli elementi
numerici. Molto bello! In fondo anche il nome della macro parla chiaro.
Tutto semplice? No, naturalmente. Non azzardatevi a scrivere:let s1
= "aa";
let s2 = "bb";
let s3 = concat!(s1, s2);
che ne
otterrete un errore in compilazione. La macro concat! non fa valutazioni
sulle variabili ma lavora proprio a livello di stringhe letterali. Quindi,
se state usando delle variabili, questa non è la strada. Detto che questa
operazione sarà più semplice quando incontreremo le String, un modo per
concatenare due stringhe letterali così come le abbiamo espresse e senza
altre interazioni possiamo fare così, usando un'altra macro,
format!:
let s1 = "abc";
let s2 = "def";
let s3 = format!("{}{}", s1, s2);
println!("{}", s3);
un altro sistema è quello di usare
un array con il suo metodo concat (non la macro)let s3 = [s1,
s2].concat();
Iterare sugli elementi di una string slice è a questo
punto banale:let s1 = "abcdef";
for x in s1.chars()
{
println!("{}", x);
}
oppure, se preferite un metodo più "rusticeano":let s1 =
"abcdef";
for x in 0..= s1.len() - 1
{
println!("{}",
s1.chars().nth(x).unwrap());
}
usando il nostro utilissimo chars().
Volendo possiamo estrarre una parte di una string slice e
crearne un'altra sulla base dei valori estratti; per fare questa
operazione possiamo utilizzare i range (altro argomento che rivedremo):
let s1 = "abcdef";
let s2 = &s1[1..3];
print!("{:?}", s2);
che copia gli elementi aventi indici 1 e 2 (il 3
è escluso in quanto il range è, appunto, esclusivo). Andrare fuori range
causa panico del programma.
copiare una string slice in un'altra è quindi semplice sulla base di quanto abbiamo visto. Il codice seguente dimostra l'uso dell'operatore = tenendo presente che si tratta pur sempre di una shallow copy di un puntatore e di una lunghezza. In effetti, come detto non c'è ownership della sequenza di caratteri, nel prossimo paragrafo vedremo un costrutto che permette di avere delle deep copy di string slice.
Esempio 9.3
fn main(){
let mut s1 = "abcdef";
let mut s2 = s1;
println!("{}", s2);
s1 = "eee";
println!("{}", s2);
println!("{}", s1);
}
l'output è:
| abcdef abcdef eee |
s1 punta ad una nuova stringa, s2 continua a puntare la stringa "abcdef".
Per trovare un elemento in una string slice potete guardare gli esempi nella sezione successiva, quella delle stringhe.
Se invece vi interessa vedere la parte "puntatore" di una string slice ecco
qua il codice che fa per voi:fn main(){
let s1 = "abc";
let p1 =
s1.as_ptr();
println!("{:?}", p1);
}
Un errore comune che fa chi proviene (mi ci metto in mezzo anche io) da altri linguaggi è quello di trattare una string slice come fosse un array ovvero pensando di avere una sequenza indicizzata qualunque. E quindi conscio del fatto che l'indice è di tipo usize e che esiste un operatore [ ] prova questo tipo di codice:
fn main(){
let s1 = "ciao";
let x: usize = 0;
println!("{}",s1[0]);
}
con il quale arriva un errore pesante:
error[E0277]: the type `str` cannot be indexed by `{integer}`
-->
r104.rs:4:20
|
4 | println!("{}",s1[0]);
| ^ string
indices are ranges of `usize`
Il punto è quello visto in precedenza, la rappresentazione dei caratteri non univoca (noi ne vediamo uno ma possono essere composti da più byte, come detto) e questo comporta una non uniformità a livello di prestazioni e di risultati. Rust evita più che può le incoerenze. Ci sono due soluzioni che mi piacciono:
1) accesso indiretto tramite chars():
come abbiamo visto in precedenza nell'esempio 9.2, approccio che si avvicina a quello classico ma che in realtà, under the hood,
lavora in modo diverso. Questo modo di procedere tuttavia non è O(1) ma O(n)
cioè la complessità computazionale non è costante ma cresce quanto
più aumentano gli elementi. Questo è importante da sottolineare.let s1 =
"ciao";
println!("{}",s1.chars().nth(0).unwrap());
println!("{}",s1.chars().nth(1).unwrap());
2) Accesso a livello di byte:let s1 = "cioè";
println!("{}",s1.as_bytes()[0]);
println!("{}",s1.as_bytes()[3]);
Per quanto non sappia se e quando userete questa feature, è possibile anche effetturare la stampa delle stringhe inserendo il carattere "a capo" che può anche essere bypassato peraltro tramite l'inserimento del carattere \. L'esempio seguente dovrebbe chiarire questo passaggio:
Esempio 9.4
fn main(){
let s1 = "ciao
Mondo";
let s2 = "ciao\
Mondo";
println!("{}",s1);
println!("{}",s2);
}
output:
| ciao Mondo ciaoMondo |
Il fatto di trovarci di fronte ad una sequenza di caratteri immutabile può essere per qualche aspetto limitativo, costringendoci ad allocare nuova memoria per cambiare i contenuti. Rust ci offre anche un altro costrutto per la gestione delle sequenze di caratteri, costrutto che come abbiamo più volte anticipato, vedremo nel prossimo capitolo: il tipo String
Concludiamo questo paragrafo con una piccola ma utile tabella riassuntiva:
| Espressione | Tipo reale | Può essere usato come |
|---|---|---|
"ciao" |
&'static str |
&str |
let x = "ciao"; |
&'static str |
&str (via coercizione) |
let x: &str = "ciao"; |
&str |
— |
let x: &'static str = "ciao"; |
&'static str |
— |