Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

File: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listato 13-10: Creazione di un iteratore

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.

File: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Ottenuto: {val}");
    }
}
Listato 13-11: Utilizzo di un iteratore in un ciclo 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.

File: src/lib.rs
#[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);
    }
}
Listato 13-12: Chiamata del metodo next su un iteratore

Nota 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.

File: src/lib.rs
#[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);
    }
}
Listato 13-13: Chiamata del metodo sum per ottenere il totale di tutti gli elementi nell’iteratore

Non è 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.

File: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listato 13-14: Chiamata dell’adattatore map per creare un nuovo iteratore

Tuttavia, 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.

File: src/main.rs
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]);
}
Listato 13-15: Chiamata del metodo map per creare un nuovo iteratore, quindi chiamata del metodo collect per consumare il nuovo iteratore e creare un vettore

Poiché 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.

File: src/lib.rs
#[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")
                },
            ]
        );
    }
}
Listato 13-16: Utilizzo del metodo 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.


  1. Lazy su wikipedia (ita)