Rust - Le stringhe


Abbiamo introdotto il concetto di barrowing nel paragrafo precedente. In questo capitolo introduciano la nozione di ownership altro pilastro di questo linguaggio. Ne parliamo grazie alle stringhe o meglio è un buffer di byte UTF‑8 validi, posseduto e mutabile , differenti da quelle immutabili e"prestate" incontrate nello scorso paragrafo.
La keyword che le identifica è String e rappresenta qualcosa di un po' più complesso rispetto alle string slice viste in precedenza. Dal punto di vista della "velocità", del peso computazionale, esse sono forse un po' più lente nell'uso rispetto a &str ma sono anche modificabili "in place" ovvero senza necessità che in caso di modifiche se ne debba creare una nuova, possono quindi crescere di dimensione, diminuire ecc.. il che le rende strumenti estremamente duttili. D'altro canto, è anche possibile che modifiche apportate possano obbligare ad una completa riallocazione degli elementi (perchè ad esempio aggiungendo nuovi caratteri lo spazio contiguo in memoria non è sufficiente in quella locazione e quindi la stringa necessita di essere spostata in una nuova area) ed ecco un motivo per cui potrebbero essere più pesanti dal punto di vista prestazionale.
Una caratteristica fondamentale delle stringhe è che esse coinvolgono sia lo stack che lo heap. Infatti suoi elementi costitutivi sono allocati nello heap, cosa che è intuibile considerando la natura dinamica di questa struttura dati che ben si coniuga con quella zona di memoria, ma viene coinvolto anche lo stack nel quale è allocata la struttura di base, per cui, a questo punto, direi che è bene chiarire la situazione che si presenta nel momento in cui avremo creato la nostra stringa:

In pratica, se "ciao" è la nostra stringa abbiamo questa situazione:


Vediamo ora come creare una stringa, per uniformità e coerenza le presentiamo tutte come mutabili:

1) attraverso new( )
let mut s1 = String::new();
abbiamo creato una stringa vuota alla quale si potranno aggiungere altri elementi se ovviamente si è messa, come in questo caso, la clausola mut

 2) partendo da una string slice con il metodo to_string( ) oppure to_owned( )
let mut s1 = "ciao".to_string();
let mut s1 = "ciao".to_owned();


questi due metodi sono in pratica equivalenti ma il secondo è operativamente più leggero e in linea di massima quest'ultimo è quello da preferire nell'uso pratico anche per ragioni puramente idiomatiche: infatti più che una conversione c'è una presa di possesso della sequenza di caratteri. Se invece dobbiamo convertire qualcosa che non è una stringa, ad esempio un numero, allora si deve usare to_string( ).

3) usando from( )
let mut s1 = String::from("ciao");

4) usando into( )
let mut my_str = "Questo è un test";
let mut s1: String = my_str.into();

5) usando la macro format!
let mut s1 = format!("{} {}", "hello", "world");

6) partendo da un vettore di byte (dobbiamo ancora parlare dei vettori e degli array) tenendo presente che i valori nell'array devono essere UTF-8 validi per convenire alla definizione iniziale:
let vec = vec![104, 101, 108, 108, 111]; // 'hello' in ASCII
let mut s1 = String::from_utf8(vec).unwrap();

7) partendo da una sequenza di caratteri:
use std::iter::FromIterator;
fn main() {
  let chars: Vec<char>= vec!['a', 'b', 'c'];
  let mut s1 = String::from_iter(chars);
}

Esistono altre strade, ma per ora queste possono bastare. Non è necessario che tutti questi sistemi siano chiari adesso, potrete tornarci sopra in seguito quando avremo affrontato altri argomenti, abbiamo già accennato ad array e vettori, ci aggiungo anche gli iteratori.
Di passaggio risolviamo subito un problema che molti si pongono ovvero come passare dalle slice che abbiamo visto nel capitolo precedente a String e viceversa; il problema è di facile soluzione:

let s1: String = "abc".to_owned();
let s2: &str = &s1[..];

la prima scrittura ci porta da slice a stringa, come al punto 2) precedente, la seconda tramite creazione di un puntatore costruisce una string slice.

Quando definiamo una stringa in uno dei modi visti in precedenza, il contenuto è in suo pieno possesso per questo motivo si parla di ownership. Le basi dell'ownership sono tre:

L'ultimo punto in particolare è importante per capire come Rust provveda automaticamente a ripulire in maniera sicura lo heap da elementi non più necessari. La pulizia è sicura sulla base dei primi due punti che garantiscono che non ci sia il rischio di imbattersi nei famosi e temuti dangling pointers.
Il seguente codice, che si basa su una string slice, compila e stampa due volte la stringa "abc" a video:

let s1: &str = "abc";
let s2 = s1;
println!("{}", s1);
println!("{}", s2);

questo secondo codice invece non compila:

let s1 = String::from("abc");
let s2 = s1;
println!("{}", s1);
println!("{}", s2);

e l'errore che ci presenta è molto interessante:

error[E0382]: borrow of moved value: `s1`
--> r141.rs:4:20
|
2 | let s1 = String::from("abc");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{}", s1);
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
Error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0382`.

in particolare le righe evidenziate in rosso sono estremamente indicative: La macro di stampa cerca di prendere in prestito (borrow) la stringa s1 ma il valore di questa è stato mosso, ovvero ha cambiato padrone e noi sappiamo che, a seguito della seconda riga del codice, che questo valore è stato assegnato alla variabile s2 che ora ne è il proprietario, l'unico proprietario, come da regole che delimitano l'ownership. La seconda riga evidenziata ci spiega che le stringhe non implementano il trait copy quindi l'unico modo per completare l'assegnazione è  il passaggio di proprietà verso s2. Ne consegue partanto che s1 è uscita dallo stack (non ci sarà il temuto double free error) ma la stringa nello heap non subirà un dropping perchè s2 punta ancora ad essa. E siccome il compilatore vuole andare fino in fondo e si traveste un po' da insegnante del linguaggio, ci invita ad usare il metodo clone che lavora diversamente da copy come vedremo. Tenete presente fin da subito che clone è piuttosto pesante come operazione e il compilatore vi dice infatti "if the performance cost is acceptable". Il primo esempio invece che si basava sulle string slice funziona senza problemi perchè il borrowing permette più puntatori ad una stringa letterale come abbiamo visto nel precedente paragrafo.
Avremo modo di vedere il concetto di ownership in azione in vari ambiti, dalla concorrenza a struttura dati interessanti come gli enumerativi o i vettori.

Tenete presente però che è bene fare un discorso profondo su barrowing e ownership così da avere le idee chiare su questo fondamentale argomento. Lo faremo nel  prossimo paragrafo anche con esempi che potranno non essere chiari all'inizio, quindi vi invito a tornarci sopra perchè, ripeto, è assolutamente essenziale comprendere questi argomenti che costituiscono il modello che ha portato al successo di questo linguaggio.

E' il momento di presentare come potrete lavorare con le String. Il materiale è tanto ne presento una parte che ritengo utile ed interessante.
Da un punto di vista tecnico una stringa è array di u8 (vec<u8> non preoccupatevi per la definizione per adesso) e tutti i suoi elementi devono essere UTF8 validi. Anche per loro esiste una indicizzazione che inizia da 0 e prosegue progressivamente di una unità per ogni elemento. Gli indici devono essere di tipo usize come nel caso delle string slice, particolare da non dimenticare mai.

La lunghezza di una stringa è rilevabile tramite il consueto metodo len( ) tenendo presente che, anche in questo caso, viene espressa la lunghezza, in byte e quindi non si tratta esattamente del conteggio degli elementi:

let s1: String = "abè".to_owned();
let s2: String = "abc".to_owned();
println!("{}", s1.len());
println!("{}", s2.len());

qui s1 avrà lunghezza 4 ed s2 lunghezza 3 per i motivi già specificati nel paragrafo relativo alle string slice. Ricordiamoci sempre che parliamo di elementi UTF8 non di semplici caratteri ASCII.
Se vogliamo il numero di elementi possiamo, come per le string slice ricorrere a chars().count(), più pesante:
println!("{}", s1.chars().count());

Altro problema, aggiungere, o meglio appendere elementi in una stringa, che ovviamente deve essere mutabile, abbiamo il metodo push(carattere), vediamo un esempio che parte da una stringa vuota:

Esempio 10.1

fn main(){
  let mut s1 = String::new();
  s1.push('a');
  s1.push('b');
  s1.push('2');
  println!("{}", s1);
}

che costruisce la stringa "ab2".

il metodo clear() cancella tutti gli elementi

let mut s1 = "ciao".to_string();
s1.clear();

mentre remove(indice) elimina l'elemento alla posizione indicata dall'indice o meglio rimuove il carattere UTF8 che inizia al byte "indice":

let mut s1 = "ciao".to_string();
s1.remove(3);
println!("{}", s1);


questo spezzone ci propone in output la stringa "cia" privata quindi dell'elemento alla posizione 3, la lettera 'o'.

Al contrario insert() inserisce un carattere alla posizione indicata (più avanti vedremo un altro sistema):

let mut s1 = "ciao".to_string();
s1.insert(3, 'r');
println!("{}", s1);


che ci restituisce la parola "ciaro" avendo inserito la lettera 'r' alla posizione con indice 3.

Forse ancora più utile è insert_str(indice, string slice) tramite il quale possiamo inserire una string slice alla posizione che indichiamo con l'indice.

let mut s1 = "ciao".to_string();
s1.insert_str(3, "sss");
println!("{}", s1);


e il nostro "ciao" diventa uno strambo "ciassso".

Possiamo invece sostituire uno o più caratteri nella stringa e un modo semplice per fare questa operazione è ricorrere a
replace_range(range, stringa).

Esempio 10.2

fn main(){
    let mut s1 = "aaaaa".to_owned();
    println!("{}", s1);
    s1.replace_range(1..3, "www");
    println!("{}", s1);
    s1.replace_range(0..1, "z");
    println!("{}", s1);
    s1.replace_range(0..0, "k");
    println!("{}", s1);
}

con output:

aaaaa
awwwaa
zwwwaa
kzwwwaa

- la riga 5 sostituisce gli elementi con indice 1 e 2 (il range non è inclusivo) con la stringa "www"
- la riga 7 sostituisce l'elemento all'indce 0 con la stringa (non carattere) "z"
- la riga 9 inserisce la stringa "k" all'indice zero (fa lo stesso lavoro di insert()).

Tutte le modifiche effettuate in questi esempi modificano la stringa senza crearne un'altra. L'ownership esercitata sul contenuto significa proprio questo. Ragionando su questo argomento, è inevitabile constatare che possono esservi problemi di performance quando si operano molte modifiche che possono costringere a riallocazioni dell'intera sequenza. Un modo per calmierare le cose può essere quello che fa uso del metodo with_capacity(n). Tale metodo predispone lo spazio indicato per accogliere una stringa ed è quindi indicato qualora si sappia le dimensioni di quello che sarà accolto o ci si voglia comunque cautelare entro certi margini.

let mut s1 = String::with_capacity(50);

Qui s1 ha lunghezza 0 ma per essa è già predisposta una capacità di 50 elementi. Qualora dovessimo aggiungerne non sarà necessario quindi allocare nuovo spazio, tutto a vantaggio delle prestazioni. Rovescio della medaglia: se predisponete spazio senza criterio e poi non serve sprecate memoria.

L'accesso  al singolo elemento è il medesimo visto per le string slice:

let s1 = "abcd".to_owned();
println!("{:?}", s1.chars().nth(2).unwrap());


è interessante notare che il seguente codice funziona:

fn main() {
  let s1 = "abcd".to_owned();
  let x = 2;
  println!("{:?}", s1.chars().nth(x).unwrap());
}


mentre quest'altro origina un errore:

fn main() {
  let s1 = "abcd".to_owned();
  let x: i32 = 2;
  println!("{:?}", s1.chars().nth(x).unwrap());
}


questo perchè nel secondo caso Rust non può convertire in usize un elemento che abbiamo imposto essere un i32.

Selezionare un indice fuori range, in questa fase almeno, dà origine ad un crash (o meglio un panic) del programma. Questo vale anche per le string slice, naturalmente. Dico in questa fase perchè più avanti impareremo a gestire anche queste sictuazioni.

La concatenazione delle stringhe è ottenibile in vari modi:

il primo è il classico concat! che si applica su string slice

let s3 = concat!("ciao", "ciao", "ciao");

oppure concat(), come visto con le string slice

let s1 = "ciao".to_string();
let s2 = "ciao".to_string();
let s3 = [s1, s2].concat();

 
anche push_str(stringa) permette di unire una stringa con una string slice

let mut s1 = "ciao".to_string();
s1.push_str("ddd");


oppure operando sulle stringhe:

let mut s1 = "aaa".to_string();
let s2 = "bbb".to_string();
s1.push_str(&s2);
// s2 usata per riferimento

e poi abbiamo già visto, ovviamente format! che lavora direttamente su stringhe

let s1 = "aaa".to_string();
let s2 = "bbb".to_string();
let s3 = format!("{}{}", s1, s2);


Si può anche usare l'operatore +:

let s1 = "aaa".to_string();
let s2 = "bbb".to_string();
let s3 = s1 + &s2;
// s2 è usata per riferimento

è interessante notare appunto che con il + il secondo termine deve essere usato per riferimento. Questo perchè usa l'implementazione interna di Add che è definita, in parte, come segue:

fn add(mut self, other: &str) -> String

Altra considerazione importante relativa a quest'ultimo esempio, è che il + "consuma" il termine s1 che viene mosso e a quel punto ad esempio il tentativo di stamparlo darebbe errore. Su questo occorre fare un po' di attenzione.
Sinceramente trovo più lineare , leggibile e sicuro usare format! rispetto al + in particolare se dovete concatenare più stringhe che aggiunge anche una conversione implicita da &String a &str (parliamo del secondo parametro ovviamente)

Per trovare la prima occorrenza di un carattere in una stringa potreste usare il metodo find(carattere). Questo restituisce un tipo Option, che vedremo nella apposita sezione per cui potreste, di primo acchito avere qualche sorpresa. Per ora prendete l'esempio così come viene:

Esempio 10.3

fn main(){
    let s1 = "aaa".to_string();
    println!("{:?}", s1.find('a'));
    println!("{:?}", s1.find('k'));
}

che restituisce:

Some(0)
None

Come detto questo sarà più chiaro quando vedremo il tipo Option. Evidentemente 0 è l'indice della prima occorrenza di 'a' mentre None si riferisce al fatto che 'k' non è presente nella stringa "aaa". Un altro metodo fa uso dell'iteratore chars():

println!("{}", s1.chars().position(|x| x == 'a').unwrap());

Per copiare una stringa in un'altra seguiamo il suggerimento che ci ha dato in precedenza il compilatore quando avevamo cercato di usare il segno = per effettuare l'operazione. Facciamo uso quindi di clone():

Esempio 10.4

fn main(){
    let mut s1 = "aaa".to_string();
    let s2 = s1.clone();
    println!("{}", s1);
    println!("{}", s2);
    s1 = "yyy".to_string();
    println!("{}", s1);
    println!("{}", s2);
}

da cui otteniamo:

aaa
aaa
yyy
aaa

le due stringhe sono indipendenti e le variazioni su s1 non influiscono su s2.

Chiudiamo con una annotazione più leggera: anche con le nostre String, è possibile scrivere l'output su più righe inserendo un " a capo" che può essere eventualmente aggirato, proprio come abbiamo visto nel capitolo precedente.

let s1 = "ciao
Mondo".to_owned();
let s2 = "ciao\
Mondo".to_owned();

Questa è solo una panoramica superficiale di quanto disponibile per le stringhe. Il sito ufficiale, nella pagina dedicata, riporta ovviamente tutto e, come per le string slice, vi consiglio di dare una lettura a quanto vi troverete.

Torniamo per un attimo, per concludere, ad una questione che a volte sorge riguarda la convenienza dell'uso delle slice string o delle stringhe. In generale le prime si fanno preferire in quanto più leggere e performanti, specialmente in progetti pesanti. Se avete bisogno della possibilità di modificare una sequenza di caratteri le stringhe sono il tipo adatto, in particolare se ne potrete definire in anticipo la capacità,  così come quando non sapete quanto sarà la durata, nell'ambito del programma, della vostra sequenza.

Rust ci propone anche la possibilità di creare delle cosiddette byte string literal (uno slice di byte, in sostanza) che si caratterizzano con il prefisso 'b' davanti alla stringa stessa. Esempio:

let s = b"ciao";

in pratica, come definizione generica e forse quella più nota, mentre una stringa normale ("ciao") è una sequenza di caratteri Unicode, una byte string è una sequenza di numeri interi a 8 bit che rappresentano i codici ASCII di quei caratteri. Affinchè la byte string sia valida i valori numerici devono coincidere solo con corrispondenti caratteri ASCII. Quindi solo se tutti i byte che contiene sono valori compresi tra 0 e 255. Diversamente vengono rifiutati. Si usano prevalentemente nella programmazione di sistema o in alcune forme di crittografia.

Infine segnalo, ma ne parleremo altrove, anche le

&Cstr

ed è il tipo che rappresenta una C string immutabile, cioè una stringa in stile C:

--- terminata da byte 0 (\0)
--- contenente byte arbitrari, non necessariamente UTF‑8
--- vista come &[u8] fino al terminatore

ovviamente usata per interoperabilità con il linguaggio C. Anche questo argomento, per ora, va al di là dei nostri scopi.

Per concludere inserisco una tabella comparativa tra string slice e String:

Caratteristica String &str
Allocazione Heap Nessuna (slice su dati esistenti)
Mutabilità Mutabile (se mut) Sempre immutabile
Ownership Possiede i dati Non possiede, è un riferimento
Lunghezza In byte In byte
UTF‑8 Sempre valida Sempre valida
Copy No
Clone Sì (copia i byte) Triviale (copia il riferimento)