|
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:
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";
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:
con questo ovvio output:
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):
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:
il cui output è:
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:
che dà come output:
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. |