Rust - Gli iteratori
In Rust un iteratore è un valore che implementa il trait Iterator. Questo al suo interno è definito come segue:
pub trait Iterator {
type Item;
fn next(&mut self) ->
Option<Self::Item>;
}
- type Item - un item deve avere un tipo associato che è anche quello che verrà restituito. Non è ammessa genericità in questo ambito perchè deve esservi un solo modo di iterare.
- segue poi la funzione next che ha il compito di avanzare elemento per elemento restituendo quello che eventualmente trova oppure none se non viene trovato nulla. Questa è realmente il cuore del cammino all'interno della sequenza.
questi sono gli elementi fondamentali di un iteratore che deve quindi avanzare in maniera univoca dal punto di vista dell'elemento iterante e restituire comunque un qualcosa che ci faccia capire quando siamo arrivati alla fine. In breve si potrebbe dire che un iteratore è un oggetto che sa produrre, uno alla volta, valori di un certo tipo predefinito. Due caratteristiche sono proprie degli iteratori:
- astrazione a costo zero - ovvero viene generato codice altamente efficiente, evitando overhead di runtime o allocazioni di memoria extra. Inoltre gli iteratori implementano il trait drop evitando effetti collaterali che potrebbero creare problemi prestazionali.
- approccio di tipo lazy (pigro). Quest'ultimo aspetto implica che gli elementi vengono effettivamente diciamo "consumati" solo quando ciò è effettivamente richiesto dal codice, ovvero solo quando è effettivamente necessario. Il tutto va a vantaggio delle prestazioni e della gestione delle risorse, in generale.
Vediamo un esempio in cui le prime due istruzioni creano un semplice iteratore:
Esempio 23.1
fn main() {
let v1 = vec![1,2,3,4];
let iter = v1.iter();
for i in iter {
print!("{} ", i)
}
}
Definiamo quindi un vec e su di esso costruiamo il nostro iteratore. Ma che
cos'èv1.iter()
in pratica?
la funzione iter() non è definita direttamente sui Vec ma si può dire che arriva tramite un meccanismo di derefereziazione verso slice. E' un meccanismo trasparente ma che in pratica lavora come segue:
Vec<i32) -> Deref verso [i32] -> metodo della slice
iter()
Detto questo che è come detto del tutto trasparente in quanto automaticamente implementato. Un cosa utile da sapere è che iter() lavora tramite puntatore, ovvero non consuma l'elemento su cui itera.
Il quadro più ampio degli iteratori però è il seguente studiato per consentire una piena operatività:
let v = vec![1, 2, 3];
let mut iter1 = v.iter(); // &i32
let mut
iter2 = v.iter_mut(); // &mut i32
let mut iter3 = v.into_iter(); // i32
(consuma il vettore)
Il significato è intuitivo, il primo, come abbiamo detto va di puntatore e siamo in sola lettura, il secondo permette di operare in scrittura e il terzo consuma il vettore sorgente. Abbiamo visto all'opera il primo, vediamo nel seguente codice l'uso, banale, degli altri 2:
Esempio 23.2
fn main() {
let mut v = vec![1, 2, 3, 4];
for x in v.iter_mut() {
*x *= 10;
}
println!("{:?}", v); // [10, 20, 30, 40]
for x in v.into_iter() {
println!("valore posseduto: {}", x);
}
// A questo punto v NON esiste più: è stato consumato da into_iter()
// println!("{:?}", v); // ERRORE di compilazione: "value borrowed after move"
}
L'output ci fa capire il funzionamento:
| [10, 20, 30, 40] valore posseduto: 10 valore posseduto: 20 valore posseduto: 30 valore posseduto: 40 |
Quindi iter_mut() va a modificare i valori interni al vettore mentre into_iter() ne prende il possesso e v a quel punto non c'è più, come si può evincere eliminando le barre di commento all'ultima riga di stampa, ne ricaveremo un errore in compilazione proprio come indicato.
Una piccola nota, potete cambiare il passo dell'iterazione usando step_by(n) laddove n indica il passo da usare. Provate a modificare il codice dell'esempio 23.1 come segue:
let v1 = vec![1,2,3,4,5,6,7,8,9,10];
let iter =
v1.iter().step_by(2);
for i in iter {
print!("{} ", i)
}
La cosa forse più interessante però sono i metodi usati con gli iteratori. Ce ne sono davvero tanti, propongo qui quelli a mio avviso più utili, trovate tutto con specifiche precise sul sito ufficiale che consiglio vivamente di consultare:
| Metodo | Cosa fa | Consuma iteratore? |
|---|---|---|
| map | Trasforma ogni elemento | No |
| filter | Tiene solo chi soddisfa la condizione | No |
| filter_map | map + filter in un colpo | No |
| flat_map | map + flatten | No |
| take / skip | Limita o salta elementi | No |
| enumerate | Aggiunge l’indice | No |
| zip | Combina due iterator | No |
| collect | Materializza in Vec, HashMap… | Sì |
| sum / product | Calcola somma/prodotto | Sì |
| fold / reduce | Accumula un valore | Sì |
| for_each | Esegue una side-effect | Sì |
| any / all | Controlla condizioni | Sì |
| find / position | Cerca un elemento | Sì |
Bisogna però tenere presente che il comportamento di questi metodi che si possono dividere in due categorie, ovvero:
- Adattatori (adapters) — map, filter, zip, take, skip, enumerate... prendono un iteratore e restituiscono un nuovo iteratore che avvolge quello originale. Sono pigri (lazy): non fanno nulla finché non vengono consumati.
- Consumatori (consumers) — sum, count, collect, fold, for_each... consumano l'iteratore fino in fondo e restituiscono un valore finale, non un altro iteratore.
Questo è da ricordare bene perchè ovviamente si tratta di comportamenti profondamente diversi. Il fatto ad esempio che gli adattatori restituiscano un nuovo iteratore fa capire che possiamo creare delle catene, delle sequenze di istruzioni, mentre un consumatore produce un risulato finale in pratica. Ad esempio:
Esempio 23.3
fn main() {
let v = vec![1, 2, 3, 4];
let somma: i32 = v.iter().sum();
println!("Somma = {}", somma);
}
In questo caso abbiamo usato sum() che ci restituisce un risultato diretto che andiamo a memorizzare in una variabile senza altre possibilità. Invece se guardiamo agli adattatori possiamo fare anche cose come queste:
Esempio 23.4
fn main() {
let v = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let it = v.iter();
let somma: i32 = it.map(|x| x * 2)
.filter(|x| x % 3 == 0)
.sum();
println!("Somma = {}", somma);
println!("{}", v.len());
}
In pratica usiamo in cascata tre metodi, map(),
filter() e alla fine
sum() per compiere una selezione completa
in un unico passaggio, questo grazie proprio al fatto che i primi due metodi
generano degli ulteriori iteratori.
proseguiamo con un altro esempio,
sempre allo scopo di illustrare l'uso di questi interessanti metodi:
Esempio 23.5
fn main() {
let nomi = vec!["Anna", "Marco", "Luca", "Paolo"];
let eta = vec![20, 25, 30, 35];
let iter = nomi.iter()
.zip(eta.iter())
.take(3);
for (nome, eta) in iter {
println!("{nome}: {eta}");
}
}
qui vediamo all'opera zip() e take(). Il primo come detto combina due iteratori creando in pratica delle coppie, mentre take() limita la selezione a 3 elementi. Sulla base di quanto detto l'output è facilmente intuibile:
| Anna: 20 Marco: 25 Luca: 30 |
resta fuori quindi l'ultima coppia (Paolo: 35).
Un caso un po' particolare è quello della funzione nth() piuttosto usata ma che va capita. Come next(), che ne costituisce la base "nascosta", nth(n) è un metodo di default del trait Iterator (ovvero non va implementato a mano, arriva gratis) che restituisce l'elemento alla posizione n (partendo da indice zero), consumando e scartando tutti gli elementi prima di esso. Anche nth() restituisce un tipo Option come vediamo nell'esempio seguente:
Esempio 23.6
fn main() {
let v = vec![10, 20, 30, 40, 50];
// Accediamo all'elemento alla posizione 3, che esiste
let third = v.iter().nth(3);
match third {
Some(value) => println!("L'elemento alla posizione 3 è: {}", value),
None => println!("Non esiste un elemento alla posizione 3"),
}
// Tentativo di accesso all'elemento alla posizione 10, che non esiste
let beyond = v.iter().nth(10);
match beyond {
Some(value) => println!("L'elemento alla posizione 10 è: {}", value),
None => println!("Nessun elemento alla posizione 10"),
}
}
con l'ovvio output:
| L'elemento alla posizione 3 è: 40 Nessun elemento alla posizione 10 |
Se volete la riprova dello scarto offerto da nth() potete provare il codice seguente, i commenti dicono tutto:
fn main() {
let v = vec![10, 20, 30, 40, 50];
let mut iter =
v.iter(); // UN SOLO iteratore, creato una volta
let a = iter.nth(2);
// scarta v[0], v[1]; restituisce v[2]
println!("{:?}", a); // Some(30)
let b = iter.nth(0); // NON riparte da v[0]!
riparte da dove si era
fermato
println!("{:?}", b); // Some(40), non Some(10)
}
Un cenno lo merita collect() che una funzione interessante in quanto è un po' il trait d'union tra le iterazioni lazy e il "mondo" delle collezioni (vettori, array, più avanti nel vedremo altre). La sua implementazione interna è la seguente:
fn collect<B: FromIterator<Self::Item>>(self) -> B
where
Self:
Sized,
{
FromIterator::from_iter(self)
}
ovvero si basa su un altro trait, FromIterator e restituisce un elemento di tipo B, la clausola where impone che la dimensione del tipo deve essere nota a tempo di compilazione. L'implementazione della funzione chiama in causa from_iter che si occupa di creare la nuova collezione. Al di là delle implementazioni interne, che per il momento non ci interessano più di tanto essendo completamenti traspoarenti nell'uso, vediamolo all'opera:
Esempio 23.7
fn main() {
let v1 = vec![1, 2, 3, 4];
let v2: Vec = v1.iter()
.map(|x| x * 2)
.collect();
println!("{v2:?}");}
che crea un vettore v2, partendo dall'iteratore che ricaviamo da map():
| [2, 4, 6, 8] |
In pratica scrivere:
let v: Vec<i32> = iter.collect();equivale a qualcosa come:
FromIterator<i32> for Vec<i32>
Degno di nota è peek() che permette di selezionare l'elemento senza consumarlo. E' in pratica la versione "curiosa" di next() che invece consuma l'elemento. Una cosa importante da sapere è che questo metodo non disponibile direttamente su tutti gli iteratori ma deve essere richiamata. In realtà è un funzione di una struct creata dal metodo peekable() interno al trait Iterator. Anche qui ci interessa vederlo in azione anche per capire come richiamare questo metodo in modo corretto:
Esempio 23.8
fn main() {
let v = vec![1, 2, 3, 4, 5];
let mut iter = v.iter().peekable();
while let Some(&valore) = iter.peek() {
if valore % 2 == 0 {
println!("Pari trovato: {}", valore);
iter.next(); // ora consumo
} else {
println!("Dispari");
iter.next();
}
}
}
È molto utilizzato, per esempio, nei parser, dove spesso vuoi guardare il prossimo token/carattere per decidere quale operazione fare, senza consumarlo immediatamente. Altro esempio, ancora più semplice:
Esempio 23.9
fn main() {
let mut iter = vec![1, 2, 3].into_iter().peekable();
if let Some(&next) = iter.peek() {
println!("Prossimo elemento: {}", next);
}
println!("Primo elemento: {}", iter.next().unwrap()); // Output: 1
}
Non è finita qui.
La letteratura spesso dedica spazio a for_each() che, di fatto non è molto diverso da for, semplicemente è un metodo consumatore: chiama next() ripetutamente sull'iteratore ed esegue una chiusura su ogni elemento, senza costruire nulla in cambio (a differenza di collect(), che accumula in una struttura). Vediamo un esempio in cui introduciamo anche un ulteriore step che ci insegna a creare un iteratore, per così dire, dal nulla. Abbiamo visto infatti finora che ogni iteratore era generato partendo da una sequenza, un Vec nei nostri esempi. Tuttavia questa non è l'unica strada:
Esempio 23.10
fn main() {
std::iter::repeat(42)
.take(5)
.for_each(|x| println!("valore: {}", x));
}
da cui ricaviamo:
| valore: 42 valore: 42 valore: 42 valore: 42 valore: 42 |
Interessante è ovviamente quella istruzione all'inizio:
std::iter::repeat(42)
la quale crea un iteratore
che all'infinito contiene solo il numero 42. La cosa interessante è quel
"infinito" che potrebbe far pensare ad un possibile rischio di overflow.
Naturalmente non è così, repeat() non crea
nulla finchè questo non viene richiesto, nell'esempio 23.10
take() lo chiede 5 volte e altrettante
viene messo a disposizione. Un comportamento che si può definire lazy anche
questo. La protezione quindi non viene da repeat() ma dal
consumatore e pertanto scrivere una cosa tipo:for x in repeat(42) {}
allora si che ne risulta un loop infinito.
Non c'è solo repeat() ovviamente, che vi può aiutare a creare sequenze particolari, la libreria di Rust è molto ampia
| Funzione | Genera |
|---|---|
empty() |
nessun elemento |
once(x) |
un solo elemento |
repeat(x) |
lo stesso elemento, infinite volte |
from_fn(f) |
elementi calcolati da una chiusura arbitraria, a ogni chiamata |
successors(inizio, f) |
sequenza dove ogni elemento deriva dal precedente |
Prima di chiudere un altro esempio istruttivo:
Esempio 23.11
fn main() {
let vec = vec![1, 2, 3, 4, 5, 6, 7, 8];
let result: Vec<_> = vec.into_iter()
.map(|x| x * 2)
.filter(|x| x > &5)
.take(4)
.skip(1)
.collect();
println!("{:?}", result);
}
vediamo passo dopo passo cosa fa così da fare un po' un riassunto generale.
let vec = vec![1, 2, 3, 4, 5, 6, 7, 8]; - definiamo un vettore di 8
elementi. Tutto chiaro, giusto?
let result: Vec<_> = vec.into_iter();
- consuma vec, produce un iteratore con Item = i32 (i valori sono posseduti, non
riferimenti).
Inizia poi una serie di funzioni che, come detto, sono lazy, ovvero nessuna in realtà fa nulla fino a quando collect() non pretende la creazione del vettore result che conterrà il risultato di tutta la sequenza delle elaborazioni.
.map(|x| x * 2) - raddoppia ogni elemento quindi da
1,2,3,4,5,6,7,8 passiamo 2,4,6,8,10,12,14,16
.filter(|x| x > &5)
- tiene solo gli elementi maggiori di 5. -> 6,8,10,12,14,16
da notare
che lavoriamo per riferimento, come richiesto da filter.
.take(4) - prende da
quanto filtrato solo i primi 4 elementi -> 6,8,10,12
.skip(1) -
skip(n) è un altro metodo che salta i primi n elementi quindi
-> 8,10,12
.collect() - alla fine
quanto resta viene riversato nel vettore result che è ciò che viene stampato a video.
Per ora con gli iteratori, che vedremo spesso in azione e già li abbiamo incontrati, è tutto, ma prima di chiudere vale la pena segnalare, per quanto sia argomento avanzato ma che dimostra ancora di più la grande duttilità degli iteratori, che usando il crate rayon, possiamo rendere gli iteratori paralleli per sfruttare più core della CPU. Questa libreria consente di iterare sui dati in parallelo in modo sicuro, mantenendo lo stesso stile funzionale degli iteratori tradizionali. Come detto è argomento avanzato e lo affronteremo più avanti.
Consiglio di dare uno sguardo alla sezione ufficiale dedicata agli iteratori in quanto c'è veramente molto materiale che può risultare utile.