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}"); } }
for
Nei 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_scarpa
La 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.