Procesamiento de una serie de elementos con iteradores

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Procesamiento de una Serie de Elementos con Iteradores. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos cómo procesar una serie de elementos utilizando iteradores, que son perezosos y nos permiten iterar sobre una secuencia de elementos sin tener que reimplementar la lógica nosotros mismos.

Procesamiento de una Serie de Elementos con Iteradores

El patrón de iterador te permite realizar una tarea en una secuencia de elementos, uno a la vez. Un iterador es responsable de la lógica de iterar sobre cada elemento y de determinar cuándo ha terminado la secuencia. Cuando utilizas iteradores, no tienes que reimplementar esa lógica por ti mismo.

En Rust, los iteradores son perezosos, lo que significa que no tienen ningún efecto hasta que llamas a métodos que consumen el iterador para agotarlo. Por ejemplo, el código de la Lista 13-10 crea un iterador sobre los elementos del vector v1 llamando al método iter definido en Vec<T>. Este código por sí solo no hace nada útil.

let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

Lista 13-10: Creación de un iterador

El iterador se almacena en la variable v1_iter. Una vez que hemos creado un iterador, podemos usarlo de varias maneras. En la Lista 3-5, iteramos sobre una matriz usando un bucle for para ejecutar un código en cada uno de sus elementos. En el fondo, esto creó implícitamente y luego consumió un iterador, pero hasta ahora nos hemos pasado por alto cómo funciona exactamente eso.

En el ejemplo de la Lista 13-11, separamos la creación del iterador del uso del iterador en el bucle for. Cuando se llama al bucle for usando el iterador en v1_iter, cada elemento del iterador se utiliza en una iteración del bucle, lo que imprime cada valor.

let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

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

Lista 13-11: Uso de un iterador en un bucle for

En los lenguajes que no tienen iteradores proporcionados por sus bibliotecas estándar, probablemente escribirías la misma funcionalidad comenzando una variable en el índice 0, usando esa variable para acceder al vector y obtener un valor, e incrementando el valor de la variable en un bucle hasta que alcanzara el número total de elementos del vector.

Los iteradores manejan toda esa lógica por ti, reduciendo el código repetitivo que podrías confundir. Los iteradores te dan más flexibilidad para usar la misma lógica con muchos tipos diferentes de secuencias, no solo con estructuras de datos en las que puedes acceder por índice, como los vectores. Veamos cómo lo hacen los iteradores.

El Trait Iterator y el Método next

Todos los iteradores implementan un trait llamado Iterator que está definido en la biblioteca estándar. La definición del trait se ve así:

pub trait Iterator {
    type Item;

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

    // métodos con implementaciones predeterminadas omitidas
}

Observa que esta definición utiliza un nuevo sintaxis: type Item y Self::Item, que están definiendo un tipo asociado con este trait. Hablaremos de los tipos asociados en profundidad en el Capítulo 19. Por ahora, todo lo que necesitas saber es que este código dice que implementar el trait Iterator requiere que también defina un tipo Item, y este tipo Item se utiliza en el tipo de retorno del método next. En otras palabras, el tipo Item será el tipo devuelto por el iterador.

El trait Iterator solo requiere que los implementadores definan un método: el método next, que devuelve un elemento del iterador a la vez, envuelto en Some, y, cuando la iteración ha terminado, devuelve None.

Podemos llamar al método next en los iteradores directamente; la Lista 13-12 demuestra qué valores se devuelven de llamadas repetidas a next en el iterador creado a partir del vector.

Nombre de archivo: src/lib.rs

#[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);
}

Lista 13-12: Llamada al método next en un iterador

Observa que tuvimos que hacer v1_iter mutable: llamar al método next en un iterador cambia el estado interno que el iterador utiliza para llevar un registro de dónde se encuentra en la secuencia. En otras palabras, este código consume, o agota, el iterador. Cada llamada a next consume un elemento del iterador. No tuvimos que hacer v1_iter mutable cuando usamos un bucle for porque el bucle tomó posesión de v1_iter y lo hizo mutable en secreto.

También observa que los valores que obtenemos de las llamadas a next son referencias inmutables a los valores en el vector. El método iter produce un iterador sobre referencias inmutables. Si queremos crear un iterador que tome posesión de v1 y devuelva valores con posesión, podemos llamar a into_iter en lugar de iter. Del mismo modo, si queremos iterar sobre referencias mutables, podemos llamar a iter_mut en lugar de iter.

Métodos que Consumen el Iterador

El trait Iterator tiene una serie de métodos diferentes con implementaciones predeterminadas proporcionadas por la biblioteca estándar; puedes conocer estos métodos consultando la documentación de la API de la biblioteca estándar para el trait Iterator. Algunos de estos métodos llaman al método next en su definición, razón por la cual se te requiere implementar el método next al implementar el trait Iterator.

Los métodos que llaman a next se denominan adaptadores consumidores porque llamarlos agota el iterador. Un ejemplo es el método sum, que toma posesión del iterador y itera a través de los elementos llamando repetidamente a next, consumiendo así el iterador. A medida que itera, suma cada elemento a un total acumulado y devuelve el total cuando la iteración ha finalizado. La Lista 13-13 tiene una prueba que ilustra el uso del método sum.

Nombre de archivo: src/lib.rs

#[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);
}

Lista 13-13: Llamada al método sum para obtener el total de todos los elementos en el iterador

No podemos utilizar v1_iter después de la llamada a sum porque sum toma posesión del iterador en el que se la llama.

Métodos que Producen Otros Iteradores

Los adaptadores de iterador son métodos definidos en el trait Iterator que no consumen el iterador. En cambio, producen iteradores diferentes cambiando algún aspecto del iterador original.

La Lista 13-14 muestra un ejemplo de llamada al método de adaptador de iterador map, que toma una clausura para llamar en cada elemento a medida que se itera a través de los elementos. El método map devuelve un nuevo iterador que produce los elementos modificados. La clausura aquí crea un nuevo iterador en el que cada elemento del vector se incrementará en 1.

Nombre de archivo: src/main.rs

let v1: Vec<i32> = vec![1, 2, 3];

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

Lista 13-14: Llamada al adaptador de iterador map para crear un nuevo iterador

Sin embargo, este código produce una advertencia:

advertencia: `Map` no utilizado que debe ser utilizado
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = nota: `#[warn(unused_must_use)]` activado por defecto
  = nota: los iteradores son perezosos y no hacen nada a menos que se consuman

El código de la Lista 13-14 no hace nada; la clausura que hemos especificado nunca se llama. La advertencia nos recuerda por qué: los adaptadores de iterador son perezosos, y aquí necesitamos consumir el iterador.

Para corregir esta advertencia y consumir el iterador, usaremos el método collect, que usamos con env::args en la Lista 12-1. Este método consume el iterador y recopila los valores resultantes en un tipo de datos de colección.

En la Lista 13-15, recopilamos en un vector los resultados de iterar sobre el iterador que se devuelve de la llamada a map. Este vector terminará conteniendo cada elemento del vector original, incrementado en 1.

Nombre de archivo: src/main.rs

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]);

Lista 13-15: Llamada al método map para crear un nuevo iterador, y luego llamada al método collect para consumir el nuevo iterador y crear un vector

Debido a que map toma una clausura, podemos especificar cualquier operación que queramos realizar en cada elemento. Este es un gran ejemplo de cómo las clausuras te permiten personalizar cierto comportamiento mientras reutilizas el comportamiento de iteración que proporciona el trait Iterator.

Puedes encadenar múltiples llamadas a adaptadores de iterador para realizar acciones complejas de manera legible. Pero como todos los iteradores son perezosos, tienes que llamar a uno de los métodos de adaptador consumidor para obtener resultados de las llamadas a adaptadores de iterador.

Usando Clausuras que Capturan su Entorno

Muchos adaptadores de iterador toman clausuras como argumentos, y comúnmente las clausuras que especificaremos como argumentos a los adaptadores de iterador serán clausuras que capturan su entorno.

Para este ejemplo, usaremos el método filter que toma una clausura. La clausura recibe un elemento del iterador y devuelve un bool. Si la clausura devuelve true, el valor se incluirá en la iteración producida por filter. Si la clausura devuelve false, el valor no se incluirá.

En la Lista 13-16, usamos filter con una clausura que captura la variable shoe_size de su entorno para iterar sobre una colección de instancias del struct Shoe. Devolverá solo los zapatos del tamaño especificado.

Nombre de archivo: 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")
                },
            ]
        );
    }
}

Lista 13-16: Usando el método filter con una clausura que captura shoe_size

La función shoes_in_size toma posesión de un vector de zapatos y un tamaño de zapato como parámetros. Devuelve un vector que contiene solo zapatos del tamaño especificado.

En el cuerpo de shoes_in_size, llamamos a into_iter para crear un iterador que tome posesión del vector. Luego llamamos a filter para adaptar ese iterador en un nuevo iterador que solo contiene elementos para los cuales la clausura devuelve true.

La clausura captura el parámetro shoe_size del entorno y compara el valor con el tamaño de cada zapato, manteniendo solo los zapatos del tamaño especificado. Finalmente, llamar a collect agrupa los valores devueltos por el iterador adaptado en un vector que es devuelto por la función.

La prueba muestra que cuando llamamos a shoes_in_size, obtenemos solo los zapatos que tienen el mismo tamaño que el valor que especificamos.

Resumen

¡Felicitaciones! Has completado el laboratorio de Procesamiento de una Serie de Elementos con Iteradores. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.