Улучшение нашего проекта ввода-вывода
С новыми знаниями об итераторах мы можем улучшить проект ввода-вывода из
главы 12, используя итераторы, чтобы сделать некоторые места в коде яснее и
кратче. Посмотрим, как итераторы могут улучшить нашу реализацию функции
Config::build и функции search.
Удаление clone с помощью итератора
В листинге 12-6 мы добавили код, который брал срез значений String и создавал
экземпляр структуры Config, индексируясь в срез и клонируя значения, что
позволяло структуре Config владеть этими значениями. В листинге 13-17 мы
воспроизвели реализацию функции Config::build такой, какой она была в
листинге 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(())
}
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);
});
// --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, которое у нас было в листинге 12-24,
на код из листинга 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, теперь мы
напрямую передаем владение итератором, возвращенным из env::args, в
Config::build.
Далее нужно обновить определение Config::build. Изменим сигнатуру
Config::build, чтобы она выглядела как в листинге 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(())
}
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.
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(())
}
Config::build для использования методов итератораПомните, что первое значение в возвращаемом значении env::args – это имя
программы. Мы хотим проигнорировать его и перейти к следующему значению,
поэтому сначала вызываем next и ничего не делаем с возвращаемым значением.
Затем мы вызываем next, чтобы получить значение, которое хотим поместить в
поле query структуры Config. Если next возвращает Some, мы используем
match, чтобы извлечь значение. Если он возвращает None, это означает, что
было передано недостаточно аргументов, и мы досрочно возвращаем значение Err.
То же самое мы делаем для значения file_path.
Прояснение кода с помощью адаптеров итераторов
Мы также можем воспользоваться итераторами в функции search нашего проекта
ввода-вывода, которая воспроизведена здесь в листинге 13-21 такой, какой она
была в листинге 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));
}
}
search из листинга 12-19Мы можем написать этот код более кратко, используя методы адаптеров итераторов.
Это также позволяет избежать промежуточного изменяемого вектора results.
Стиль функционального программирования предпочитает минимизировать объем
изменяемого состояния, чтобы сделать код яснее. Удаление изменяемого состояния
может позволить в будущем улучшить поиск так, чтобы он выполнялся параллельно,
потому что нам не пришлось бы управлять конкурентным доступом к вектору
results. Листинг 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Вспомните, что назначение функции 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 предпочитает использовать стиль итераторов. Поначалу к нему немного сложнее привыкнуть, но когда вы почувствуете разные адаптеры итераторов и поймете, что они делают, итераторы могут стать проще для понимания. Вместо возни с разными частями цикла и построением новых векторов код фокусируется на высокоуровневой цели цикла. Это абстрагирует часть обычного кода, так что проще увидеть концепции, уникальные для этого кода, например условие фильтрации, которое должен пройти каждый элемент итератора.
Но действительно ли эти две реализации эквивалентны? Интуитивное предположение может быть таким: более низкоуровневый цикл будет быстрее. Поговорим о производительности.