Улучшение нашего I/O проекта
С этими новыми знаниями об итераторах мы можем улучшить I/O проект из главы 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(())
}
В то время мы сказали не беспокоиться о неэффективных вызовах 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 проекта, который должен выглядеть так:
Файл: 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.
Далее нам нужно обновить определение 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(())
}
Документация стандартной библиотеки для функции 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! Listing 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(())
}
Помните, что первое значение в возвращаемом значении env::args — это имя программы. Мы хотим проигнорировать его и перейти к следующему значению, поэтому сначала мы вызываем next и ничего не делаем с возвращаемым значением. Затем мы вызываем next, чтобы получить значение, которое мы хотим поместить в поле query структуры Config. Если next возвращает Some, мы используем match для извлечения значения. Если он возвращает None, это означает, что было предоставлено недостаточно аргументов, и мы досрочно возвращаем значение Err. Мы делаем то же самое для значения file_path.
Упрощение кода с помощью адаптеров итераторов
Мы также можем воспользоваться итераторами в функции search нашего I/O проекта, которая воспроизведена здесь в листинге 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));
}
}
Мы можем написать этот код более кратко, используя методы адаптеров итераторов. Это также позволяет нам избежать наличия изменяемого промежуточного вектора 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 — вернуть все строки в 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 предпочитают использовать стиль итераторов. Сначала к нему немного сложнее привыкнуть, но как только вы почувствуете различные адаптеры итераторов и то, что они делают, итераторы могут стать легче для понимания. Вместо возни с различными частями циклов и построения новых векторов код сосредотачивается на высокоуровневой цели цикла. Это абстрагирует некоторую стандартную часть кода, чтобы было легче увидеть концепции, уникальные для этого кода, такие как условие фильтрации, которое каждый элемент в итераторе должен пройти.
Но действительно ли две реализации эквивалентны? Интуитивное предположение может заключаться в том, что низкоуровневый цикл будет быстрее. Давайте поговорим о производительности.