Procesarea unei serii de elemente cu ajutorul iteratorilor

Modelul iterator permite efectuarea unei anumite sarcini pe o secvență de elemente, unul câte unul. Un iterator este responsabil de logica parcurgerii fiecărui element și stabilirea momentului în care secvența este completă. Când folosești iteratori, nu este necesar să reimplementezi tu această logică.

În Rust, iteratorii sunt indolenți (lazy), adică nu produc niciun efect până nu apelezi metode care consumă iteratorul pentru utilizare. De exemplu, codul din Listarea 13-10 creează un iterator pentru elementele din vectorul v1 prin apelarea metodei iter, definită pe Vec<T>. Acest cod în sine nu realizează nimic util.

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}

Listarea 13-10: Crearea unui iterator

În variabila v1_iter este stocat iteratorul. După ce un iterator a fost creat, putem să-l utilizăm în diferite moduri. În Listarea 3-5 din Capitolul 3, am itinerat peste un array folosind o buclă for pentru a executa un cod pe fiecare element. În realitate, acest lucru a creat și consumat implicit un iterator, dar nu am detaliat cum funcționează acest proces până acum.

În exemplul din Listarea 13-11, crearea iteratorului este separată de utilizarea lui în bucla for. Când bucla for folosește iteratorul v1_iter, fiecare element din iterator participă la o iterație a buclei, imprimând astfel fiecare valoare.

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {}", val);
    }
}

Listarea 13-11: Utilizarea unui iterator într-o buclă for

În limbajele de programare care nu dispun de iteratori în bibliotecile lor standard, probabil ai scrie aceeași funcționalitate inițializând o variabilă la indexul 0, utilizând această variabilă pentru a accesa elementele din vector și incrementând valoarea variabilei într-o buclă până la atingerea numărului total de elemente din vector.

Iteratorii se ocupă de toată această logică pentru tine, minimizând codul repetitiv care ar putea fi și greșit. Aceștia oferă o flexibilitate sporită pentru a aplica aceeași logică la diferite tipuri de secvențe, nu doar la structuri de date indexabile, precum vectorii. Acum să explorăm cum realizează iteratorii acest lucru.

Trăsătura Iterator și metoda next

Toți iteratorii implementează o trăsătură denumită Iterator definită în biblioteca standard. Definiția acestei trăsături este următoarea:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // metodele cu implementări predefinite au fost omise
}
}

Această definiție introduce o sintaxă nouă: type Item și Self::Item, care stabilește un tip asociat trăsăturii. Vom aborda tipurile asociate în detaliu în Capitolul 19. Deocamdată, e important să știm că, pentru a implementa trăsătura Iterator, trebuie definit și un tip Item, care este utilizat în tipul de retur al metodei next, adică tipul Item va fi tipul de date returnat de iterator.

Trăsătura Iterator necesită de la cei ce o implementează să definească o singură metodă: next, care returnează câte un element al iteratorului învelit în Some, iar când iterația s-a încheiat, returnează None.

Metoda next poate fi apelată direct pe iteratori; Listarea 13-12 ilustrează valorile returnate de apeluri multiple la next pe iteratorul creat din vector.

Numele fișierului: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        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);
    }
}

Listarea 13-12: Apelarea metodei next pe un iterator

Trebuie să remarcăm că a fost necesar să facem v1_iter mutabil: invocarea metodei next pe un iterator modifică starea internă pe care acesta o utilizează pentru a urmări poziția curentă în secvență. Practic, acest cod consumă iteratorul. Fiecare chemare a lui next consumă un element din iterator. Când folosim o buclă for, nu este nevoie să facem v1_iter mutabil deoarece bucla preia posesiunea lui v1_iter și îl face mutabil în mod implicit.

Mai trebuie să observăm că valorile obținute din apelurile la next sunt referințe imutabile la elementele din vector. Metoda iter creează un iterator de referințe imutabile. Dacă vrem un iterator care să preia posesiunea v1 și să returneze valori proprii, putem folosi metoda into_iter în loc de iter. În mod similar, pentru a itera peste referințe mutabile, putem utiliza iter_mut în locul lui iter.

Metode care consumă iteratorul

Trăsătura Iterator include o serie de metode diferite cu implementări default furnizate de biblioteca standard; poți afla mai multe despre aceste metode consultând documentația API pentru trăsătura Iterator. Câteva dintre aceste metode folosesc next în definiția lor, de aceea este necesar să implementezi metoda next când realizezi implementarea trăsăturii Iterator.

Metodele care invocă next sunt denumite adaptoare consumatoare, deoarece utilizarea lor epuizează iteratorul. Un exemplu este metoda sum, care își asumă posesiunea iteratorului și parcurge elementele prin apeluri repetate ale next, consumând în acest fel iteratorul. În timpul iterării, adaugă fiecare element la un total acumulat și returnează acest total la finalizarea iterației. Listarea 13-13 prezintă un test care ilustrează folosirea metodei sum:

Numele fișierului: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}

Listarea 13-13: Utilizarea metodei sum pentru a calcula suma totală a elementelor din iterator

Nu ne este permis să utilizăm v1_iter după apelul la sum, deoarece sum preia posesiunea iteratorului pe care îl apelăm.

Metode ce generează alți iteratori

Adaptoarele de iterator sunt metode definite pe trăsătura Iterator care nu consumă iteratorul. În loc de asta, ele generează alți iteratori modificând anumite aspecte ale iteratorului original.

Listarea 13-14 prezintă un exemplu de utilizare a metodei adaptor de iterator map, care primește o închidere ce este apelată pentru fiecare element în timp ce sunt iterați. Metoda map returnează un nou iterator ce produce elementele modificate. În acest caz, închiderea creează un nou iterator unde fiecare element din vector va fi mărit cu o unitate:

Numele fișierului: src/main.rs

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

    v1.iter().map(|x| x + 1);
}

Listarea 13-14: Utilizarea adaptorului de iterator map pentru a genera un nou iterator

Cu toate acestea, acest cod generează un avertisment:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default

warning: `iterators` (bin "iterators") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

Codul din Listarea 13-14 de fapt nu realizează nimic; închiderea pe care am specificat-o nu este invocată niciodată. Avertismentul ne reamintește motivul: adaptoarele de iterator sunt evaluate indolent și trebuie să consumăm iteratorul aici.

Pentru a corecta acest avertisment și a consuma iteratorul, ne vom folosi de metoda collect, aceeași pe care am utilizat-o în Capitolul 12 cu env::args în Listarea 12-1. Această metodă consumă iteratorul și colectează valorile obținute într-o structură de tip colecție.

În Listarea 13-15, noi colectăm rezultatele iterației peste noul iterator rezultat din apelul la map într-un vector. Acest vector va conține în final fiecare element din vectorul inițial incrementat cu 1.

Numele fișierului: 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]);
}

Listarea 13-15: Utilizarea metodei map pentru a genera un nou iterator și apoi utilizarea metodei collect pentru a consuma acest iterator nou și a crea un vector

Deoarece map primește o închidere, putem defini orice operație dorim să efectuăm asupra fiecărui element. Aceasta este o ilustrație excelentă a modului în care închiderile îți permit să personalizezi anumite comportamente în timp ce refolosești comportamentul de iterație pe care trăsătura Iterator îl furnizează.

Putem combina mai multe apeluri către adaptoarele de iteratori pentru a efectua acțiuni complexe într-un mod inteligibil. Totuși, fiindcă toți iteratorii sunt evaluați indolent, e necesar să folosim una dintre metodele adaptoare consumatoare pentru a obține rezultate din apelurile către adaptoare de iteratori.

Folosirea închiderilor pentru captarea variabilelor din context

Adaptoarele de iteratori folosesc des închideri ca parametri. De obicei, închiderile pe care urmează să le specificăm pentru aceste adaptoare sunt cele care captează variabilele din contextul lor.

În exemplul nostru, vom apela la metoda filter, care acceptă o închidere. Aceasta primește fiecare element din iterator și returnează un bool. Dacă închiderea evaluează la true, elementul va fi inclus în seria de date generată de filter. Pe de altă parte, dacă închiderea evaluează la false, elementul nu va fi inclus.

În Listarea 13-16, folosim filter împreună cu o închidere care include variabila shoe_size din context, pentru a itera prin colecția de structuri Shoe, furnizând doar pantofii care au o anumită mărime.

Numele fișierului: src/lib.rs

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}

Listarea 13-16: Utilizarea metodei filter cu o închidere care capturează variabila shoe_size

Funcția shoes_in_size preia un vector de pantofi și o mărime a pantofului ca parametri. Ea returnează un vector care cuprinde exclusiv pantofi cu mărimea specificată.

În corpul funcției shoes_in_size, creăm un iterator prin apelarea into_iter, care preia vectorul. Următorul pas este aplicarea filter la acest iterator, transformându-l într-un nou iterator care va conține doar elementele pentru care închiderea evaluează la true.

Închiderea folosită captează parametrul shoe_size din context și îl compară cu mărimea fiecărui pantof, selectând doar pantofii care conform cu măsura specificată. Finalizând cu collect, agregăm valorile filtrate de iterator într-un vector care este apoi returnat de funcție.

Testul nostru confirmă că, după apelarea funcției shoes_in_size, rezultatul conține doar pantofii având mărimea egală cu cea specificată inițial.