Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 9
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 caratteri (la codifica è UTF-8) 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. Da un punto di vista visivo abbiamo:

stringa c i a o
indice 0 1 2 3

quindi la nostra sequenza è 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:

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";
| +


mentre

b) let x = "ciao";

funziona. Ovvero laddove entra in gioco l'inferenza di tipo le cose girano correttamente (perchè il compilatore sa cosa deve fare). L'errore evidenziato ci suggerisce come prima cosa che il tipo a cui appartiene "ciao" è individuato come &str. In dettaglio:

& è un operatore potente che abbiamo già incontrato con un ruolo diverso e che qui funge da riferimento ad una variabile. In breve possiamo accedere al contenuto senza possedere quel contenuto.
"ciao" è una stringa letterale. Essa è una sequenza di caratteri immutabile UTF-8 e viene gestita nella memoria statica del programma.
str è una string slice e ne parleremo tra poche righe.
&str suggerito dal compilatore (e creato dall'inferenza nell'esempio b) ) è un riferimento ad una string slice di cui conosce il contentuto senza averne il possesso. Qui la zona di memoria interessata è lo stack.

NB: Il termine slice è generico in Rust ed è appilcabile ad una larga varietà di tipi ad esempio di può parlare di slice di un array o di un vettore. In questo caso siamo ristretto all'ambito delle sequenze di caratteri o meglio delle stringhe letterali.

L'ultimo punto realizza un meccanismo fondamentale per Rust ovvero quello del borrowing che, appunto, significa "prestito", ovvero otteniamo in prestito qualcosa di cui non abbiamo pieno possesso ma che comunque possiamo utilizzare. Il meccanismo è interessante perchè, come vedremo, ogni valore in Rust può avere un solo proprietario (owner) ma tramite il borrowing tale valore può essere 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.
Tornando al nostro codice la scrittura corretta al posto della a) è quindi la seguente:


let x: &str = "ciao";

come appunto ci suggerisce il compilatore nella riga in verde. A questo punto tutto funziona. La riga evidenziata in blu, ci fornisce un altro interessante spunto. In pratica ci viene detto che str non ha dimensioni definite e quindi, per le solite questioni di safety, il codice non può essere accettato. Il nostro str, abbiamo detto, è una string slice (che chiamerò sempre con dizione inglese) e di per sè è un elemento generico che può contenere sequenze di qualsiasi lunghezza quindi il compilatore ci dice che non può creare direttamente qualcosa di cui non conosce le dimensioni, sa solo che tali dimensioni sono finite e quindi quanti e quali blocchi di memoria andrà a scrivere. Una volta che viene occupato lo spazio necessario, ad essa ci possiamo rivolgere tramite un puntatore a quella zona di memoria che quindi avrà accesso ai dati in esso contenuti. Inoltre il compilatore aggiunge anche, ed è forse questo il punto cruciale, che str non implementa il trait Sized che è invece proprio di quei tipi la cui dimensione (size) è nota a compile time. Questa mancata implementazione, che è ovviamente una scelta di design, comporta che non possiamo scrivere istruzioni come la a) perchè imporremmo una sorta chimiamola di forzatura dimensionale a qualcosa che non prevede un dimensionamento a priori.
Da un punto di vista logico si può dire che:

1) il compilatore alloca in memoria la stringa letterale "ciao"
2) accede a tale locazione tramite puntatore usando una string slice
3) effettua il binding con la variabile x 

Si tratta di un sistema efficiente, in quanto chiama in causa lo stack e sicuro perchè anche l'integrità dei dati è garantita. 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";

Il metodo 3 sarà chiarito quando parleremo dell'importantissimo concetto di lifetime. Nelle 3 definizioni precedenti possiamo come visto aggiungere la clausola mut per renderle modificabili.
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 studiarvele tutte.

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.

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.

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
1
2
3
4
5
6
fn main()
{
    let x: &str = "ciao";
    println!("{}", x.len());
    println!("{}", x.ends_with("ao"));
}

con questo ovvio output:

4
true

Potreste essere interessati ad estrarre un singolo carattere in una certa posizione nell'ambito di una string slice. Il metodo chars() è quello che fa al caso in quanto restituisce un iteratore che può estrarre il carattere in una certa posizione tramite il metodo nth() proprio degli iteratori (dei quali dovremo riparlare). Ecco l'esempio (non considerate alcune parti di codice oscure, per il momento):

  Esempio 9.2
1
2
3
4
5
6
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);
}

Prima di chiudere vediamo comunque qualche altro tipo di operazione che è possibile fare con le string slice così da poter essere produttivi da subito. Qualcosa potrebbe non essere chiaro, Rust è veramente ampio come linguaggio e alcune cose bisogna anticiparle per forza:

Concatenare due stringhe 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"); // s1 sarà "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());
}

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:

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)

copiare una string slice in un'altra è quindi semplice sulla base di quanto abbiamo visto. Il codice seguente dimostra l'indipendenza dei due elementi creati:

  Esempio 9.3
1
2
3
4
5
6
7
8
9
fn main()
{
    let mut s1 = "abcdef";
    let mut s2 = s1;
    println!("{}", s2);
    s1 = "eee";
    println!("{}", s2);
    println!("{}", s1);
}

il cui output è:

abcdef
abcdef
eee

ovvero, modificando s1 non viene modificata s2 che continuerà a puntare alla zona di memoria in cui sono contenuti i dati che avete importato al momento della assegnazione alla riga 4.

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);  
}

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. L'esempio dovrebbe chiarire questo passaggio:

  Esempio 9.4
1
2
3
4
5
6
7
8
9
fn main()
{
let s1 = "ciao
Mondo";
let s2 = "ciao\
Mondo";
println!("{}",s1);
println!("{}",s2);
}

che dà come 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 vedremo nel prossimo capitolo.