Rust - gli array


Gli array, come intuibile dal loro stesso nome e come avviene in molti altri linguaggi, sono sequenze. In particolare, si tratta di sequenze di elementi dello stesso tipo indicizzati tramite interi sequenziali che iniziano da 0. Vengono immagazzinati in memoria contigua.
Semanticamente la definzione è la seguente:

[T; N]
laddove T rappresenta il tipo degli elementi ed N il loro numero, ovviamente un intero positivo che è una costante a compile time. Quindi anche la dimensione è nota a compile time e questo come vedremo è un elemento non secondario. Gli array in Rust sono immutabili dal punto di vista dimensionale e quindi non è possibile aggiungere o togliere elementi dagli stessi. Per N è ammesso il valore 0 che quindi crea un array vuoto mentre è da notare che
[T; N1] è diverso da [T; N2]
in quanto sia il tipo sia il numero di elementi sono caratteristiche peculiari di ogni array.

Sintatticamente abbiamo due modi per definire direttamente un array:

1) [valore-1, valore-2, ...., valore-N]
2) [espressione; numero-elementi]


Nel caso 2, come da documentazione ufficiale, espressione deve implementare il trait copy oppure essere una costante. Quest'ultimo fatto lo potete verificare in maniera facile, ad esempio:

let ar01 = ["aa".to_string(); 5];
non compila e l'errore è, come sempre, illuminante:

error[E0277]: the trait bound `String: Copy` is not satisfied
--> r172.rs:2:17
|
2 | let ar01 = ["aa".to_string(); 5];
| ^^^^^^^^^^^^^^^^ the trait `Copy` is not implemented for `String`
|
= note: the `Copy` trait is required because this value will be copied for each element of the array
= help: consider using `core::array::from_fn` to initialize the array
= help: see https://doc.rust-lang.org/stable/std/array/fn.from_fn.html for more information


La riga evidenziata vi spiega anche il perchè dell'errore. Sappiamo, dal capitolo relativo, che le stringhe non implementano il trait Copy. Quindi non può avvenire la proliferazione della stringa in questa forma.

In memoria gli array come detto all'inizio, sono conservati in blocchi di memoria consecutivi e gli elementi sono indicizzati da 0 in avanti attraverso numeri interi. Ad esempio l'array ['a', 'b', 'c', 'd'] può essere rappresentato come segue:

array a b c d
indice 0 1 2 3

Riassumendo:
--- gli array definiti in questo paragrafo sono elementi statici, ovvero una volta definiti non possono essere aumentati ne diminuiti per quanto riguarda il numero di elementi che lo compongono
--- i dati sono contenuti in blocchi consecutivi di memoria
--- ogni blocco rappresenta un elemento dell'array ed è caratterizzato da un indice

aggiungiamo che:
--- gli array sono contenuti nello stack il che rende molto veloce la loro manipolazione. Ovviamente però se un array contiene elementi che devono "abitare" nello heap, ad esempio delle stringhe, verranno coinvolti sia lo stack che lo heap. Nell'esempio, nello stack avremo puntatore + lunghezza + capacità, nello heap avremo gli elementi della stringa.
--- si tratta di un tipo "sicuro" sia per quanto concerne gli accessi oltre i limiti sia per le elaborazioni, stante l'omogenietà dei tipi e i controlli rigorosi che in questo modo il compilatore può mettere in atto.

Per inizializzare un array, come detto, abbiamo sostanzialmente due modi:
--- specificare i singoli elementi che lo compongono:
let ar1 = [1,2,3,4];
--- specificare un singolo elemento ripetuto n volte:
let ar1 = ['a'; 5];

Il secondo sistema crea un array contenente 5 volte il carattere 'a'. Possiamo anche forzare la natura degli elementi senza lasciar fare all'inferenza:

let ar1 : [u8, 3] = [1,2,3];

rispettando come sempre i limiti imposti dal tipo scelto. E' da notare tuttavia, sempre relativamente al secondo metodo una particolarità che possiamo illustrare tramite il seguente codice:

let x = 8;
let ar1 = [0; x];

Questo codice non compila è il motivo è semplice: la dimensione di un array deve essere nota a compile-time mentre il valore viene assegnato ad x a run-time quindi, diciamo, troppo tardi. L'errore è chiaro ed "educativo" in quanto ci indica anche una soluzione:

error[E0435]: attempt to use a non-constant value in a constant
--> r1001.rs:4:19
|
3 | let x = 8;
| ----- help: consider using `const` instead of `let`: `const x`
4 | let ar1 = [0; x];
| ^ non-constant value


Quindi la soluzione in questi casi è usare una costante, come indicato nella riga evidenziata. Il compilatore come sempre cerca di darci una mano. Tutto semplice? Si ma c'è ancora un problema:

const X: i32 = 8;
let ar1 = [0; X];


questo codice dà origine ad errore e precisamente:

error[E0308]: mismatched types
--> r1002.rs:4:19
|
4 | let ar1 = [0; X];
| ^ expected `usize`, found `i32`


ovvero la costante deve essere di tipo usize non i32. Questo ci porta a concludere che il tipo al quale appartengono gli indici di un array è usize quindi legato all'architettura della macchina (ricordiamo che per scoprire il limite esiste sempre usize::MAX). La soluzione pertanto consiste nel sostituire i32 con usize nella prima riga.
Dobbiamo tuttavia fornire una soluzione al caso visto all'inizio, ovvero come inizializzare un array in modo veloce con elementi che non supportano il trait Copy, come le stringhe. Ebbe tornando a quel caso possiamo scrivere una soluzione del genere:

let ar01: [String; 5] = std::array::from_fn(|_| "aa".to_string());

è presente il concetto di chiusura che ancora non abbiamo visto. La funzione std::array::from_fn in Rust è un'utilità che permette di creare un array di dimensioni fisse, inizializzando ciascun elemento dell'array tramite appunto una chiusura. Ne parleremo delle chiusure che sono un altro argomento piuttosto importante, non solo in Rust.
Sempre con riguardo alla inizializzazione, array fino a 32 elementi supportano il trait Default ed è possibile assegnare un valore di default  se il tipo scelto per i singoli elementi lo supporta:

let x1 : [i32; 32] = Default::default();
println!("{}", x1[3]);

ne ricaveremo un bello '0' a video. Se proviamo a definire l'array precedente con dimensione 33 o superiore il programma non compilerà.

Ancora, è possibile, fino a 12 elementi passare da un tuple ad un array tramite From:

Esempio 13.1

fn main() {
  let tup = (1, 2, 3, 4);
  let arr: [i32; 4] = From::from(tup);
  println!("{:?}", arr);
}
E' il momento di vedere come manipolare i nostri array:

Accedere ad un elemento di un array è molto semplice tramite l'uso dell'operatore [ ]
let ar1 = [1,2,2000];
println!("{}", ar1[1]);
ovviamente cercare di accedere ad indici fuori range provoca un errore in compilazione, come il seguente dove ho imposto indice 5:

error: this operation will panic at runtime
--> r983.rs:4:18
|
4 | println!("{}", ar1[5]);
| ^^^^^^ index out of bounds: the length is 3 but the index is 5

se l'indice è proposto a runtime avrete un crash (panic) del programma. Questo come sempre a tutela della solidità del programma, non ci sono overflow o comportamenti anomali, i famosi UB, e quindi pericolosi.

Il metodo len() è quello usato per ottenere la lunghezza, ovvero il numero di elementi che compongono l'array.

let ar1 = [1,2,2000];
println!("{}", ar1.len());

ricaveremo, dalla funzione di stampa, il numero 3.

Scorrere gli elementi di un array è molto semplice. Questo task può essere ottenuto sia utilizzando in maniera del tutto intuitiva il ciclo for:

let x01 = [1,2,3,4,5];
for i in 0..x01.len() {
    print!("{},", x01[i])
}

sia ricorrendo allo specifico metodo iter():

let x01 = [1,2,3,4,5];
for i in x01.iter() {
    print!("{},", i)
}

Se volete potete anche procedere tramite riferimento

let x1 : [i32; 32] = [3; 32];
for x in &x1 {
  println!("{}", x);
}

Non ci sono sostanziali differenza pratiche tra questi codici.

Ricavare la copia di un array dipende anche dal tipo degli elementi interni. E, come avrete intuito, non è cosa che non nasconda qualche insidia.
Il caso più semplice è come sempre quello che prevede all'interno dell'array elementi che implementano il trait Copy. In tale caso l'operatore = va benissimo:

Esempio 13.2

fn main() {
  let ar1 = [1,2,3,4];
  let ar2 = ar1;
  println!("{:?}", ar1);
  println!("{:?}", ar2);
}

da cui otteniamo

[1, 2, 3, 4]
[1, 2, 3, 4]

stesso risultato se invece dell'operatore di assegnazione avessimo usato clone() scrivendo:

let ar2 = ar1.clone();

d'altronde in Rust come sappiamo Clone è un supertrait di Copy.
Laddove invece all'interno dell'array vi siano elementi che non implementano Copy allora le cosa cambiano. Se infatti ar1 nelle esempio 13.2 fosse:

let ar1 = ["ciao".to_owned(), "mondo".to_owned()];

dopo averne effettuato la copia con

let ar2 = ar1;

l'istruzione di stampa di ar1 andrebbe in errore di compilazione:

 borrow of moved value: `ar1`
--> r178.rs:4:18
2 | let ar1 = ["ciao".to_owned(), "mondo".to_owned()];
| --- move occurs because `ar1` has type `[String; 2]`, which does not implement the `Copy` trait
3 | let ar2 = ar1;
| --- value moved here
4 | println!("{:?}", ar1);
| ^^^ value borrowed here after move
help: consider cloning the value if the performance cost is acceptable
3 | let ar2 = ar1.clone();
| ++++++++

con tanto di soluzione proposta nella riga evidenziata.

let ar1 = ["ciao".to_owned(), "mondo".to_owned()];
let ar2 = ar1.clone();
println!("{:?}", ar1);
println!("{:?}", ar2);

così funziona. Infatti clone() è il caso più generale come detto e le stringhe implementano il trait Clone.
Il caso peggiore però avviene quando l'array è costituito da elementi che non implementano nemmeno clone(), ad esempio quando definiamo un array di tipo custom. Lì le cose si fanno più complicate perchè è necessario ricorrere ad implementazioni manuali. Un caso è il seguente
(nb: vedrete del codice che non probabilmente non vi è chiaro: certi concetti verrano spiegati più avanti).

Esempio 13.3

fn main() {
#[derive(Debug, Clone)]
  struct Punto {
    x: f64,
    y: f64,
  }

  let p01 = Punto { x: 1.0, y: 2.0 };
  let p02 = Punto { x: 3.0, y: 4.0 };
  let ar1 = [p01, p02];
  let ar2 = ar1.clone();
  println!("{:?}", ar1);
  println!("{:?}", ar2);
}

Ignorate i warning in compilazione, concentratevi solo sul fatto che abbiamo messo il trait Clone a disposizione della struct e quindi anche dell'array.
Casi ancora più complicati sono quelli in cui Clone deve essere implementato manualmente oppure, peggio ancora, quei rari casi in cui Clone non è implementabile per gli elementi dell'array ma qui si va un territorio troppo complesso per il momento.

Altri metodi comodi per lavorare utilmente sugli array sono sort() che, laddove sia permesso dal tipo interno, ordina in senso crescente, reverse() che rovescia gli elementi in senso inverso e contains(&elemento) (si noti che richiede un puntatore ad un valore... questo perchè la sua firma è unica per tutti i i tipi e in questo modo evitiamo dei move inutili) che ci dice se l'array contiene o no un certo elemento. Si tratta di metodi utili nella pratica quotidiana e li vediamo tutti insieme nel seguente codice:

Esempio 13.4

fn main() {
  let mut ar1 = [3, 5, 1, 17, 9, 0];
  ar1.sort();
  println!("{:?}", ar1);
  ar1.reverse();
  println!("{:?}", ar1);
  println!("esiste il numero 5? {}", ar1.contains(&5));
}

Da cui abbiamo:

[0, 1, 3, 5, 9, 17]
[17, 9, 5, 3, 1, 0]
esiste il numero 5? true

Come abbiamo detto, il numero di elementi di array [T;N] è immutabile, ribadiamo quindi che non possiamo cancellare o aggiungere elementi, ovvero, in pratica, la lunghezza di un array è una costante a compile time.  Tuttavia gli elementi all'interno, ferma restando la necessaria compatibilità di tipo, possono essere modificati, sostituiti, con altri dello stesso tipo. Il metodo più rapido e semplice fa ancora uso dell'operatore [ ] oltre che ovviamente dell'immancabile mut per conferire mutabilità all'array:

let mut ar1 = [10, 3, 7, 2, 18];
ar1[1] = 88;


e l'elemento all'indice 1 da 3 diventa 88. Questo è il metodo più semplice ma genera un panic se utilizzate un indice errato. Se volete una strada più sicura potete far uso di get_mut(indice), che genera un Option in grado di gestire eventuali indici errati. Vediamo l'esempio:

Esempio 13.5

fn main() {
    let mut arr = [1, 2, 3, 4, 5];
    match arr.get_mut(2) {
        Some(val) => *val = 42, // Modifica l'elemento all'indice 2
        None => println!("Indice non valido!"),
    }
    println!("{:?}", arr); // [1, 2, 42, 4, 5]
}

Provate a vedere cosa succede se alla riga 3 invece dell'indice 2 inserite un indice fuori range, quindi maggiore di 4 in questo caso. (Lo so non sappiamo ancora cos'è il tipo Option... ci arriveremo)

Altro argomento ancora sono gli array multidimensionali. Sostanzialmente si tratta di array di array come nel seguente esempio:

Esempio 13.6

fn main(){
    let v2 = [[1,2,3];2];
    println!("{}", v2[1][1]);
    println!("{}", v2.len());
}

questo esempio propone due array costituiti da 3 elementi. L'output è il seguente:

2
2

in cui il secondo "2" indica le dimensioni dell'array che è costituito proprio da 2 array.

Ovviamente potete anche complicare la cosa e aggiungere ulteriori dimensioni al vostro array:

let v2 = [[[1,2,3];2];3];
println!("{}", v2[1][1][0]);
println!("{}", v2.len());

Questo è un array di 3 elementi, ciascuno dei quali è un array di due elementi ciascuno dei quali è composto da 3 elementi. Un po' difficile da immaginare "fisicamente" ma non particolarmente complesso da manipolare.

Array e slice
Come si confrontano array e slice? Vediamo il seguente codice per passare da array a slice:

let a = [1, 2, 3]; // array [i32; 3], vive sullo stack
let s: &[i32] = &a; // slice: riferimento a una porzione di 'a'

Qui abbiamo definito un array e successivamente una slice che punta ad una porzione dell'array. Come sempre la slice non possiede l'array o la sua porzione ma diciamo che la osserva, avendola in prestito grazie al puntatore. Ci si può sbizzarrire nel costruire slice aventi diversa ampiezza:

let a = [1, 2, 3, 4, 5];
let s1: &[i32] = &a; // [1,2,3,4,5] — tutto
let s2: &[i32] = &a[1..4]; // [2,3,4] — porzione
let s3: &[i32] = &a[..2]; // [1,2] — inizio
let s4: &[i32] = &a[3..]; // [4,5] — fine

E' molto utile quando dobbiamo passare porzioni di array ad una funzione. Diversamente si dovrebbe costruire una funzione diversa per ogni dimensione del parametro da passare.

Il passaggio inverso è più problematico e lo capite da soli: un array porta co sè a compile time l'informazione della dimensione, una slice sappiamo che non la porta. Quindi bisogna fare qualche controllo in più. In pratica bisogna verificare che la lunghezza runtime della slice (o della porzione che vogliamo considerare) coincida con quella dell'array che abbiamo definito. Tipicamente si usa la funzione try_into():

let s: &[i32] = &[1, 2, 3, 4, 5];
let a: [i32; 3] = s.try_into().unwrap(); //  panico: slice ha 5 elementi, non 3
let a: [i32; 5] = s.try_into().unwrap(); //  dimensioni coincidono

Questo è un modo veloce e funzionale, teniamo presente che try_into() restituisce un result (si, lo devo dire anche qui, lo vedremo più avanti) che permette di gestire meglio l'errore, non bisogna usare unwrap, ma come detto lo vedremo.
altra possibilità è usare try_from(slice)
let arr = <[u8; 3]>::try_from(slice).unwrap();

 

Array e Vec!
E' argomento che vediamo nel prossimo paragrafo.

Array ed ennuple
Di norma non è possibile effettuare una conversione diretta perchè le ennuple sono eterogenee a livello di tipologia dei propri elementi. E' possibile quindi quando sia uniofrmità ma non esiste modo idiomatico, bisogna fare a mano:

slice -> array
let t: (i32, i32, i32) = (1, 2, 3);
let a: [i32; 3] = [t.0, t.1, t.2];


array -> slice
let a: [i32; 3] = [1, 2, 3];
let t: (i32, i32, i32) = (a[0], a[1], a[2]);

Prima di concludere vediamo i trait implementati dagli array se i tipi che ne costituiscono gli elementi lo permettono:

Copy
Clone
Debug
IntoIterator (implementato per [T; N], &[T; N] e &mut [T; N])
PartialEq, PartialOrd, Eq, Ord
Hash
AsRef, AsMut
Borrow, BorrowMut


Su questo elenco, come sugli altri analoghi, sarà utile tornare quando avremo trattato i vari Trait.

Destrutturazione di un array
Possiamo usare un array per inizializzare più variabili, con varie possibilità e stando attenti a qualche regola di base.

let ar1 = [1,2,3];
let [a, b, c] = ar1;

Questo è il caso più semplice. Abbiamo 3 variabili inizializzate con i valori dell'array che resta utilizzabile a sua volta. Se però il trait copy non è implementato dagli elementi interni, come nel caso:

let arr = [String::from("a"), String::from("b")];
let [s1, s2] = arr; // OK: i valori vengono *mossi*

allora arr non è più utilizzabile perchè le stringhe non implementano Copy e quindi bisogna fare attenzione.

Se vi servissero solo due valori, ad esempio il primo e il terzo potrete usare la variabile di scarto:

let [a, _, c] = ar1;
 

Vi potreste chiedere dove "vive" un array. Ebbene la sua "casa" è lo stack, come abbiamo visto possono anche esserci elementi al suo interno che vanno sullo heap ma nello stack avremo sempre i descrittori ( ad esempio, puntatore, dimensioni e capacità) e saranno quelli la parte vera e propria dell'array. Questo a meno di non ricorrere ad operazioni di boxing che vedremo nell'apposito paragrafo e che in effetti permetteranno di posizionare un array sullo heap.

Tutto qui? No. Gli array ad esempio vanno molto d'accordo con le funzioni e questo sarà un argomento che tratteremo in maniera particolareggiata nell'apposito paragrafo.