Покращення нашого I/O-проєкту
З цим новим знанням про ітератори ми можемо покращити I/O-проєкт у
Розділі 12, використовуючи ітератори, щоб зробити місця в коді зрозумілішими й
лаконічнішими. Давайте подивимося, як ітератори можуть покращити нашу реалізацію
функції Config::build і функції search.
Видалення clone за допомогою ітератора
У Listing 12-6 ми додали код, який брав зріз значень String і створював
екземпляр структури Config, індексуючись у зріз і клонуючи
значення, що дозволяло структурі Config володіти цими значеннями. У Listing 13-17
ми відтворили реалізацію функції Config::build так, як вона була
у Listing 12-23.
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(())
}
Тоді ми сказали не хвилюватися через неефективні виклики clone, тому що
ми приберемо їх у майбутньому. Що ж, цей час настав!
Нам був потрібен clone тут, тому що ми маємо зріз з елементами String у
параметрі args, але функція build не володіє args. Щоб повернути
власність над екземпляром Config, нам довелося клонувати значення з полів
query і file_path структури Config, щоб екземпляр Config міг володіти
своїми значеннями.
З нашим новим знанням про ітератори ми можемо змінити функцію build, щоб
брати у володіння ітератор як свій аргумент замість запозичення зрізу.
Ми використаємо функціональність ітератора замість коду, який перевіряє довжину
зрізу та індексується в конкретні місця. Це прояснить, що саме робить функція
Config::build, тому що ітератор отримуватиме доступ до значень.
Щойно Config::build почне брати у володіння ітератор і перестане використовувати
операції індексування, які запозичують, ми зможемо переміщувати значення String
з ітератора в Config замість виклику clone і створення нового виділення.
Безпосереднє використання поверненого ітератора
Відкрийте файл src/main.rs вашого I/O-проєкту, який має виглядати так:
Filename: 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);
});
// --snip--
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, який був у Listing
12-24, на код у Listing 13-18, який цього разу використовує ітератор.
Це не скомпілюється, доки ми не оновимо також Config::build.
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);
});
// --snip--
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(())
}
Функція env::args повертає ітератор! Замість того, щоб збирати
значення ітератора у вектор, а потім передавати зріз до Config::build, тепер
ми передаємо у володіння ітератор, повернений з env::args, безпосередньо до
Config::build.
Далі нам потрібно оновити визначення Config::build. Давайте змінимо
сигнатуру Config::build так, щоб вона виглядала як у Listing 13-19. Це все ще
не скомпілюється, тому що нам потрібно оновити тіло функції.
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> {
// --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,
})
}
}
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(())
}
Документація стандартної бібліотеки для функції env::args показує, що
тип ітератора, який вона повертає, — це std::env::Args, і цей тип реалізує
трейт Iterator та повертає значення String.
Ми оновили сигнатуру функції Config::build так, що
параметр args має узагальнений тип з обмеженнями трейту impl Iterator<Item = String> замість &[String]. Це використання синтаксису impl Trait, яке ми
обговорювали в розділі “Using Traits as Parameters”
Розділу 10, означає, що args може бути будь-яким типом, який реалізує
трейт Iterator і повертає елементи String.
Оскільки ми беремо у володіння args і будемо змінювати args, ітеруючись
по ньому, ми можемо додати ключове слово mut у специфікацію
параметра args, щоб зробити його змінним.
Використання методів трейту Iterator
Далі ми виправимо тіло Config::build. Оскільки args реалізує
трейт Iterator, ми знаємо, що можемо викликати на ньому метод next! Listing 13-20
оновлює код з Listing 12-23, щоб використовувати метод next.
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(())
}
Пам’ятайте, що перше значення у значенні, яке повертає env::args, — це ім’я
програми. Ми хочемо проігнорувати його й дійти до наступного значення, тому спочатку ми викликаємо
next і нічого не робимо з поверненим значенням. Потім ми викликаємо next, щоб отримати
значення, яке хочемо помістити в поле query структури Config. Якщо next повертає
Some, ми використовуємо match, щоб витягти значення. Якщо він повертає None, це означає
що передано недостатньо аргументів, і ми завчасно повертаємо Err. Ми робимо
те саме для значення file_path.
Уточнення коду за допомогою адаптерів ітератора
Ми також можемо скористатися перевагами ітераторів у функції search у нашому I/O
проєкті, яка відтворена тут у Listing 13-21 так, як вона була у Listing 12-19.
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));
}
}
Ми можемо написати цей код лаконічніше, використовуючи методи адаптера ітератора.
Роблячи так, ми також можемо уникнути змінного проміжного вектора results. Стиль
функціонального програмування надає перевагу мінімізації кількості змінного стану, щоб
зробити код зрозумілішим. Видалення змінного стану може дати змогу майбутньому вдосконаленню
зробити пошук паралельним, тому що нам не доведеться керувати
конкурентним доступом до вектора results. Listing 13-22 показує цю зміну.
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)
);
}
}
Пригадайте, що мета функції search — повернути всі рядки в
contents, які містять query. Подібно до прикладу filter у Listing
13-16, цей код використовує адаптер filter, щоб залишити лише ті рядки, для яких
line.contains(query) повертає true. Потім ми збираємо відповідні рядки в
інший вектор за допомогою collect. Набагато простіше! За бажанням ви можете зробити таку саму зміну,
щоб використовувати методи ітератора також у функції search_case_insensitive.
Для подальшого покращення поверніть ітератор з функції search, прибравши
виклик collect і змінивши тип повернення на impl Iterator<Item = &'a str>, щоб функція стала адаптером ітератора.
Зверніть увагу, що вам також потрібно оновити тести! Пошукайте у великому файлі
за допомогою вашого інструмента minigrep до і після внесення цієї зміни, щоб спостерігати
різницю в поведінці. До цієї зміни програма не друкуватиме жодних результатів
доти, доки не збере всі результати, але після зміни результати
друкуватимуться в міру знаходження кожного відповідного рядка, тому що цикл for
у функції run може скористатися лінивістю ітератора.
Вибір між циклами та ітераторами
Наступне логічне питання — який стиль ви повинні вибрати у власному коді і чому: оригінальну реалізацію в Listing 13-21 чи версію, що використовує ітератори в Listing 13-22 (припускаючи, що ми збираємо всі результати перед поверненням їх, а не повертаємо ітератор). Більшість програмістів Rust віддають перевагу стилю ітератора. Спочатку до нього трохи важче звикнути, але коли ви відчуєте різні адаптери ітератора та те, що вони роблять, ітератори можуть бути зрозумілішими. Замість того, щоб возитися з різними частинами циклу та побудовою нових векторів, код зосереджується на високорівневій меті циклу. Це абстрагує деякий звичайний код, щоб було легше побачити концепції, які є унікальними для цього коду, такі як умова фільтрації, яку кожен елемент в ітераторі повинен пройти.
Але чи є ці дві реалізації справді еквівалентними? Інтуїтивне припущення може бути таким, що нижчорівневий цикл буде швидшим. Давайте поговоримо про продуктивність.