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

Улучшаем наш проект

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

Избавляемся от clone с помощью итератора

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

Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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

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

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

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

impl Config {
    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,
        })
    }
}

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(())
}
Листинг 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 проекта с вводом-выводом. Он должен выглядеть так:

Файл: src/main.rs

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

use minigrep::{search, search_case_insensitive};

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);
    });

    // --пропуск--

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

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

impl Config {
    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,
        })
    }
}

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(())
}

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

Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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

    // --пропуск--

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

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

impl Config {
    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,
        })
    }
}

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(())
}
Листинг 13-18: Передаём в Config::build то, что вернула env::args

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

Теперь нужно обновить определение Config::build. Изменим её сигнатуру, как в листинге 13-19. Код всё ещё не скомпилируется: нужно обновить и тело функции.

Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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

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

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

impl Config {
    fn build(
        mut args: impl Iterator<Item = 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,
        })
    }
}

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(())
}
Листинг 13-19: Меняем сигнатуру Config::build, чтобы она ожидала итератор

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

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

Мы забираем владение args и будем менять его, перебирая элементы, поэтому в описание параметра args можно добавить mut, чтобы сделать его изменяемым.

Методы трейта Iterator вместо индексации

Теперь исправим тело Config::build. Раз args реализует трейт Iterator, у него можно вызывать метод next! В листинге 13-20 код из листинга 12-23 переписан с использованием next.

Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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

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

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

impl Config {
    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,
        })
    }
}

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(())
}
Листинг 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
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
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.

А чтобы улучшить код ещё больше, сделайте так, чтобы search возвращала итератор: уберите вызов collect и поменяйте возвращаемый тип на impl Iterator<Item = &'a str> — тогда функция сама станет адаптером итератора. Учтите, что придётся обновить и тесты! Попробуйте поискать что-нибудь в большом файле утилитой minigrep до и после этого изменения и сравните поведение. До изменения программа ничего не выводит, пока не соберёт все результаты. А после — результаты выводятся по мере нахождения совпадающих строк, потому что цикл for в функции run пользуется ленивостью итератора.

Что выбрать: циклы или итераторы

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

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