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

Обладая новыми знаниями об итераторах, можно улучшить проект ввода-вывода главы 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 filename: String,
    pub case_sensitive: bool,
}

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

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

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&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::new(&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

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

use minigrep::Config;

fn main() {
    let config = Config::new(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-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

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

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

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

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

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&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-26: Обновление сигнатуры Config::new для ожидания итератора

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

Нам также нужно было указать, что тип ошибки среза строки теперь может иметь только 'static время жизни. Так как раньше мы возвращали только строковые литералы, это было так. Однако, когда у нас была ссылка в параметрах, была вероятность того, что ссылка в возвращаемом типе могла иметь то же время жизни, что и ссылка в параметрах. Применялись правила, которые мы обсуждали в разделе «Lifetime Elision» главы 10, и от нас не требовалось аннотировать время жизни &str. С изменением args правила исключения времени жизни больше не применяются, и мы должны указать 'static время жизни.

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

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

Файл: src/lib.rs

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

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

impl Config {
    pub fn new(mut args: env::Args) -> Result<Config, &'static str> {
        args.next();

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

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

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&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-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 filename: String,
}

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

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

        Ok(Config { query, filename })
    }
}

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

    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

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

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

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

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

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&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-29: Использование адаптерных методов итератора в реализации функции search

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

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

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