Улучшение нашего проекта с вводом/выводом

Вооружившись полученными знаниями об итераторах, мы можем улучшить реализацию работы с вводом/выводом в проекте главы 12, применяя итераторы для того, чтобы сделать некоторые места в коде более понятными и лаконичными. Давайте рассмотрим, как итераторы могут улучшить нашу реализацию функции Config::build и функции search.

Удаляем clone, используем итератор

В листинге 12-6 мы добавили код, который принимает срез значений String и создаёт экземпляр структуры Config путём индексации среза и клонирования значений, позволяя структуре Config владеть этими значениями. В листинге 13-17 мы воспроизвели реализацию функции Config::build, как это было в листинге 12-23:

Файл: 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)
        );
    }
}

Листинг 13-17: Репродукция функции Config::build из листинга 12-23

Ранее мы говорили, что не стоит беспокоиться о неэффективных вызовах clone, потому что мы удалим их в будущем. Ну что же, время пришло!

Нам понадобился здесь clone, потому что в параметре args у нас срез с элементами String, но функция build не владеет args. Чтобы экземпляр Config владел значениями, нам пришлось клонировать их из args в переменные query и file_path.

Благодаря нашим новым знаниям об итераторах мы можем изменить функцию build, чтобы вместо заимствования среза она принимала в качестве аргумента итератор. Мы будем использовать функциональность итератора вместо кода, который проверяет длину среза и обращается по индексу к определённым значениям. Это позволит лучше понять, что делает функция Config::build, поскольку итератор будет обращаться к значениям.

Как только Config::build получит в своё распоряжение итератор и перестанет использовать операции индексирования с заимствованием, мы сможем переместить значения String из итератора в Config вместо того, чтобы вызывать clone и создавать новое выделение памяти.

Использование возвращённого итератора напрямую

Откройте файл src/main.rs проекта ввода-вывода, который должен выглядеть следующим образом:

Файл: 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);
    }
}

Сначала мы изменим начало функции main, которая была в листинге 12-24, на код в листинге 13-18, который теперь использует итератор. Это не будет компилироваться, пока мы не обновим Config::build.

Файл: 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);
    }
}

Листинг 13-18: Передача возвращаемого значения из env::args в Config::build

Функция env::args возвращает итератор! Вместо того чтобы собирать значения итератора в вектор и затем передавать срез в Config::build, теперь мы передаём владение итератором, возвращённым из env::args в Config::build напрямую.

Далее нам нужно обновить определение Config::build. В файле src/lib.rs вашего проекта ввода-вывода изменим сигнатуру Config::build так, чтобы она выглядела как в листинге 13-19. Это все ещё не скомпилируется, потому что нам нужно обновить тело функции.

Файл: 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)
        );
    }
}

Листинг 13-19: Обновление сигнатуры Config::build для определения итератора как ожидаемого параметра

Документация стандартной библиотеки для функции env::args показывает, что тип возвращаемого ею итератора - std::env::Args, и этот тип реализует признак Iterator и возвращает значения String.

Мы обновили сигнатуру функции Config::build, чтобы параметр args имел универсальный тип ограниченный трейтом impl Iterator<Item = String> вместо &[String]. Такое использование синтаксиса impl Trait, который мы обсуждали в разделе " Трейты как параметры" главы 10, означает, что args может быть любым типом, реализующим тип Iterator и возвращающим элементы String.

Поскольку мы владеем args и будем изменять args в процессе итерации над ним, мы можем добавить ключевое слово mut в спецификацию параметра args, чтобы сделать его изменяемым.

Использование методов типажа Iterator вместо индексов

Далее мы подправим содержимое Config::build. Поскольку args реализует признак Iterator, мы знаем, что можем вызвать у него метод next! В листинге 13-20 код из листинга 12-23 обновлён для использования метода next:

Файл: 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)
        );
    }
}

Листинг 13-20: Изменяем тело Config::build так, чтобы использовать методы итератора

Помните, что первое значение в возвращаемых данных env::args - это имя программы. Мы хотим проигнорировать его и перейти к следующему значению, поэтому сперва мы вызываем next и ничего не делаем с возвращаемым значением. Затем мы вызываем next, чтобы получить значение, которое мы хотим поместить в поле query в Config. Если next возвращает Some, мы используем match для извлечения значения. Если возвращается None, это означает, что было задано недостаточно аргументов, и мы досрочно возвращаем значение Err. То же самое мы делаем для значения file_path.

Делаем код понятнее с помощью адаптеров итераторов

Мы также можем воспользоваться преимуществами итераторов в функции search в нашем проекте с операциями ввода-вывода, которая воспроизведена здесь в листинге 13-21 так же, как и в листинге 12-19:

Файл: 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));
    }
}

Листинг 13-21: Реализация функции search из листинга 12-19

Мы можем написать этот код в более сжатом виде, используя методы адаптера итератора. Это также позволит нам избежать наличия изменяемого временного вектора results. Функциональный стиль программирования предпочитает минимизировать количество изменяемого состояния, чтобы сделать код более понятным. Удаление изменяемого состояния может позволить в будущем сделать поиск параллельным, поскольку нам не придётся управлять одновременным доступом к вектору results. В листинге 13-22 показано это изменение:

Файл: 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)
        );
    }
}

Листинг 13-22: Использование методов адаптера итератора в реализации функции search

Напомним, что назначение функции search - вернуть все строки в contents, которые содержат query. Подобно примеру filter в листинге 13-16, этот код использует адаптер filter, чтобы сохранить только те строки, для которых line.contains(query) возвращает true. Затем мы собираем совпадающие строки в другой вектор с помощью collect. Так гораздо проще! Не стесняйтесь сделать такое же изменение для использования методов итератора в функции search_case_insensitive.

Выбор между циклами или итераторами

Следующий логичный вопрос - какой стиль вы должны выбрать в своём коде и почему: оригинальную реализацию в листинге 13-21 или версию с использованием итераторов в листинге 13-22. Большинство программистов на языке Rust предпочитают использовать стиль итераторов. Сначала разобраться с ним немного сложно, но как только вы почувствуете, что такое различные адаптеры итераторов и что они делают, понять итераторы станет проще. Вместо того чтобы возиться с различными элементами цикла и создавать новые векторы, код фокусируется на высокоуровневой цели цикла. Это абстрагирует часть обычного кода, поэтому легче увидеть концепции, уникальные для этого кода, такие как условие фильтрации, которое должен пройти каждый элемент в итераторе.

Но действительно ли эти две реализации эквивалентны? Интуитивно можно предположить, что более низкоуровневый цикл будет быстрее. Давайте поговорим о производительности.