Rust - Gli slice


Proseguendo nel nostro viaggio tra i tipi proposti da Rust è arrivato il momento di parlare degli slice, che avevamo sfiorato parlando delle sequenze letterali,  in maniera approfondita. Argomento fondamentale per questo linguaggio, si tratta in breve di un riferimento ad una sequenza contigua di elementi in memoria. In realtà si tratta di un fat-pointer che contiene un indirizzo e un metadata (la lunghezza che vedremo espressa tramite len()) Gli slice sono particolarmente utili perché offrono un modo per accedere a una porzione di un array, di una stringa o di un altro slice senza prendere possesso di esso, permettendo così operazioni sicure e efficienti su sottosequenze di, in generale, una collezione di dati. Inoltre uno dei fatti essenzialie degli slice è che rappresentano una astrazione a costo zero in Rust. Quando lavoriamo con una slice, non viene introdotto overhead aggiuntivo a runtime rispetto a quanto avviene lavorando direttamente con gli array. Ciò è garantito dal fatto che il compilatore genera codice ottimizzato come se stessimo lavorando direttamente sugli array stessi. In pratica gli slice in Rust sono progettati per essere sicuri e performanti offrendo una vista sull'oggetto sul quale li definiamo senza bisogno di copie. Sono molto utilizzate anche all'interno delle librerie del linguaggio. Il compilatore garantisce che  siano sempre validi e non causino errori di accesso alla memoria. Sono anche la chiave della interoperabilità tra array, Vec, collezioni e stringhe. Capire le slice vuol dire capire una buona parte della filosofia di Rust.
Semanticamente abbiamo due tipi di slice:

&[T]
&mut [T]
- slice mutabili

dove T ovviamente rappresenta il tipo.

Come troverete scritto nella letteratura dedicata, i vantaggi pratici degli slice si possono rissumere in:

Sicurezza di memoria: Rust assicura che l'accesso tramite slice sia sicuro durante la compilazione, prevenendo errori comuni come l'accesso fuori dai limiti dell'array.
Flessibilità: Gli slice possono essere utilizzati con diversi tipi di collezioni, aumentando la flessibilità del codice.
Prestazioni: Accedere ai dati tramite slice può essere molto efficiente, poiché non c'è la necessità di copiare dati o prendere possesso della collezione originale.detto questo vediamo un esempio:

Esempio 14.1

fn main() {
    let arr = [1, 2, 3, 4, 5];
    // Uno slice che contiene tutti gli elementi dell'array
    let intero = &arr[..];
    // Uno slice che contiene gli elementi da 1 a 3 dell'array
    let parziale = &arr[1..4];
    println!("Intero array: {:?}", intero);
    println!("Porzione di array: {:?}", parziale);
}

Il range 1..4 non è inclusivo quindi gli elementi vanno dall'indice 1 all'indice 3.
Come avevamo già visto, componente fondamentale degli slice è l'operatore & che in questo contesto rappresenta come detto un puntatore, un riferimento, alla sequenza indicata ciò che permette la realizzazione del meccanismo di borrowing. Nel caso delle sequenze letterali avevamo una string slice ma adesso scopriamo che l'applicazione è molto più ampia nel senso che è adattabile ad un numero molto più ampio di sequenze. Questo è un altro dei punti di forza di questo costrutto.

Concettualmente quindi, cos'è una slice? Abbiamo detto che non possiede gli elementi quindi possiamo dire che è costituito "fisicamente" da un puntatore e da una lunghezza. Ovvero abbiamo il puntamento al primo elemento e poi il numero di elementi da prendere in considerazione. Ci sono varie possibilità:

&a[..] range completo
&a[1..] dal elemento avente indice 1 alla fine
&a[..3] dall'indice 0 al 3 escluso, come visto nell'ambito dell'esempio 14.1
&a[1..3] punta l'intervallo costituito dagli elementi con indice 1 e 2
&a[1..=3] range inclusivo dell'ultimo quindi 1,2 e 3

Adesso giochiamo un po' con i nostri slice.

Iniziamo con una paio di metodi di base molto utili, specialmente il primo:

len - che indica il numero di elementi dello slice
is_empty - che riporta se lo slice è vuoto oppure no
contains(&elem) - ci dice se l'elemento puntato è presente o no

Esempio 14.2

fn main(){
  let ar01 = [1, 2, 3, 4, 5];
  let sl01 = &ar01[1..4];
  println!("Elementi in slice: {}", sl01.len());
  println!("Slice vuoto? {}", sl01.is_empty());
  println!("Esiste il 3? {}", sl01.contains(&3));
}

da cui ricaviamo

Elementi in slice: 3
Slice vuoto? false
Esiste il 3? true

In realtà le funzioni con cui potrete manipolare gli slice sono tante, ma proprio tante. Consiglio di andare sul sito ufficiale e dare un'occhiata tanto per farvi un'idea. Ad esempio abbiamo sort() e reverse() che possono essere utili per classiche operazioni frequenti sugli elementi. Un esempio veloce:

let sl01 = &mut[7,5,9,1,0];
sl01.sort();
sl01.reverse();
println!("{:?}", sl01);

Per accedere al singolo elemento abbiamo il consueto operatore [ ]:

let ar01 = [1, 2, 3, 4, 5];
let sl01 = &ar01[1..4];
println!("{}", sl01[2]);

otterremo il numero 4 perchè la slice è composta dai numero 2,3 e 4 quest'ultimo all'indice 2. Questo metodo, come intuirete, va in panico (come sempre, meglio quello di un risultato a casaccio) se forniamo l'indice errato, quindi se lo usate dovete essere sicuri di essere nel range. In realtà un sistema sicuro e molto idiomatico fa uso di get() o get_mut() che restituiscono un tipo Option (che per adesso non sapete cos'è). Il secondo metodo permette variazioni su una slice mutabile. Fornisco solo un esempio completo, quando avrete visto il tipo Option magari tornate a fare un giro qui:

Esempio 14.3

fn main() {
  let mut sl = [1, 2, 3, 4, 5];
  match sl.get(2) {
    Some(val) => println!("Trovato: {}", val), // Trovato: 3
    None      => println!("Fuori range"),
 }
  // Oppure con if let
  if let Some(val) = sl.get(2) {
      println!("{}", val); // 3
  }
  // Oppure con unwrap_or
  let val = sl.get(2).unwrap_or(&0); // 3
  println!("{}", val);
  let val = sl.get(99).unwrap_or(&0); // 0
  println!("{}", val);
  if let Some(val) = sl.get_mut(2) {
    *val = 99;
  }
  println!("{:?}", sl); // [1, 2, 99, 4, 5]
}

Per iterare attraverso gli elementi di uno slice possiamo usare iter() o anche no:

Esempio 14.4

fn main() {
    let sl01 = &[1, 2, 3, 4, 5]; // Definiamo uno slice
    for element in sl01.iter() {
        println!("Elemento: {}", element);
    }
    for x in sl01 {
        println!("Elemento: {}", x);
    }
}

Attravrso iter() possiamo però solo leggere e non modificare i dati. Anche se la slice fosse dichiarata mut questo ulteriore modificatore non si estende all'iteratore che di suo restituisce un riferimento immutabile. Quindi Rust ci fornisce iter_mut():

Esempio 14.5

fn main() {
    let sl01 = &mut[1, 2, 3, 4, 5]; // Definiamo uno slice
    for element in sl01.iter_mut() {
        *element *= 2; // Modifichiamo ogni elemento dello slice
    }
    for x in sl01 {
        println!("Elemento: {}", x);
    }
}

Nell'esempio 14.4 abbiamo creato una slice che permette di modificare gli elementi interni puntati. L'output sarà quindi:

Elemento: 2
Elemento: 4
Elemento: 6
Elemento: 8
Elemento: 10

Se provate a sostituire iter_mut con iter il compilatore non gradisce. Attenzione ad una particolarità però:

1)
let ar01 = [1, 2, 3, 4, 5]; // Definiamo un array
let sl01 = &mut ar01; // Definiamo uno slice

2)
let sl01 = &mut[1, 2, 3, 4, 5]; // Definiamo uno slice su array

Bene, il blocco 2) permette la modifica degli elementi, come nell'esempio 14.4, mentre se usate il blocco 1) non è possibile cambiare nulla. Affinchè anche per il blocco 1) possiamo effettuare la modifica degli elementi dovremmo definire l'array ar01 come mut. Però anche [1,2,3,4,5] del blocco 2) è un array. Perchè su di esso posso manipolare gli elementi? La risposta è forse poco intuitiva. Il fatto è che nel blocco 1) l'array è definito in modo esplicito e su di esso si possono, anzi si devono, applicare tutte le limitazioni consuete. Nel caso 2)  [1, 2, 3, 4, 5] non è una variabile: è un valore temporaneo (rvalue) creato inline. Rust in questo caso crea automaticamente un temporaneo mutabile e ne passa subito il riferimento mutabile. Come troverete spiegato più o meno in modo identico altrove questa situazione equivale a:

let mut _temp = [1, 2, 3, 4, 5]; // creato implicitamente da Rust
let sl01 = &mut _temp;

In sintesi, un valore letterale inline non ha ancora i vincoli di mutabilità di una variabile dichiarata esplicitamente.

Come sempre in Rust il bound-checking è presente anche qui, vi troverete un bel panic se cercate di usare range non permessi.

let ar01 = [1, 2, 3, 4, 5]; // Definiamo un array
let sl01 = &ar01[1..10]; // Definiamo uno slice - range errato

questo codice a runtime vi regalerà un bel:

thread 'main' (136072) panicked at r197.rs:3:21:
range end index 10 out of range for slice of length 5

Una possibilità interessante e poco nota è quella che permette di ottenere due slice mutabili a partire da una data sequenza.

Esempio 14.6

fn main() {
  let mut a = [1,2,3,4,5,6];
  let (left,  right) = a.split_at_mut(2);
  left[0] = 10;
  right[0] = 30;
  println!("{:?}", left);
  println!("{:?}", right);
}

dove il numero tra parentesi indica l'indice a partire dal quale inizierà la seconda slice. Quindi in questo caso otterremo:

[10, 2]
[30, 4, 5, 6]

COPIA DI SLICE

Quando si parla di slice parliamo di riferimenti che implementano Copy quindi è abbastanza banale:

fn main() {
  let originale = [1, 2, 3, 4, 5];

  let sl01 = &originale;

  println!("{:?}", sl01);

  let sl02 = sl01;

  println!("{:?}", sl02);

}

funziona ma in realtà abbiamo solo due puntatori alla medesima area di memoria, come si evince dal seguente programma che restituisce proprio gli indirizzi a qui il puntatore riferisce:

Esempio 14.7

fn main() {
  let originale = [1, 2, 3, 4, 5];
  let sl01 = &originale;
  println!("{:p}", sl01);
  let sl02 = sl01;
  println!("{:p}", sl02);
}

verrà fuori qualcosa tipo:

0x2ca90ff864
0x2ca90ff864

Sentirete parlare, a proposito delle slice, del meccanismo di deref coercion, ovvero detto in breve, la capacità di convertire automaticamente i Vector o gli Array in slice quando necessario. Lo vedremo.
Vedremo ancora gli slice in azione con i vettori e li troveremo anche al lavoro con le funzioni. Sono fondamenti del linguaggio.