Покращуємо наш проєкт з введенням/виведенням

Використовуючи нові знання про ітератори, ми можемо покращити проєкт введення/виведення у Розділі 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, оскільки ми видалимо їх e майбутньому. Що ж, цей час настав!

Нам тут потрібен clone, тому що ми маємо слайс з елементами String у параметрі args, але функція 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::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, щоб приймала ітератор

The standard library documentation for the env::args function shows that the type of the iterator it returns is std::env::Args, and that type implements the Iterator trait and returns String values.

Ми оновили сигнатуру функції 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, щоб отримати значення, ми хочемо вставити в поле Config query. Якщо 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 вважають за краще використовувати ітераторний стиль. До нього дещо складніше призвичаїтися в перший час, але відколи ви набудете відчуття різноманітних ітераторів і що вони роблять, ітератори стають простішими для розуміння. Замість того, щоб займатися дрібними уточненнями в циклі й збирати нові вектори, код зосереджується на високорівневій меті циклу. Це дозволяє абстрагуватися від деякого банального коду, щоб легше було побачити концепції, унікальні для цього коду, такі як умови фільтрації, яку має пройти кожен елемент ітератора.

Але чи ці дві реалізації дійсно еквівалентні? Інтуїтивне припущення може казати, що більш низькорівневий цикл буде швидшим. Поговорімо про швидкодію.