Рефакторинг: модульность и обработка ошибок
Чтобы улучшить программу, исправим четыре проблемы, связанные с её
структурой и обработкой возможных ошибок. Первая: сейчас функция main делает
две вещи — разбирает аргументы и читает файл. По мере роста программы
отдельных задач у main будет становиться всё больше. Чем больше
обязанностей у функции, тем сложнее о ней рассуждать, тем труднее её
тестировать и тем легче, меняя её, что-нибудь сломать. Лучше разделить
функциональность так, чтобы каждая функция отвечала за одну задачу.
С этим связана и вторая проблема: query и file_path — это настройки
программы, а переменные вроде contents нужны для её логики. Чем длиннее
main, тем больше переменных придётся держать в области видимости, а чем их
больше, тем сложнее помнить, зачем нужна каждая. Лучше собрать настройки в
одну структуру, чтобы их назначение было очевидным.
Третья проблема: при ошибке чтения файла мы выводим сообщение через expect,
но оно говорит только Should have been able to read the file («файл должен
был прочитаться»). А прочитать файл не удаётся по разным причинам: например,
файла нет или у нас нет прав на его открытие. Сейчас при любой ситуации мы
выводим одно и то же сообщение, которое ничего не говорит пользователю!
Четвёртая: мы обрабатываем ошибку через expect, и если пользователь
запустит программу, не передав достаточно аргументов, он получит от Rust
ошибку index out of bounds, которая толком не объясняет проблему. Лучше,
чтобы весь код обработки ошибок был в одном месте: тогда тем, кто будет
поддерживать код, придётся заглядывать только в одно место, если логику
обработки ошибок понадобится поменять. Заодно так мы точно будем выводить
сообщения, понятные конечным пользователям.
Решим эти четыре проблемы, проведя рефакторинг проекта.
Разделение ответственности в бинарных проектах
Проблема с тем, что на функцию main взвалено несколько задач, свойственна
многим бинарным проектам. Поэтому многие программисты на Rust, когда main
начинает разрастаться, разделяют обязанности бинарной программы. Делается это
так:
- Разбейте программу на файлы main.rs и lib.rs и перенесите логику программы в lib.rs.
- Пока логика разбора командной строки небольшая, она может оставаться в
функции
main. - Когда логика разбора командной строки начинает усложняться, вынесите её из
mainв отдельные функции или типы.
После этого у функции main должны остаться только такие обязанности:
- вызвать логику разбора командной строки со значениями аргументов;
- выполнить прочую настройку;
- вызвать функцию
runиз lib.rs; - обработать ошибку, если
runеё вернула.
Суть этого шаблона — разделение ответственности: main.rs отвечает за запуск
программы, а lib.rs — за всю логику решаемой задачи. Функцию main нельзя
протестировать напрямую, а при такой структуре вся логика программы вынесена
из main, и её можно тестировать. Код, оставшийся в main, будет настолько
маленьким, что в его правильности можно убедиться, просто прочитав его.
Переделаем нашу программу по этой схеме.
Выносим разбор аргументов
Вынесем разбор аргументов в функцию, которую будет вызывать main. В
листинге 12-5 — новое начало функции main, которое вызывает новую функцию
parse_config; определим её в src/main.rs.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --пропуск--
println!("Searching for {query}");
println!("In file {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
main функцию parse_configМы по-прежнему собираем аргументы командной строки в вектор. Но теперь main
не раскладывает их сама (аргумент с индексом 1 — в query, с индексом 2 — в
file_path), а передаёт весь вектор в функцию parse_config. Решать, какой
аргумент в какую переменную попадает, теперь будет parse_config, а готовые
значения она вернёт в main.
Переменные query и file_path мы по-прежнему создаём в main, но main
больше не отвечает за соответствие между аргументами командной строки и
переменными.
Для нашей маленькой программы такая переделка может показаться излишней, но мы проводим рефакторинг маленькими шагами. После этого изменения снова запустите программу и убедитесь, что разбор аргументов работает. Полезно почаще проверять результат: если что-то сломается, проще будет найти причину.
Группируем настройки
Функцию parse_config можно улучшить ещё одним маленьким шагом. Сейчас она
возвращает кортеж, который мы тут же снова разбираем на части. Это признак
того, что подходящей абстракции у нас пока нет.
Есть и другой признак, что код можно улучшить: часть config в имени
parse_config подразумевает, что два возвращаемых значения связаны между
собой и вместе составляют одну конфигурацию. Сейчас мы никак не выражаем этот
смысл в структуре данных, разве что объединяем два значения в кортеж. Вместо
этого положим оба значения в одну структуру и дадим каждому полю осмысленное
имя. Так тем, кто будет поддерживать код, будет проще понять, как связаны
разные значения и зачем они нужны.
В листинге 12-6 — улучшенная функция parse_config.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
// --пропуск--
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
parse_config, чтобы она возвращала экземпляр структуры ConfigМы добавили структуру Config с полями query и file_path. Сигнатура
parse_config теперь говорит, что функция возвращает значение Config. Раньше
в теле parse_config мы возвращали строковые срезы, ссылающиеся на значения
String в args, а теперь Config хранит собственные значения String.
Владелец значений аргументов — переменная args в main, и она лишь даёт
parse_config их позаимствовать. Значит, если бы Config попыталась забрать
владение значениями из args, мы нарушили бы правила заимствования Rust.
Управлять данными String можно по-разному. Самый простой, хоть и не самый
эффективный путь — вызвать у значений метод clone. Так Config получит
собственную полную копию данных, что требует больше времени и памяти, чем
хранение ссылки на строку. Зато клонирование делает код очень простым: не
нужно управлять временами жизни ссылок. В данной ситуации пожертвовать
небольшой долей производительности ради простоты — разумный компромисс.
Цена clone
Многие растацеаны стараются не решать проблемы владения через clone из-за
его цены во время выполнения. В главе 13 вы узнаете
более эффективные способы для таких ситуаций. Но сейчас вполне можно
скопировать пару строк, чтобы двигаться дальше: копирование происходит лишь
один раз, а путь к файлу и строка запроса очень маленькие. Лучше рабочая,
хоть и не самая эффективная программа, чем попытка сверхоптимизировать код с
первого захода. С опытом вам будет проще сразу писать самое эффективное
решение, а пока вызывать clone — совершенно нормально.
Мы изменили main: теперь экземпляр Config, который возвращает
parse_config, кладётся в переменную config. А код, который раньше
использовал отдельные переменные query и file_path, теперь использует поля
структуры Config.
Теперь из кода яснее видно, что query и file_path связаны и что они
задают поведение программы. Любой код, которому нужны эти значения, знает, что
искать их надо в экземпляре config, в полях с говорящими именами.
Конструктор для Config
Мы вынесли из main логику разбора аргументов командной строки в функцию
parse_config. Это помогло увидеть, что значения query и file_path связаны
и эту связь нужно выразить в коде. Затем мы добавили структуру Config: она
даёт общее имя связке query и file_path, а parse_config теперь
возвращает именованные поля, а не безымянный кортеж.
Раз теперь задача parse_config — создавать экземпляр Config, её можно
превратить из обычной функции в функцию new, ассоциированную со структурой
Config. Так код станет идиоматичнее. Экземпляры типов стандартной
библиотеки, например String, создаются вызовом String::new. Точно так же,
превратив parse_config в функцию new, ассоциированную с Config, мы сможем
создавать экземпляры Config вызовом Config::new. В листинге 12-7 показаны
нужные изменения.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
// --пропуск--
}
// --пропуск--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
parse_config в Config::newВ main вместо вызова parse_config теперь вызывается Config::new. Мы
переименовали parse_config в new и перенесли её в блок impl, который
связывает функцию new с Config. Скомпилируйте код ещё раз и убедитесь, что
всё работает.
Исправляем обработку ошибок
Теперь займёмся обработкой ошибок. Напомним: если в векторе args меньше трёх
элементов, обращение к элементам с индексами 1 или 2 вызовет панику.
Попробуйте запустить программу без аргументов — получится так:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023615) panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Строка index out of bounds: the len is 1 but the index is 1 — сообщение для
программистов. Конечному пользователю оно не поможет понять, что делать.
Исправим это.
Улучшаем сообщение об ошибке
В листинге 12-8 мы добавляем в функцию new проверку, что срез достаточно
длинный, прежде чем обращаться к индексам 1 и 2. Если срез слишком короткий,
программа паникует и выводит более понятное сообщение.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --пропуск--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --пропуск--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Этот код похож на функцию Guess::new из листинга 9-13, где мы вызывали panic!, если аргумент value выходил за пределы
допустимых значений. Только здесь мы проверяем не диапазон значений, а длину
args: в нём должно быть хотя бы три элемента, и остальная часть функции
может на это рассчитывать. Если элементов меньше трёх, условие в if
истинно, и мы вызываем макрос panic!, чтобы сразу завершить программу.
С этими несколькими строками в new снова запустим программу без аргументов
и посмотрим, как теперь выглядит ошибка:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023776) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Так лучше: теперь сообщение об ошибке осмысленное. Но в выводе есть и лишняя
информация, которую пользователям показывать не нужно. Возможно, приём из
листинга 9-13 здесь не лучший: как обсуждалось в главе
9, panic! больше подходит для ошибок
программирования, чем для ошибок использования. Вместо этого воспользуемся
другим приёмом из главы 9 — вернём Result,
который означает либо успех, либо ошибку.
Возвращаем Result вместо вызова panic!
Можно возвращать значение Result, в котором при успехе будет экземпляр
Config, а при ошибке — описание проблемы. Заодно переименуем функцию из
new в build: многие программисты ожидают, что функции new никогда не
завершаются неудачей. Через тип Result функция Config::build сможет
сообщить main, что возникла проблема. А main уже превратит вариант Err
в понятную пользователю ошибку — без сопутствующего текста про thread 'main'
и RUST_BACKTRACE, который появляется при вызове panic!.
В листинге 12-9 показано, что нужно изменить в возвращаемом значении функции,
которую мы теперь называем Config::build, и в её теле, чтобы она возвращала
Result. Учтите, что код не скомпилируется, пока мы не обновим и main, —
это мы сделаем в следующем листинге.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
Config::build возвращает ResultФункция build возвращает Result: при успехе — экземпляр Config, при
ошибке — строковый литерал. Значениями ошибок у нас всегда будут строковые
литералы со временем жизни 'static.
В теле функции мы сделали два изменения: вместо вызова panic!, когда
пользователь передал недостаточно аргументов, теперь возвращается значение
Err, а возвращаемое значение Config мы обернули в Ok. С этими
изменениями функция соответствует своей новой сигнатуре.
Теперь, когда Config::build умеет возвращать Err, функция main может
сама разобрать Result, который вернула build, и при ошибке завершить
процесс аккуратнее.
Вызываем Config::build и обрабатываем ошибки
Чтобы обработать ошибку и вывести понятное пользователю сообщение, нужно
изменить main так, чтобы она обрабатывала Result, который возвращает
Config::build, — как в листинге 12-10. Заодно мы забираем у panic!
обязанность завершать утилиту с ненулевым кодом ошибки и реализуем это сами.
Ненулевой код завершения — это соглашение, которым программа сообщает
вызвавшему её процессу, что завершилась с ошибкой.
use std::env;
use std::fs;
use std::process;
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);
});
// --пропуск--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
Config не удалосьВ этом листинге используется метод, который мы ещё не разбирали подробно, —
unwrap_or_else, определённый в стандартной библиотеке для Result<T, E>.
unwrap_or_else позволяет задать собственную обработку ошибки без panic!.
Если Result — это Ok, метод ведёт себя как unwrap: возвращает значение,
обёрнутое в Ok. Если же это Err, метод вызывает код замыкания — анонимной
функции, которую мы определяем и передаём аргументом в unwrap_or_else.
Подробнее о замыканиях — в главе 13. Пока достаточно
знать, что unwrap_or_else передаст внутреннее значение Err — в нашем
случае статическую строку "not enough arguments" из листинга 12-9 — в наше
замыкание, в аргумент err, указанный между вертикальными чертами. Код
замыкания может пользоваться значением err.
Мы добавили строку use, чтобы подключить process из стандартной
библиотеки. Код замыкания, который выполняется при ошибке, — всего две
строки: выводим значение err и вызываем process::exit. Функция
process::exit немедленно останавливает программу и возвращает переданное
число как код завершения. Это похоже на обработку через panic! из листинга
12-8, только без лишнего вывода. Попробуем:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
Отлично! Такой вывод гораздо дружелюбнее к пользователям.
Выносим логику из main
С разбором конфигурации разобрались — займёмся логикой программы. Как мы
говорили в разделе «Разделение ответственности в бинарных
проектах»,
вынесем функцию run, в которой будет вся логика из main, не связанная с
настройкой конфигурации и обработкой ошибок. В итоге main станет короткой, и
её правильность можно будет проверить на глаз, а для всей остальной логики
можно будет написать тесты.
В листинге 12-11 — небольшое пошаговое улучшение: выносим функцию run.
use std::env;
use std::fs;
use std::process;
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);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
// --пропуск--
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
run с остальной логикой программыТеперь в функции run вся оставшаяся логика из main, начиная с чтения
файла. Функция run принимает аргументом экземпляр Config.
Возвращаем ошибки из run
Когда остальная логика вынесена в run, можно улучшить обработку ошибок — так
же, как мы сделали с Config::build в листинге 12-9. Вместо того чтобы
давать программе паниковать через expect, функция run будет возвращать
Result<T, E>, если что-то пошло не так. Так мы сможем собрать всю логику
обработки ошибок в main и сделать её дружелюбной к пользователю. В листинге
12-12 — изменения в сигнатуре и теле run.
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --пропуск--
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);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
run теперь возвращает ResultЗдесь три важных изменения. Во-первых, возвращаемый тип run теперь
Result<(), Box<dyn Error>>. Раньше функция возвращала единичный тип (), и
его мы оставляем как значение в случае Ok.
Для типа ошибки мы взяли трейт-объект Box<dyn Error> (и подключили
std::error::Error инструкцией use в начале файла). О трейт-объектах мы
поговорим в главе 18. Пока достаточно знать, что
Box<dyn Error> означает: функция вернёт тип, реализующий трейт Error, но
какой именно — указывать не нужно. Это даёт гибкость: в разных ситуациях
можно возвращать ошибки разных типов. Ключевое слово dyn — сокращение от
dynamic (динамический).
Во-вторых, вместо вызова expect теперь оператор ?, о котором мы говорили в
главе 9. Вместо паники при ошибке ?
вернёт значение ошибки из текущей функции, и её обработает вызывающий код.
В-третьих, при успехе функция run теперь возвращает значение Ok. В
сигнатуре мы объявили тип успеха run как (), значит, нужно обернуть
единичное значение в Ok. Запись Ok(()) поначалу может показаться
странной, но такое использование () — идиоматичный способ показать, что
run вызывается только ради побочных эффектов и нужного нам значения не
возвращает.
Этот код скомпилируется, но с предупреждением:
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Rust сообщает, что наш код проигнорировал значение Result, а это значение
может означать, что произошла ошибка. Мы не проверяем, была ли ошибка, и
компилятор напоминает, что здесь, скорее всего, должен быть код обработки
ошибок! Исправим это.
Обрабатываем в main ошибки из run
Проверять и обрабатывать ошибки будем примерно так же, как с Config::build в
листинге 12-10, но с небольшим отличием:
Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
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);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
Здесь вместо unwrap_or_else используется if let: он проверяет, вернула
ли run значение Err, и если да — вызывает process::exit(1). В отличие от
Config::build, которая возвращает экземпляр Config, run не возвращает
значения, которое нужно было бы «развернуть» (unwrap). При успехе run
возвращает (), так что нас интересует только обнаружение ошибки, и
unwrap_or_else, возвращающий развёрнутое значение (а это был бы просто
()), не нужен.
Тела if let и unwrap_or_else в обоих случаях одинаковы: выводим ошибку и
завершаемся.
Выносим код в библиотечный крейт
Пока проект minigrep выглядит неплохо! Теперь разделим файл src/main.rs и
перенесём часть кода в src/lib.rs. Так код можно будет тестировать, а у
src/main.rs станет меньше обязанностей.
Определим код поиска текста в src/lib.rs, а не в src/main.rs: тогда мы (или
кто угодно, кто пользуется нашей библиотекой minigrep) сможем вызывать
функцию поиска не только из бинарника minigrep.
Сначала определим в src/lib.rs сигнатуру функции search, как в листинге
12-13, а в теле вызовем макрос unimplemented!. Подробнее сигнатуру разберём,
когда будем писать реализацию.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search в src/lib.rsКлючевое слово pub в определении функции делает search частью публичного
API нашего библиотечного крейта. Теперь у нас есть библиотечный крейт, которым
можно пользоваться из бинарного и который можно тестировать!
Теперь нужно подключить код из src/lib.rs в область видимости бинарного крейта в src/main.rs и вызвать его, как в листинге 12-14.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --пропуск--
use minigrep::search;
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);
}
}
// --пропуск--
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
search из библиотечного крейта minigrep в src/main.rsСтрока use minigrep::search подключает функцию search из библиотечного
крейта в область видимости бинарного. Затем в функции run вместо вывода
содержимого файла мы вызываем search, передавая аргументами config.query и
contents. А run в цикле for выводит каждую строку, которую вернула
search, то есть каждую строку, совпавшую с запросом. Самое время убрать и
вызовы println! в main, которые выводили запрос и путь к файлу, — чтобы
программа выводила только результаты поиска (если не произошло ошибок).
Обратите внимание: функция поиска сначала собирает все результаты в вектор, который возвращает, и лишь потом что-то выводится. При поиске в больших файлах результаты могут появляться медленно, ведь они выводятся не по мере нахождения. Как это можно исправить с помощью итераторов, мы обсудим в главе 13.
Уф! Работы было много, но мы заложили основу для будущих успехов. Теперь обрабатывать ошибки гораздо проще, а код стал модульнее. Дальше почти вся работа будет происходить в src/lib.rs.
Воспользуемся новой модульностью и сделаем то, что со старым кодом было бы трудно, а с новым — легко: напишем тесты!