Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Улучшение нашего I/O-проекта

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

Удаление clone с помощью итератора

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

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

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

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

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

Прямое использование возвращаемого итератора

Откройте файл src/main.rs вашего I/O-проекта, который должен выглядеть так:

Имя файла: 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.

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: Передача возвращаемого значения env::args в Config::build

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

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

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: Обновление сигнатуры 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.

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: Изменение тела Config::build для использования методов итератора

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

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

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

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

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: Использование методов-адаптеров итератора в реализации функции search

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

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

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

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