Elaborare una Serie di Elementi con Iteratori
Il modello dell’iteratore consente di eseguire un’attività su una sequenza di elementi a turno. Un iteratore è responsabile della logica di iterazione su ciascun elemento e di determinare quando la sequenza è terminata. Quando si utilizzano gli iteratori, non è necessario re-implementare questa logica da soli.
In Rust, gli iteratori sono lazy1 (pigri), il che
significa che non hanno effetto finché non si chiamano metodi che consumano
l’iteratore per utilizzarlo. Ad esempio, il codice nel Listato 13-10 crea un
iteratore sugli elementi nel vettore v1 chiamando il metodo iter definito su
Vec<T>. Questo codice di per sé non fa nulla di utile.
fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); }
L’iteratore è memorizzato nella variabile v1_iter. Una volta creato un
iteratore, possiamo utilizzarlo in diversi modi. Nel Listato 3-5, abbiamo
iterato su un array utilizzando un ciclo for per eseguire del codice su
ciascuno dei suoi elementi. In pratica, questo creava e poi consumava
implicitamente un iteratore, ma finora abbiamo omesso come funziona in pratica.
Nell’esempio del Listato 13-11, separiamo la creazione dell’iteratore dal suo
utilizzo nel ciclo for. Quando il ciclo for viene chiamato utilizzando
l’iteratore in v1_iter, ogni elemento dell’iteratore viene utilizzato in
un’iterazione del ciclo, che stampa ciascun valore.
fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); for val in v1_iter { println!("Ottenuto: {val}"); } }
forNei linguaggi che non hanno iteratori forniti dalle loro librerie standard, probabilmente scriveresti questa stessa funzionalità inizializzando una variabile all’indice 0, usando quella variabile per indicizzare il vettore per ottenere un valore e incrementando il valore della variabile in un ciclo fino a raggiungere il numero totale di elementi nel vettore.
Gli iteratori gestiscono tutta questa logica per te, riducendo il codice ripetitivo che potrebbe potenzialmente creare errori. Gli iteratori offrono maggiore flessibilità nell’utilizzare la stessa logica con molti tipi diversi di sequenze, non solo con strutture dati in cui puoi indicizzare, come i vettori. Esaminiamo come riescono a farlo.
Il Trait Iterator e il Metodo next
Tutti gli iteratori implementano un trait chiamato Iterator definito nella
libreria standard. La definizione del trait è la seguente:
#![allow(unused)] fn main() { pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; // metodi con implementazioni predefinite tralasciati } }
Nota che questa definizione utilizza una sintassi che non abbiamo mai visto:
type Item e Self::Item, che definiscono un type associato (associated
type) a questo trait. Parleremo approfonditamente dei type associati nel
Capitolo 20. Per ora, tutto ciò che devi sapere è che questo codice afferma che
l’implementazione del trait Iterator richiede anche la definizione di un
type Item, e questo type Item viene utilizzato nel type di ritorno del
metodo next. In altre parole, il type Item sarà il type restituito
dall’iteratore.
Il trait Iterator richiede agli implementatori di definire un solo metodo:
il metodo next, che restituisce un elemento dell’iteratore alla volta,
racchiuso in Some, e, al termine dell’iterazione, restituisce None.
Possiamo chiamare direttamente il metodo next sugli iteratori; il Listato
13-12 mostra quali valori vengono restituiti da chiamate ripetute a next
sull’iteratore creato dal vettore.
#[cfg(test)]
mod tests {
#[test]
fn dimostrazione_iteratore() {
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}
}
next su un iteratoreNota che è stato necessario rendere v1_iter mutabile: chiamare il metodo
next su un iteratore modifica lo stato interno che l’iteratore utilizza per
tenere traccia della propria posizione nella sequenza. In altre parole, questo
codice consuma, o esaurisce, l’iteratore. Ogni chiamata a next consuma un
elemento dall’iteratore. Non era necessario rendere v1_iter mutabile quando
abbiamo usato un ciclo for, perché il ciclo prendeva ownership di v1_iter
e lo rendeva mutabile in background.
Nota inoltre che i valori ottenuti dalle chiamate a next sono reference
immutabili ai valori nel vettore. Il metodo iter produce un iteratore su
reference immutabili. Se vogliamo creare un iteratore che prende ownership
di v1 e restituisce i valori posseduti, possiamo chiamare into_iter invece
di iter. Allo stesso modo, se vogliamo iterare su reference mutabili,
possiamo chiamare iter_mut invece di iter.
Metodi che Consumano l’Iteratore
Il trait Iterator ha diversi metodi con implementazioni predefinite fornite
dalla libreria standard; è possibile scoprire di più su questi metodi
consultando la documentazione API della libreria standard per il trait
Iterator. Alcuni di questi metodi chiamano il metodo next nella loro
definizione, motivo per cui è necessario implementare il metodo next quando si
implementa il trait Iterator su un proprio type.
I metodi che chiamano next sono chiamati consumatori (consuming adapters),
perché chiamandoli si consuma l’iteratore. Un esempio è il metodo sum, che
prende ownership dell’iteratore e itera attraverso gli elementi chiamando
ripetutamente next, consumandolo. Durante l’iterazione, aggiunge ogni elemento
a un totale parziale e restituisce il totale al termine dell’iterazione. Il
Listato 13-13 contiene un test che illustra l’uso del metodo sum.
#[cfg(test)]
mod tests {
#[test]
fn somma_con_iteratore() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let totale: i32 = v1_iter.sum();
assert_eq!(totale, 6);
}
}
sum per ottenere il totale di tutti gli elementi nell’iteratoreNon è consentito utilizzare v1_iter dopo la chiamata a sum perché sum
prende ownership dell’iteratore su cui viene chiamato.
Metodi che Producono Altri Iteratori
Gli adattatori di iteratore (iterator adapter) sono metodi definiti sul
trait Iterator che non consumano l’iteratore. Invece, producono iteratori
diversi modificando qualche aspetto dell’iteratore originale.
Il Listato 13-14 mostra un esempio di chiamata del metodo dell’adattatore map,
che accetta una chiusura per chiamare ogni elemento durante l’iterazione. Il
metodo map restituisce un nuovo iteratore che produce gli elementi modificati.
La chiusura qui crea un nuovo iteratore in cui ogni elemento del vettore verrà
incrementato di 1.
fn main() { let v1: Vec<i32> = vec![1, 2, 3]; v1.iter().map(|x| x + 1); }
map per creare un nuovo iteratoreTuttavia, questo codice genera un avviso:
$ cargo run
Compiling iteratori v0.1.0 (file:///progetti/iteratori)
warning: unused `Map` that must be used
--> src/main.rs:5:5
|
5 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
5 | let _ = v1.iter().map(|x| x + 1);
| +++++++
warning: `iteratori` (bin "iteratori") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s
Running `target/debug/iteratori`
Il codice nel Listato 13-14 non fa nulla; la chiusura che abbiamo specificato non viene mai chiamata. L’avviso ci ricorda il motivo: gli adattatori sono lazy e qui dobbiamo consumare l’iteratore.
Per correggere questo avviso e consumare l’iteratore, useremo il metodo
collect, che abbiamo usato con env::args nel Listato 12-1. Questo metodo
consuma l’iteratore e raccoglie i valori risultanti in un collezione di type
appropriato.
Nel Listato 13-15, raccogliamo i risultati dell’iterazione sull’iteratore
restituito dalla chiamata a map in un vettore. Questo vettore finirà per
contenere ogni elemento del vettore originale, incrementato di 1.
fn main() { let v1: Vec<i32> = vec![1, 2, 3]; let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); assert_eq!(v2, vec![2, 3, 4]); }
map per creare un nuovo iteratore, quindi chiamata del metodo collect per consumare il nuovo iteratore e creare un vettorePoiché map accetta una chiusura, possiamo specificare qualsiasi operazione
desideriamo eseguire su ciascun elemento. Questo è un ottimo esempio di come le
chiusure consentano di personalizzare alcuni comportamenti, riutilizzando al
contempo il comportamento di iterazione fornito dal trait Iterator.
È possibile concatenare più chiamate agli adattatori per eseguire azioni complesse in modo leggibile. Tuttavia, poiché tutti gli iteratori sono lazy, è necessario chiamare uno dei metodi consumatori per ottenere risultati dalle chiamate agli adattatori.
Chiusure che Catturano il Loro Ambiente
Molti adattatori accettano le chiusure come argomenti e, di solito, le chiusure che specificheremo come argomenti degli adattatori saranno chiusure che catturano il loro ambiente.
Per questo esempio, useremo il metodo filter che accetta una chiusura. La
chiusura riceve un elemento dall’iteratore e restituisce un valore bool. Se la
chiusura restituisce true, il valore verrà incluso nell’iterazione prodotta da
filter. Se la chiusura restituisce false, il valore non verrà incluso.
Nel Listato 13-16, utilizziamo filter con una chiusura che cattura la
variabile misura_scarpe dal suo ambiente per iterare su una collezione di
istanze della struct Scarpa . Restituirà solo le scarpe della taglia
specificata.
#[derive(PartialEq, Debug)]
struct Scarpa {
misura: u32,
stile: String,
}
fn misura_scarpe(scarpe: Vec<Scarpa>, misura_scarpa: u32) -> Vec<Scarpa> {
scarpe.into_iter().filter(|s| s.misura == misura_scarpa).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filtra_per_misura() {
let scarpe = vec![
Scarpa {
misura: 10,
stile: String::from("sneaker"),
},
Scarpa {
misura: 13,
stile: String::from("sandalo"),
},
Scarpa {
misura: 10,
stile: String::from("scarpone"),
},
];
let della_mia_misura = misura_scarpe(scarpe, 10);
assert_eq!(
della_mia_misura,
vec![
Scarpa {
misura: 10,
stile: String::from("sneaker")
},
Scarpa {
misura: 10,
stile: String::from("scarpone")
},
]
);
}
}
filter con una chiusura che cattura misura_scarpaLa funzione misura_scarpe prende ownership di un vettore di scarpe e una
taglia di scarpa come parametri. Restituisce un vettore contenente solo scarpe
della taglia specificata.
Nel corpo di misura_scarpe, chiamiamo into_iter per creare un iteratore che
prende ownership del vettore. Quindi chiamiamo filter per adattare
quell’iteratore in un nuovo iteratore che contiene solo elementi per i quali la
chiusura restituisce true.
La chiusura cattura il parametro misura_scarpa dall’ambiente e confronta il
valore con la taglia di ogni scarpa, mantenendo solo le scarpe della taglia
specificata. Infine, la chiamata a collect raccoglie i valori restituiti
dall’iteratore adattato in un vettore restituito dalla funzione.
Il test mostra che quando chiamiamo misura_scarpe, otteniamo solo le scarpe
che hanno la stessa taglia del valore specificato.