Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 23
Iteratori

Concludiamo questo intermezzo prima di affrontare le funzioni parlando brevemente degli iteratori. Come avrete capito anche nei capitoli precedenti, si tratta di strumenti che ci permettono  di visitare gli elementi di una collezione (anche se il discorso delle collezioni è ampio e sarà trattato a parte) e di lavorare su di essi. Il materiale è ampio e qui darò solo dei concetti di base utili per lavorare e capire il discorso nel suo complesso. Va detto che si tratta, per via della loro implementazioni, di costrutti molto duttili ed efficienti. Ad esempio, gli iteratori in Rust sono progettati per essere zero-cost abstractions. Ciò significa che usano il monomorfismo e il compilatore LLVM (tale framework, lo ricordiamo, è il motore sottostante a questo linguaggio) per generare 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.

Si tratta di elementi che implementano il trait Iterator e che adottano un 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.

Creare un iteratore è abbastanza semplice: dichiariamo prima una sequenza e poi lo definiamo a partire da questa:

let v1 = vec![1,2,3,4];    
let iter = v1.iter();

ecco fatto, con due semplici istruzioni abbiamo definito il nostro iter che ci permetterà di lavorare sui singoli elementi del vettore. Vediamo un semplice esempio completo:

  Esempio 23.1
1
2
3
4
5
6
7
fn main() {
    let v1 = vec![1,2,3,4];
    let iter = v1.iter();
    for i in iter {
        print!("{} ", i)
    }
}

iter non è ovviamente l'unico iteratore che possiamo utilizzare (lo vedremo comunque all'opera anche in un esempio nel capitolo delle funzioni). Molto interessante è into_iter che consuma la sorgente dei dati svuotandola. Nell'esempio seguente usiamo uno dei metodi più noti e comuni usati in Rust quando si tratta di sequenze ovvero next() fondamentale nel trait Iterator. Next(), come dice il nome stesso,  viene usato per recuperare il prossimo elemento in una sequenza. Restituisce Some(elemento) se vi è ancora un elemento incontrato durante il suo avanzamento e None se non vi è più nulla. Ecco un breve esempio di utilizzo pratico:

  Esempio 23.2
1
2
3
4
5
6
7
8
fn main() {
    let mut nums = vec![1, 2, 3].into_iter();
    // Chiamando `next()` si ottiene il prossimo elemento dell'iteratore
    println!("{:?}", nums.next()); // "Some(1)"
    println!("{:?}", nums.next()); // "Some(2)"
    println!("{:?}", nums.next()); // "Some(3)"
    println!("{:?}", nums.next()); // "None" perché non ci sono più elementi
}

Di frequente incontrerete anche iter_mut il quale fornisce riferimenti mutabili ad un collezione. Su tali riferimenti è possibile lavorare, come nel seguente semplice e classico esempio:

 
  Esempio 23.3
1
2
3
4
5
6
7
fn main() {
let mut vec = vec![10, 20, 30];
for num in vec.iter_mut() {
    *num += 5; // Aggiunge 5 a ogni elemento del vettore
    }
println!("{:?}", vec); // Stampa "[15, 25, 35]"
}

dove ad ogni elemento su cui agisce l'iteratore viene aggiunto il numero 5. Non preoccupatevi per ora di quel * che capiremo bene nel capitolo dedicato ai puntatori. In questo caso è necessario usare quell'operatore, in questo caso è un operatore di dereferenziazione, in quanto, come detto, iter_mut non restituisce un intero ma un riferimento.
Molto simile al calssico for ma usato per un approccio funzionale è
for_each. Lo mostriamo in azione nel seguente esempio, dove trovate una anticipazione delle chiusure, oggetto di una dei capitoli più avanti:

  Esempio 23.4
1
2
3
4
fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    numbers.iter().for_each(|&num| println!("Numero: {}", num));
}

Abbiamo già incontrato nei capitoli scorsi un utilissimo metodo di iterazione ovvero
nth(n). Esso viene utilizzato per accedere all’elemento in una posizione specifica all’interno di un iteratore. Quando chiamiamo nth(n) su un iteratore, ci restituisce un’opzione (Option) che contiene l’elemento alla posizione n (dove la numerazione inizia da zero). Se l’iteratore ha meno di n + 1 elementi, nth(n) restituirà None. Ecco l'esempio:

  Esempio 23.5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
    let v = vec![10, 20, 30, 40, 50];
    // Accedere 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 accedere 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!("Non esiste un elemento alla posizione 10"),
    }
}

I commenti spiegano bene quello che accade, di nostro interesse ovviamente sono risultano in particolare la riga 4 e la 10 dove entra in azione nth(n). La cosa non finisce qui in quanto le manipolazioni possibili sono davvero tante. Mostro di seguito un esempio completo che potrete rivedere una volta che avremo affrontato il capitolo dedicato ai puntatori:

  Esempio 23.6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn main() {
    let mut v = vec![10, 20, 30, 40, 50];
    // Variabile di tipo i32 con un valore iniziale
    let mut x: i32 = 0;
    // Uso un iteratore mutabile per manipolare l'elemento
    let third_mut = v.iter_mut().nth(2);
    match third_mut {
        Some(value) => {
            *value *= 10;  // Modifica l'elemento moltiplicandolo per 10
            x = *value; // Assegna il valore modificato alla variabile x
            println!("Elemento modificato indice 2 =: {}", value);
        },
        None => {
            println!("Nessun elemento alla posizione 2");
            x = -1; // Assegna un valore di errore a x
        }
    }
    // Utilizzo della variabile x nel resto del programma
    if x > 0 {
        println!("Valore ottenuto da x: {}", x);
    } else {
        println!("Errore, nessun valore valido ottenuto");
    }
    println!("Vettore modificato: {:?}", v);
    println!("{}", x * 4);
}

Lo scopo dell'esempio è mostrare un uso completo dei valori estratti grazie a nth().

Per tutte queste iterazioni è disponibile anche possibilità di eseguire uno step incrementale diverso da 1. L'esempio seguente incrementa di due unità ad ogni passo, in questi casi si usa step_by(num), vedi l'esempio seguente (poco originale, lo so... lo trovate più o meno dappertutto):

  Esempio 23.7
1
2
3
4
5
6
fn main() {
    let a = [0, 1, 2, 3, 4, 5];
    for numero in a.iter().step_by(2) {
        println!("{}", numero);
    }    
}

Diamo ora una sguardo a map che in pratica può essere utilizzato in congiunzione con iter per trasformare ogni elemento di un iteratore in un altro valore, producendo un nuovo iteratore che contiene i risultati delle trasformazioni. Internamente la definizione di map non è proprio immediata e bisogna conoscere il concetto di chiusura che è l'argomento richiesto, ma dal punto di vista pratico in molti casi il suo uso più facile a farsi che a dirsi, come dimostra il seguente esempio:

  Esempio 23.8
1
2
3
4
5
6
7
fn main() {
    let numeri = vec![1, 2, 3, 4, 5];
    // Utilizziamo map con iter per raddoppiare ogni numero
    let numeri_doppi: Vec<i32> = numeri.iter().map(|x| x * 2).collect();
    println!("Numeri originali: {:?}", numeri);
    println!("Numeri raddoppiati: {:?}", numeri_doppi);
}

con questo output:

Numeri originali: [1, 2, 3, 4, 5]
Numeri raddoppiati: [2, 4, 6, 8, 10]

come si vede map usa come parametro, quel
|x| che trovate subito dopo la chiamata a map, i valori che vengono ricavati dall'iterazione. Si noti la presenza di collect alla riga 4, metodo necessario al fine di ricreare una sequenza. Necessario perchè, senza di esso, map praticamente non genera nulla. Forse ancora meglio, alla riga 4 |x| può essere sostituito da |&x| . Per chi fosse curioso di vedere come funzionano le cose under the hood, internamente collect è definita come segue:

fn collect<B: FromIterator<Self::Item>>(self) -> B
where
Self: Sized,
{
  FromIterator::from_iter(self)
}


in breve: collect restituisce un tipo B che deve implementare il trait FromIterator, <Self::Item> si riferisce al fatto che verrà preso in carico il tipo dell'iteratore mentre 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.

I metodi che trovate nel trait Iterator sono davvero tanti. Questo breve excursus non può coprirli tutti. L'ultimo che mi pare molto utile per lavorare in manira proficua è zip, che abbiamo già incontrato. Questo in pratica combina due sequenze creando una sequenza di ennuple dove il primo elemento di ciascuna ennupla viene dalla prima sequenza. Vediamo anche qui un esempio:

  Esempio 23.9
1
2
3
4
5
6
fn main() {  let numeri = vec![1, 2, 3, 4, 5];  
    let v1 = vec!["gatto", "Toyota", "Rust"];
    let v2 = vec!["felino", "marchio", "linguaggio"];
    let combinati: Vec<_> = v1.iter().zip(v2.iter()).collect();
    println!("{:?}", combinati);
}

che ci dà:

[("gatto", "felino"), ("Toyota", "marchio"), ("Rust", "linguaggio")]

Molto comodo è enumerate che trasforma un iteratore in un iteratore che restituisce ogni elemento con il suo indice. In pratica avremo una ennupla contenente una coppia (indice, elemento). Ecco l'esempio:

Esempio 23.10
1
2
3
4
5
6
fn main() {  
    let numeri = vec![1, 2, 3, 4, 5];  
    let a = ['a', 'b', 'c', 'd'];
    let v1: Vec<_> = a.iter().enumerate().collect();
    println!("{:?}", v1);
}

ed ecco qua le nostre ennuple:

[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd')]

Concludo evidenziando altri iteratori meno usati ma utili in qualche occasione:

std::iter::repeat(): Produce un iteratore che ripete infinitamente un valore specifico.

std::iter::once(): Restituisce un iteratore che produce un singolo elemento una sola volta.

std::iter::empty(): Crea un iteratore che non produce alcun elemento.

Diamo un esempio:

  Esempio 23.11
1
2
3
4
5
fn main() {
    let ripeti_valore = std::iter::repeat(42);
    let vettore: Vec<_> = ripeti_valore.take(5).collect();
    println!("{:?}", vettore);
}

Fa la sua comparsa anche take che è utilizzato dal linguaggio per limitare il numero di elementi preso in carico da un iteratore. Un altro frammento di codice che lo vede in azione è il seguente:

let numbers = vec![1, 2, 3, 4, 5]; // Crea un iteratore che restituirà solo i primi 3 elementi.
let limited_numbers: Vec<_> = numbers.iter().take(3).collect();

Piuttosto utile può essere anche peek che permette di esaminare l'elemento successivo senza consumarlo. Questo può essere utile in algoritmi dove è necessario guardare avanti nel flusso di dati. Ottimo in congiunzione con into_iter

  Esempio 23.12
1
2
3
4
5
6
7
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
}

Può essere utile prima di chiudere riassumere quegli adattatori che permettono di filtrare i risultati degli iteratori, che forse ho presentato in modo un po' caotico. In particolare parliamo di alcuni tra i più usati e che rivediamo in un esempio che li  include tutti:

  Esempio 23.13
1
2
3
4
5
6
7
8
9
10
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);  
 }

map:     Trasforma ogni elemento.
filter Filtra gli elementi in base a una condizione.
take:    Limita il numero di elementi. 
skip:    Salta un certo numero di elementi.

In considerazione di ciò l'output sarà:

[8, 10, 12]

Per quanto sia argomento avanzato, ma per dimostrare ancora di più la grande duttilità degli iteratori, è interessante notare 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.