Mejorando nuestro proyecto I/O

Con este nuevo conocimiento sobre iteradores, podemos mejorar el proyecto I/O en el Capítulo 12 usando iteradores para hacer que los lugares en el código sean más claros y concisos. Veamos cómo los iterators pueden mejorar nuestra implementación de la función Config::build y la función search.

Removiendo un clone usando un iterator

En el Listado 12-6, agregamos código que tomó un slice de valores String y creó una instancia del struct Config indexando en el slice y clonando los valores, permitiendo que el struct Config posea esos valores. En el Listado 13-17, hemos reproducido la implementación de la función Config::build tal como estaba en el Listado 12-23:

Filename: src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Listing 13-17: Reproducción de la función Config::build del Listing 12-23

En ese momento, dijimos que no nos preocupáramos por las llamadas ineficientes a clone porque las eliminaríamos en el futuro. ¡Bueno, ese momento es ahora!

Necesitábamos clone aquí porque tenemos un slice con elementos String en el parámetro args, pero la función build no posee args. Para retornar la propiedad de una instancia de Config, tuvimos que clonar los valores de los campos query y file_path de Config para que la instancia de Config pueda poseer sus valores.

Con nuestro nuevo conocimiento sobre iteradores, podemos cambiar la función build para tomar propiedad de un iterator como su argumento en lugar de tomar prestado un slice. Usaremos la funcionalidad del iterator en lugar del código que verifica la longitud del slice e indexa en ubicaciones específicas. Esto aclarará lo que la función Config::build está haciendo porque el iterator accederá a los valores.

Una vez que Config::build tome ownership del iterator y deje de usar operaciones de indexación que toman borrowing, podemos mover los valores String del iterator dentro de Config en lugar de llamar a clone y hacer una nueva asignación.

Usando el iterator retornado directamente

Abre tu proyecto I/O en src/main.rs, el cual debería verse así:

Filename: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Primero cambiaremos el inicio de la función main que teníamos en el Listado 12-24 al código del Listado 13-18, el cual esta vez usa un iterator. Esto no compilará hasta que actualicemos Config::build también.

Filename: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Listing 13-18: Pasando el valor de retorno de env::args a Config::build

¡La función env::args retorna un iterator! En lugar de recolectar los valores del iterator en un vector y luego pasar un slice a Config::build, ahora estamos pasando ownership del iterator retornado por env::args directamente a Config::build.

Luego, necesitamos actualizar la definición de Config::build. En el archivo src/lib.rs de tu proyecto I/O, cambiemos la firma de Config::build para que se vea como el Listado 13-19. Esto aún no compilará porque necesitamos actualizar el cuerpo de la función.

Filename: src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Listing 13-19: Actualizando la firma de Config::build para esperar un iterator

La documentación de la biblioteca estándar para la función env::args muestra que el tipo del iterator que retorna es std::env::Args, y que ese tipo implementa el trait Iterator y retorna valores String.

Hemos actualizado la firma de la función Config::build para que el parámetro args tenga un tipo genérico con los trait bounds impl Iterator<Item = String> en lugar de &[String]. Este uso de la sintaxis impl Trait que discutimos en la sección “Traits como parámetros”

del Capítulo 10 significa que `args` puede ser cualquier tipo

que implemente el trait Iterator y retorne items String.

Debido a que estamos tomando ownership de args y estaremos mutando args por iterarlo, podemos agregar la palabra clave mut en la especificación del parámetro args para hacerlo mutable.

Usando los métodos del trait Iterator en lugar de indexar

Luego, necesitamos actualizar el cuerpo de Config::build para usar los métodos del trait Iterator en lugar de indexar en el slice. En el Listado 13-20 hemos actualizado el código del Listado 12-23 para usar el método next:

Filename: src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Listing 13-20: Cambiando el cuerpo de Config::build para usar métodos de iterators

Recuerda que el primer valor en el valor de retorno de env::args es el nombre del programa. Queremos ignorar eso y llegar al siguiente valor, así que primero llamamos a next y no hacemos nada con el valor de retorno. Segundo, llamamos a next para obtener el valor que queremos poner en el campo query de Config. Si next retorna un Some, usamos un match para extraer el valor. Si retorna None, significa que no se dieron suficientes argumentos y retornamos temprano con un valor Err. Hacemos lo mismo para el valor file_path.

Haciendo el código más claro con iterator adaptors

También podemos aprovechar los iterators en la función search de nuestro proyecto I/O, el cual se reproduce aquí en el Listado 13-21 como estaba en el Listado 12-19:

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Listing 13-21: La implementación de la función search del Listing 12-19

Podemos escribir este código de una manera más concisa usando los métodos adaptor del iterator. Hacerlo también nos permite evitar tener un vector intermedio mutable results. El estilo de programación funcional prefiere minimizar la cantidad de estado mutable para hacer el código más claro. Remover el estado mutable podría permitir una mejora futura para hacer que la búsqueda ocurra en paralelo, porque no tendríamos que manejar el acceso concurrente al vector results. El Listado 13-22 muestra este cambio:

Filename: src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Listing 13-22: Utilizando método iterator adaptor en la implementación de la función search

Recuerda que el propósito de la función search es retornar todas las líneas en contents que contengan query. Similar al ejemplo de filter en el Listado 13-16, este código usa el adaptador filter para mantener solo las líneas que retornan true para line.contains(query). Luego recolectamos las líneas que coinciden en otro vector con collect. ¡Mucho más simple! Siéntete libre de hacer el mismo cambio para usar los métodos del iterator en la función search_case_insensitive también.

Escogiendo entre loops o iterators

La siguiente pregunta lógica es qué estilo deberías escoger en tu propio código y por qué: la implementación original en el Listado 13-21 o la versión usando iterators en el Listado 13-22. La mayoría de los programadores Rust prefieren usar el estilo de iterators. Es un poco más difícil de entender al principio, pero una vez que obtienes una idea de los varios adaptadores de iterators y lo que hacen, los iterators pueden ser más fáciles de entender. En lugar de manipular los varios bits de los loops y construir nuevos vectores, el código se enfoca en el objetivo de alto nivel del loop. Esto abstrae un poco del código común para que sea más fácil ver los conceptos que son únicos a este código, como la condición de filtrado que cada elemento en el iterator debe pasar.

¿Pero son las dos implementaciones realmente equivalentes? La suposición intuitiva podría ser que el loop más bajo nivel será más rápido. Hablemos de performance.