Улучшаем наш проект
Теперь, зная об итераторах, можно улучшить проект с вводом-выводом из главы
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);
});
// --пропуск--
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);
});
// --пропуск--
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(())
}
Config::build то, что вернула env::argsФункция env::args возвращает итератор! Вместо того чтобы собирать значения
итератора в вектор и передавать в Config::build срез, мы теперь передаём в
Config::build владение самим итератором, который вернула env::args.
Теперь нужно обновить определение 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> {
// --пропуск--
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 теперь не тип
&[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.
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 предпочитают стиль с итераторами. Освоиться с ним поначалу чуть сложнее, но когда разберёшься в разных адаптерах и в том, что они делают, итераторы читаются легче. Вместо возни с деталями цикла и построением новых векторов код сосредоточен на высокоуровневой цели цикла. Рутинный код уходит в абстракцию, и проще увидеть то, что уникально именно для этого кода, — например, условие фильтрации, которое должен пройти каждый элемент итератора.
Но действительно ли эти две реализации равноценны? Интуиция подсказывает, что низкоуровневый цикл будет быстрее. Поговорим о производительности.