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