Рефакторизація для покращення модульності та обробки помилок

Для покращення програми ми розв'яжемо чотири проблеми, пов’язані зі структурою програми та тим, як вона обробляє потенційні помилки. По-перше, наша функція 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)
}

Блок коду 12-5: Вилучення функції 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.

Файл: 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 }
}

Блок коду 12-6: Рефакторизація функції parse_config, що тепер повертає екземпляр структури Config

Ми додали структуру, що зветься 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 }
    }
}

Listing 12-7: Зміна 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 [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 }
    }
}

Блок коду 12-8: Додавання перевірки на число аргументів

Цей код подібний до функції 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 })
    }
}

Блок коду 12-9: Повертання Result з Config::build

Наша функція 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 })
    }
}

Блок коду 12-10: Вихід з кодом помилки, якщо збірка 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 [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 })
    }
}

Блок коду 12-11: Виділення функції run, що містить решту логіки програми

Функція 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 })
    }
}

Блок коду 12-12: Зміна функції run, що повертає Result

Ми зробили тут три суттєві зміни. По-перше, ми змінили тип, що повертає функція 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(())
}

Блок коду 12-13: Перенесення Config і run до src/lib.rs

Ми дещо вільно використали ключове слово 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);
    }
}

Блок коду 12-14: Використання бібліотечного крейту minigrep у src/main.rs

Ми додали рядок use minigrep::Config, щоб внести тип Config з бібліотечного крейту до області видимості двійкового крейту, і додали перед функцією run назву нашого крейту. Тепер уся функціональність має бути з'єднана і мусить працювати. Запустіть програму за допомогою cargo run і переконайтеся, що все працює правильно.

Хух! Добряче попрацювали, але налаштували себе на успіх у майбутньому. Тепер буде значно легше обробляти помилки, і ми зробили код більш модульним. Майже вся наша робота з цього моменту буде виконуватися в src/lib.rs.

Скористаймося з цієї новоствореної модульності, зробивши дещо, що було б складним зі старим кодом, але легко з новим: напишемо кілька тестів!