Рефакторинг для улучшения модульности и обработки ошибок
Чтобы улучшить нашу программу, мы исправим четыре проблемы, связанные со
структурой программы и с тем, как она обрабатывает возможные ошибки. Во-первых,
сейчас функция 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);
// --snip--
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)
}
parse_config из mainМы по-прежнему собираем аргументы командной строки в вектор, но вместо того
чтобы присваивать значение аргумента с индексом 1 переменной query, а
значение аргумента с индексом 2 переменной file_path внутри функции main,
мы передаем весь вектор функции 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");
// --snip--
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 заимствовать их; это значит, что мы нарушили бы правила
заимствования Rust, если бы Config попыталась забрать владение значениями из
args.
Есть несколько способов управлять данными String; самый простой, хотя и
несколько неэффективный путь, – вызвать метод clone для этих значений. Это
сделает полную копию данных, которой будет владеть экземпляр Config, что
требует больше времени и памяти, чем хранение ссылки на строковые данные.
Однако клонирование данных также делает наш код очень простым, потому что нам
не нужно управлять временами жизни ссылок; в этой ситуации отказаться от
небольшой части производительности ради простоты – разумный компромисс.
Компромиссы использования clone
Многие разработчики Rust склонны избегать использования clone для исправления
проблем владения из-за его стоимости во время выполнения. В
главе 13 вы узнаете, как использовать более
эффективные методы в таких ситуациях. Но сейчас нормально скопировать
несколько строк, чтобы продолжать двигаться вперед, потому что вы сделаете
эти копии только один раз, а путь к файлу и строка запроса очень малы. Лучше
иметь рабочую программу, которая немного неэффективна, чем пытаться
гипероптимизировать код при первом проходе. Когда вы станете опытнее в Rust,
будет проще сразу начинать с самого эффективного решения, но пока совершенно
допустимо вызвать 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, мы можем превратить parse_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}");
// --snip--
}
// --snip--
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' 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 {
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Этот код похож на функцию Guess::new, которую мы написали в листинге
9-13, где мы вызывали panic!, когда
аргумент value выходил за диапазон допустимых значений. Здесь, вместо
проверки диапазона значений, мы проверяем, что длина args не меньше 3, и
оставшаяся часть функции может работать, исходя из предположения, что это
условие выполнено. Если в args меньше трех элементов, это условие будет
true, и мы вызываем макрос 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' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Этот вывод лучше: теперь у нас есть разумное сообщение об ошибке. Однако в нем
также есть лишняя информация, которую мы не хотим показывать пользователям.
Возможно, прием, который мы использовали в листинге 9-13, не лучший для этого
случая: вызов panic! больше подходит для проблемы в программе, чем для
проблемы использования, как обсуждалось в главе 9.
Вместо этого мы используем другой прием, о котором вы узнали в главе 9, –
возврат Result, который указывает либо на
успех, либо на ошибку.
Возврат Result вместо вызова panic!
Вместо этого мы можем вернуть значение Result, которое в случае успеха будет
содержать экземпляр Config, а в случае ошибки будет описывать проблему. Мы
также изменим имя функции с new на build, потому что многие программисты
ожидают, что функции new никогда не завершаются ошибкой. Когда
Config::build взаимодействует с main, мы можем использовать тип
Result, чтобы подать сигнал о возникшей проблеме. Затем мы можем изменить
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 })
}
}
Result из Config::buildНаша функция build возвращает Result с экземпляром Config в случае успеха
и строковым литералом в случае ошибки. Наши значения ошибок всегда будут
строковыми литералами с временем жизни 'static.
Мы внесли два изменения в тело функции: вместо вызова panic!, когда
пользователь не передает достаточно аргументов, мы теперь возвращаем значение
Err, а возвращаемое значение Config обернули в Ok. Эти изменения
приводят функцию в соответствие с ее новой сигнатурой типа.
Возврат значения Err из Config::build позволяет функции 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);
});
// --snip--
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() {
// --snip--
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}");
}
// --snip--
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;
// --snip--
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. Вместо того чтобы вызывать
panic! при ошибке, ? вернет значение ошибки из текущей функции, чтобы его
обработал вызывающий код.
В-третьих, функция 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)]` 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, а значение
Result может указывать на возникшую ошибку. Но мы не проверяем, была ошибка
или нет, и компилятор напоминает, что мы, вероятно, хотели добавить сюда код
обработки ошибок! Исправим эту проблему.
Обработка ошибок, возвращенных из run, в main
Мы проверим наличие ошибок и обработаем их приемом, похожим на тот, который
использовали с Config::build в листинге 12-10, но с небольшим отличием:
Имя файла: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
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 })
}
}
Мы используем if let, а не unwrap_or_else, чтобы проверить, возвращает ли
run значение Err, и вызвать process::exit(1), если это так. Функция
run не возвращает значение, которое мы хотели бы unwrap таким же образом,
как Config::build возвращает экземпляр Config. Поскольку в случае успеха
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.
Сначала определим сигнатуру функции search в src/lib.rs, как показано в
листинге 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;
// --snip--
use minigrep::search;
fn main() {
// --snip--
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);
}
}
// --snip--
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.
Воспользуемся этой новой модульностью и сделаем то, что было бы трудно со старым кодом, но легко с новым: напишем несколько тестов!