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

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

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

В листинге 12-6 мы добавили код, который использовал срез String и создал экземпляр структуры Config, взяв по индексам данные из среза и клонировав эти значения, таким образом позволив структуре Config владеть значениями. В листинге 13-24 мы повторили реализацию функции Config::new, как это было в листинге 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-24: Воспроизведение функции Config::new из листинга 12-23

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

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

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

Как только Config::new заберёт во владение итератор и перестанет использовать заимствующие операции индексирования, мы можем переместить значения 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-25. Он не компилируется, пока мы не обновим Config::new.

Файл: src/main.rs

{{#rustdoc_include ../listings/ch13-functional-features/listing-13-25/src/main.rs:here}}

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

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

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

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch13-functional-features/listing-13-26/src/lib.rs:here}}

Листинг 13-26: Обновление сигнатуры Config::new для ожидания итератора

Документация стандартной библиотеки для функции env::args показывает, что типом возвращаемого итератора является std::env::Args. Мы обновили сигнатуру функции Config::new, поэтому параметр args имеет тип std::env::Args вместо &[String]. Поскольку мы забираем во владение args и будем изменять args перебирая его элементы, мы можем добавить ключевое слово mut в спецификацию параметра args, чтобы сделать его изменяемым.

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

Далее мы вносим изменения в код тела Config::new. Стандартная библиотека документации также упоминает, что std::env::Args реализует типаж Iterator, поэтому мы знаем, что можем вызвать метод next! Листинг 13-27 обновляет код из листинга 12-23 с использованием метода next:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch13-functional-features/listing-13-27/src/lib.rs:here}}

Листинг 13-27: Новое содержание функции Config::new с использованием методов итератора

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

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

Мы также можем использовать преимущества итераторов в функции search в проекте ввода/вывода, который приводится здесь в листинге 13-28, как это было в листинге 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-28: Реализация функции search из листинга 12-19

Мы можем написать этот код более кратко, используя адаптерные методы итератора. Это также позволяет нам избежать использования изменяемого промежуточного вектора result. Стиль функционального программирования предпочитает минимизировать количество изменяемых состояний, чтобы сделать код более понятным. Удаление изменяемого состояния может позволить в будущем усовершенствовать и сделать поиск параллельным, потому что нам не нужно управлять одновременным доступом к вектору results. Листинг 13-29 показывает это изменение:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch13-functional-features/listing-13-29/src/lib.rs:here}}

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

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

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

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