Рефакторизація для покращення модульності та обробки помилок
Для покращення програми ми розв'яжемо чотири проблеми, пов’язані зі структурою програми та тим, як вона обробляє потенційні помилки. По-перше, наша функція 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.rs.
- Коли обробка логіки командного рядка починає ускладнюватись, витягніть її з main.rs і перемістіть до lib.rs.
Відповідальність коду, що залишиться в функції main
після цього, має бути обмеженою до такого:
- Виклик логіки аналізу командного рядка і передача їй значень аргументів
- Налаштування решти конфігурації
- Виклик функції
run
із lib.rs - Обробка помилок, якщо
run
поверне помилку
Цей шаблон стосується поділу інтересів: main.rs обробляє запуск програми, а lib.rs обробляє всю логіку основного завдання. Оскільки функцію main
неможливо тестувати напряму, ця структура дозволяє вам тестувати усю логіку вашої програми, перенісши її до функцій у lib.rs. Код, що залишився в main.rs буде досить маленьким, щоб перевірити його правильність, прочитавши його. Переробімо нашу програму відповідно до цього процесу.
Перенесення аналізатора аргументів
Ми перенесемо функціональність для аналізу аргументів у функцію, котру буде викликати main
, щоб підготувати переміщення логіки розбору командного рядка до src/lib. s. Блок коду 12-5 показує початок нової функції main
, яка викликає нову функцію parse_config
, котру ми скоро визначимо в src/main.rs.
Файл: 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)
}
Ми все ще збираємо аргументи командного рядка до вектора, але замість присвоювати значення аргументу з індексом 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
.
Файл: src/main.rs
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 }
}
Ми додали структуру, що зветься Config
, у якій визначили поля, що звуться query
та file_path
. Сигнатура parse_config
тепер показує, що вона повертає значення типу Config
. У тілі parse_config
, де раніше ми повертали стрічкові слайси, які посилалися на значення String
у args
, тепер ми задаємо значення String
, якими володіє Config
. Змінна args
у main
є власником значень аргументів і лише дозволяє функції parse_config
позичити їх, тобто ми б порушили правила позичання Rust якби Config
пробував взяти володіння значеннями з args
.
Є багато способів, як ми могли б керувати даними String
; найпростіший, хоча і дещо неефективний спосіб - викликати метод clone
для значень. Це зробить повну копію даних для надання володіння екземпляра Config
, що потребує більше часу і пам'яті, ніж зберігання посилання на дані стрічки. Однак клонування даних також робить наш код вкрай прямолінійним, бо нам не треба керувати часами існування посилань; за цих обставин, віддати трохи продуктивності задля спрощення є гідним компромісом.
Використання
clone
як компромісІснує тенденція, якої дотримується багато растацеанців, уникати використання
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 показує, які зміни треба зробити.
Файл: src/main.rs
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 }
}
}
Ми замінили у 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 [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
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. Якщо слайс недостатньо довгий, програма панікує і показує краще повідомлення про помилку.
Файл: src/main.rs
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
має менш ніж три елементи, ця умова буде істинною, і ми викличемо макрос panic!
, щоб негайно завершити програму.
Після додавання цих кількох рядків коду до new
знову запустімо програму без аргументів, щоб побачити, як помилка виглядатиме тепер:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
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
, що ми робимо в наступному блоці коду.
Filename: src/main.rs
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 })
}
}
Наша функція build
тепер повертає Result
з екземпляром Config
у разі успіху і &'static str
у разі помилки. Значення наших помилок завжди будуть стрічковими літералами з часом існування 'static
.
Ми зробили дві зміни у тілі функції: замість виклику panic!
, коли користувач не надав достатньо аргументів, ми тепер повертаємо значення Err
, і ми обгорнули значення Config
, що ми повертаємо, у Ok
. Ці зміни узгоджують функцію з новою сигнатурою типу.
Повертання значення Err
з Config::build
дозволяє функції main
обробити значення Result
, повернуте з функції build
, і вийти з процесу чистіше у випадку помилки.
Виклик Config::build
і обробка помилок
Щоб обробити випадок з помилкою і вивести дружнє для користувача повідомлення, нам треба змінити main
, щоб обробити Result
, повернений Config::build
, як показано у Блоці коду 12-10. Ми також візьмемо відповідальність за вихід з інструменту командного рядка з ненульовим кодом помилки з panic!
і реалізуємо його самостійно. Ненульовий статус на виході - це угода, щоб повідомити процесу, який викликав нашу програму, що програма завершилася з помилкою.
Файл: src/main.rs
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 })
}
}
У цьому блоці коду ми скористалися методом, про який ще детально не розповідали - 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 [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
Чудово! Це повідомлення набагато дружніше до наших користувачів.
Перенесення логіки з main
Тепер, коли ми закінчили рефакторизацію аналізу конфігурації, повернімося до логіки програми. Як ми казали в "Розділення зон інтересів у двійкових проєктах", ми виділимо функцію, що зветься run
, що міститиме всю логіку, наразі розміщену у функції main
, яка не бере участі у встановленні конфігурації чи обробці помилок. Коли ми закінчимо, main
стане виразним і легким для перевірки на помилки простим переглядом, і ми зможемо написати тести для решти логіки програми.
Блок коду 12-11 показує виокремлену функцію run
. Поки що, ми робимо маленькі, поступові покращення при виділенні функції. Ми все ще визначаємо цю функцію у src/main.rs.
Файл: src/main.rs
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
тепер містить решту логіки з main
, починаючи з читання файлу. Функція run
приймає аргументом екземпляр Config
.
Повертання помилок з функції run
Для решти логіки програми, виділеної в функцію run
, ми можемо покращити обробку помилок, як ми зробили з Config::build
у Блоці коду 12-9. Замість дозволяти програмі панікувати викликом expect
, функція run
повертатиме Result<T, E>
, коли щось піде не так. Це дозволить нам об'єднати логіку обробки помилок у main
у дружній для користувача спосіб. Блок коду 12-12 показує зміни, які нам треба зробити в сигнатурі і тілі run
.
Файл: src/main.rs
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<(), Box<dyn Error>>
. Ця функція раніше повертала одиничний тип, ()
, і ми залишаємо це значення у випадку Ok
.
Для типу помилок, ми використовуємо трейтовий об'єкт Box<dyn Error>
(і ми внесли std::error::Error
до області видимості за допомогою інструкції use
на початку). Ми розкажемо про трейтові об'єкти у Розділі 17. Поки що, вам достатньо знати, що 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: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished dev [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
, з невеликою відмінністю:
Файл: 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 менше відповідальності.
Перенесімо весь код, крім функції main
, з src/main.rs до src/lib.rs:
- Визначення функції
run
- Відповідні інструкції
use
- Визначення
Config
- Визначення функції
Config::build
Вміст src/lib.rs має містити сигнатури, показані в Блоці коду 12-13 (ми опустили тіла функцій для стислості). Зверніть увагу, що цей код не скомпілюється, поки ми не змінимо src/main.rs у Блоці коду 12-14.
Файл: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[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();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
// --snip--
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
Ми дещо вільно використали ключове слово pub
: для Config
, його полів і його методу build
, а також для функції run
. Тепер ми маємо бібліотечний крейт, що має публічний API, який ми можемо тестувати!
Now we need to bring the code we moved to src/lib.rs into the scope of the binary crate in src/main.rs, as shown in Listing 12-14.
Файл: src/main.rs
use std::env;
use std::process;
use minigrep::Config;
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) = minigrep::run(config) {
// --snip--
println!("Application error: {e}");
process::exit(1);
}
}
Ми додали рядок use minigrep::Config
, щоб внести тип Config
з бібліотечного крейту до області видимості двійкового крейту, і додали перед функцією run
назву нашого крейту. Тепер уся функціональність має бути з'єднана і мусить працювати. Запустіть програму за допомогою cargo run
і переконайтеся, що все працює правильно.
Хух! Добряче попрацювали, але налаштували себе на успіх у майбутньому. Тепер буде значно легше обробляти помилки, і ми зробили код більш модульним. Майже вся наша робота з цього моменту буде виконуватися в src/lib.rs.
Скористаймося з цієї новоствореної модульності, зробивши дещо, що було б складним зі старим кодом, але легко з новим: напишемо кілька тестів!