Il Type Slice
Le slice (sezioni, fette, porzioni in italiano) ti permettono di fare riferimento (un reference) a una sequenza contigua di elementi in una collezione. Una slice è una tipologia di reference, quindi non ha ownership.
Ecco un piccolo problema di programmazione: scrivi una funzione che prenda una stringa di parole separate da spazi e restituisca la prima parola che trova in quella stringa. Se la funzione non trova uno spazio nella stringa, l’intera stringa deve essere considerata come una sola parola, quindi deve essere restituita l’intera stringa.
Nota: Ai fini dell’introduzione alle slice di stringhe, stiamo assumendo solo caratteri ASCII in questa sezione; una discussione più approfondita sulla gestione di UTF-8 si trova nella sezione “Memorizzare testo codificato UTF-8 con le stringhe” del Capitolo 8.
Lavoriamo su come scrivere la firma di questa funzione senza usare slice per ora, per comprendere il problema che le slice risolveranno:
fn prima_parola(s: &String) -> ?
La funzione prima_parola
ha un parametro di tipo &String
. Non abbiamo
bisogno di ownership, quindi va bene. (In Rust idiomatico, le funzioni non
prendono la ownership dei loro argomenti se non strettamente necessario, e i
motivi per questo diventeranno chiari man mano che andremo avanti.) Ma cosa
dovremmo ritornare? Non abbiamo davvero un modo per descrivere una parte di
una stringa. Tuttavia, potremmo restituire l’indice della fine della parola,
indicato da uno spazio. Proviamo a farlo, come mostrato nel Listato 4-7.
fn prima_parola(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &lettera) in bytes.iter().enumerate() { if lettera == b' ' { return i; } } s.len() } fn main() {}
prima_parola
che restituisce un valore di indice byte nella variabile String
Poiché dobbiamo esaminare la String
elemento per elemento e controllare se un
valore è uno spazio, convertiremo la nostra String
in un array di byte usando
il metodo as_bytes
.
fn prima_parola(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &lettera) in bytes.iter().enumerate() {
if lettera == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Successivamente, creiamo un iteratore sull’array di byte usando il metodo
iter
:
fn prima_parola(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &lettera) in bytes.iter().enumerate() {
if lettera == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Discuteremo gli iteratori in modo più dettagliato nel Capitolo 13. Per ora, sappi che iter
è un metodo che restituisce ogni elemento
in una collezione e che enumerate
prende il risultato di iter
e restituisce
ogni elemento come parte di una tupla. Il primo elemento della tupla restituita
da enumerate
è l’indice, e il secondo elemento è un riferimento all’elemento.
Questo è un po’ più conveniente rispetto a calcolarci l’indice da soli.
Poiché il metodo enumerate
restituisce una tupla, possiamo usare i pattern
per destrutturare quella tupla. Discuteremo meglio i pattern nel Capitolo
6. Nel ciclo for
, specifichiamo un pattern che ha i
per l’indice
nella tupla e &item
per il singolo byte nella tupla. Poiché da
.iter().enumerate()
otteniamo un reference all’elemento, usiamo &
nel
pattern.
All’interno del ciclo for
, cerchiamo il byte che rappresenta lo spazio usando
la sintassi del letterale byte. Se troviamo uno spazio, restituiamo la
posizione. Altrimenti, restituiamo la lunghezza della stringa usando s.len()
.
fn prima_parola(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &lettera) in bytes.iter().enumerate() {
if lettera == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Ora abbiamo un metodo per scoprire l’indice della fine della prima parola nella
stringa, ma c’è un problema. Stiamo ritornando un usize
da solo, che è un
numero significativo solo se usato in contesto con &String
. In altre parole,
poiché è un valore separato dalla String
, non c’è garanzia che rimarrà valido
in futuro. Considera il programma nel Listing 4-8 che utilizza la funzione
prima_parola
dal Listing 4-7.
fn prima_parola(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &lettera) in bytes.iter().enumerate() { if lettera == b' ' { return i; } } s.len() } fn main() { let mut s = String::from("hello world"); let parola = prima_parola(&s); // `parola` riceverà il valore 5 s.clear(); // questo svuota la String, rendendola uguale a "" // `parola` mantiene ancora il valore di 5, ma `s` non contiene più quello a cui // quel 5 si riferisce, quindi `parola` è adesso considerabile come non valida! }
prima_parola
e poi modificare il contenuto della String
Questo programma si compila senza errori e lo farebbe anche se usassimo parola
dopo aver chiamato s.clear()
. Poiché parola
non è collegato allo stato di
s
, parola
contiene ancora il valore 5
. Potremmo usare quel valore 5
con
la variabile s
per cercare di estrarre la prima parola, ma questo sarebbe un
bug perché il contenuto di s
è cambiato da quando abbiamo salvato 5
in
parola
.
Doversi preoccupare dell’indice in parola
che si disallinea con i dati in s
è noioso e soggetto a errori! Gestire questi indici è ancora più fragile se
scriviamo una funzione seconda_parola
. La sua firma dovrebbe apparire così:
fn seconda_parola(s: &String) -> (usize, usize) {
Ora stiamo tracciando un indice di partenza e un indice di fine, e abbiamo ancora altri valori che sono stati calcolati da dati in un determinato stato, ma che non sono per nulla legati a quello stato in qualche modo. Abbiamo tre variabili non correlate e indipendenti che noi dobbiamo mantenere in sincronia.
Fortunatamente, Rust ha una soluzione a questo problema: le slice di stringhe.
Slice di Stringa
Una slice di stringa (string slice) è un reference a una sequenza contigua
degli elementi di una String
, e appare così:
fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
Invece di un reference all’intera String
, hello
è un reference a una
porzione della String
, specificata con l’aggiunta di [0..5]
. Creiamo le
slice usando un intervallo all’interno delle parentesi quadre specificando
[indice_inizio..indice_fine]
, dove indice_inizio
è la prima posizione
nella slice e indice_fine
è l’ultima posizione nella slice più uno.
Internamente, la struttura dati della slice memorizza la posizione iniziale e
la lunghezza della slice, che corrisponde a indice_fine
meno
indice_inizio
. Quindi, nel caso di let world = &s[6..11];
, world
sarebbe
una slice che contiene un puntatore al byte all’indice 6 di w
con un valore
di lunghezza di 5
.
La Figura 4-7 mostra questo in un diagramma.
Figura 4-7: Slice di stringa che si riferisce a parte di
una String
Con la sintassi d’intervallo ..
di Rust, se vuoi iniziare dall’indice 0, puoi
omettere il valore prima dei due punti. In altre parole, questi sono
equivalenti:
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
Allo stesso modo, se la tua slice include l’ultimo byte della String
, puoi
omettere il numero finale. Ciò significa che questi sono equivalenti:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[3..len]; let slice = &s[3..]; }
Puoi anche omettere entrambi i valori per prendere una slice dell’intera stringa. Quindi questi sono equivalenti:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; }
Nota: Gli indici di intervallo delle slice di stringa devono trovarsi in posizioni valide tenendo conto anche dei caratteri UTF-8. Se tenti di creare una slice nel mezzo di un carattere multi-byte, il tuo programma terminerà con un errore.
Tenendo presente tutte queste informazioni, riscriviamo prima_parola
per
restituire una slice. Il type che indica la slice di stringa è scritto
come &str
:
fn prima_parola(s: &String) -> &str { let bytes = s.as_bytes(); for (i, &lettera) in bytes.iter().enumerate() { if lettera == b' ' { return &s[0..i]; } } &s[..] } fn main() {}
Otteniamo l’indice per la fine della parola nello stesso modo in cui lo abbiamo fatto nel Listato 4-7, cercando la prima occorrenza di uno spazio. Quando troviamo uno spazio, restituiamo una slice di stringa usando l’inizio della stringa e l’indice dello spazio come indici di inizio e fine.
Ora, quando chiamiamo prima_parola
, otteniamo un singolo valore che è legato
ai dati sottostanti. Il valore è composto da un reference al punto di partenza
della slice e dal numero di elementi nella slice.
Restituire una slice funzionerebbe anche per una funzione seconda_parola
:
fn seconda_parola(s: &String) -> &str {
Ora abbiamo una funzione più semplice in cui è molto più difficile succedano
cose strane perché il compilatore garantirà che i reference alla String
rimangano validi. Ricordi il bug nel programma nel Listato 4-8, quando abbiamo
ottenuto l’indice per la fine della prima parola ma poi abbiamo svuotato la
stringa, rendendo il nostro indice non valido? Quel codice era logicamente
errato ma non mostrava immediatamente errori. I problemi si sarebbero
manifestati più tardi se avessimo continuato a cercare di usare l’indice della
prima parola con una stringa svuotata. Le slice rendono questo bug impossibile
e ci fanno sapere che abbiamo un problema con il nostro codice molto prima.
Usare la versione slice di prima_parola
genererà un errore di compilazione:
fn prima_parola(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &lettera) in bytes.iter().enumerate() {
if lettera == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("hello world");
let parola = prima_parola(&s);
s.clear(); // errore!
println!("la prima parola è: {parola}");
}
Ecco l’errore del compilatore:
$ cargo run
Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:19:5
|
17 | let parola = prima_parola(&s);
| -- immutable borrow occurs here
18 |
19 | s.clear(); // errore!
| ^^^^^^^^^ mutable borrow occurs here
20 |
21 | println!("la prima parola è: {parola}");
| ------ immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Ricorda le regole di borrowing: se abbiamo un reference immutabile a
qualcosa, non possiamo anche prendere un reference mutabile. Poiché clear
deve troncare la String
, ha bisogno di ottenere un reference mutabile. Il
println!
dopo la chiamata a clear
utilizza il reference a parola
, quindi
il reference immutabile deve essere ancora attivo a quel punto. Rust vieta che
il reference mutabile in clear
e il reference immutabile a parola
esistano contemporaneamente, e la compilazione fallisce. Non solo Rust ha reso
la nostra funzione più facile da usare, ma ha anche eliminato un’intera classe
di errori durante la compilazione!
Letterali Stringa come Slice
Ricordi che abbiamo parlato dei letterali stringa memorizzati all’interno del binario? Ora che abbiamo scoperto le slice, possiamo comprendere correttamente i letterali stringa:
#![allow(unused)] fn main() { let s = "Hello, world!"; }
Il type di s
qui è &str
: è una slice che punta a quel punto specifico
nel binario. Questo è anche il motivo per cui i letterali stringa sono
immutabili; &str
è un reference immutabile.
Slice di Stringa come Parametri
Sapendo che puoi avere slice di letterali e di valori String
, arriviamo a un
ulteriore miglioramento per prima_parola
, e cioè la sua firma:
fn prima_parola(s: &String) -> &str {
Un Rustacean più esperto scriverebbe invece la firma come mostrata nel Listato
4-9, perché ci permette di usare la stessa funzione sia su valori &String
che
su valori &str
.
fn prima_parola(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &lettera) in bytes.iter().enumerate() {
if lettera == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mia_stringa = String::from("hello world");
// `prima_parola` funziona con slice di `String`, parziali o intere.
let parola = prima_parola(&mia_stringa[0..6]);
let parola = prima_parola(&mia_stringa[..]);
// `prima_parola` funziona anche con reference a `String`, che corrisponde
// a una slice intera di `String`.
let parola = prima_parola(&mia_stringa);
let mia_stringa_letterale = "hello world";
// `prima_parola` funziona con slice di letterali di stringa,
// parziali o intere.
let parola = prima_parola(&mia_stringa_letterale[0..6]);
let parola = prima_parola(&mia_stringa_letterale[..]);
// E siccome i letterali di stringa *sono* già delle slice,
// funziona pure così, senza usare la sintassi delle slice!
let parola = prima_parola(mia_stringa_letterale);
}
prima_parola
utilizzando una slice come type del parametro s
Se abbiamo una slice di stringa, possiamo passarlo direttamente. Se abbiamo
una String
, possiamo passare una slice della String
o un reference alla
String
. Questa flessibilità sfrutta la deref coercions (de-referenziazione
forzata), una funzionalità che tratteremo nella sezione “Usare la
De-Referenziazione Forzata in Funzioni e Metodi”
del Capitolo 15.
Definire una funzione che come parametro prende una slice di stringa invece di
un reference a una String
rende la nostra funzione più generica e utile
senza perdere alcuna funzionalità:
fn prima_parola(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &lettera) in bytes.iter().enumerate() { if lettera == b' ' { return &s[0..i]; } } &s[..] } fn main() { let mia_stringa = String::from("hello world"); // `prima_parola` funziona con slice di `String`, parziali o intere. let parola = prima_parola(&mia_stringa[0..6]); let parola = prima_parola(&mia_stringa[..]); // `prima_parola` funziona anche con reference a `String`, che corrisponde // a una slice intera di `String`. let parola = prima_parola(&mia_stringa); let mia_stringa_letterale = "hello world"; // `prima_parola` funziona con slice di letterali di stringa, // parziali o intere. let parola = prima_parola(&mia_stringa_letterale[0..6]); let parola = prima_parola(&mia_stringa_letterale[..]); // E siccome i letterali di stringa *sono* già delle slice, // funziona pure così, senza usare la sintassi delle slice! let parola = prima_parola(mia_stringa_letterale); }
Altre Slice
Le slice di stringa, come puoi immaginare, sono specifiche per le stringhe. Ma c’è anche un tipo di slice più generale. Considera questo array:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; }
Proprio come potremmo voler fare riferimento a parte di una stringa, potremmo voler fare riferimento a parte di un array. Lo faremmo in questo modo:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
Questa slice ha il type &[i32]
. Funziona allo stesso modo delle string
slice, memorizzando un reference al primo elemento e una lunghezza.
Utilizzerai questo tipo di slice per tutti i type di altre collezioni.
Discuteremo di queste collezioni in dettaglio quando parleremo dei vettori nel
Capitolo 8.
Riepilogo
I concetti di ownership, borrowing e slice garantiscono la sicurezza della memoria nei programmi Rust già in fase d compilazione. Il linguaggio Rust ti offre il controllo sul tuo utilizzo della memoria nello stesso modo in cui fanno altri linguaggi di programmazione di sistema, ma avere un proprietario (ownership) per ogni dato e che questo pulisca automaticamente i propri dati quando se ne va (non più in scope), significa non dover scrivere e debuggare codice extra per ottenere questo controllo.
L’ownership influisce su come molte altre parti di Rust funzionano, quindi
parleremo di questi concetti ulteriormente nel resto del libro. Passiamo al
Capitolo 5 e vediamo come raggruppare pezzi di dati insieme in una struct
.