Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Щоб покращити нашу програму, ми виправимо чотири проблеми, які стосуються структури програми та того, як вона обробляє можливі помилки. По-перше, наша функція main тепер виконує дві задачі: вона розбирає аргументи та читає файли. У міру того як наша програма зростатиме, кількість окремих задач, які обробляє функція main, збільшуватиметься. Коли функція набуває більше обов’язків, її стає важче осмислювати, важче тестувати і важче змінювати, не зламавши одну з її частин. Найкраще розділяти функціональність так, щоб кожна функція відповідала за одну задачу.

Ця проблема також пов’язана з другою проблемою: хоча query і file_path є змінними конфігурації нашої програми, такі змінні, як contents, використовуються для виконання логіки програми. Чим довшою стає main, тим більше змінних нам потрібно буде вводити в область видимості; чим більше змінних у нашій області видимості, тим важче буде відстежувати призначення кожної. Найкраще згрупувати змінні конфігурації в одну структуру, щоб зробити їхнє призначення зрозумілим.

Третя проблема полягає в тому, що ми використали expect, щоб вивести повідомлення про помилку, коли читання файлу завершується невдачею, але повідомлення про помилку просто виводить Should have been able to read the file. Читання файлу може завершитися невдачею з кількох причин: наприклад, файл може бути відсутнім, або в нас може не бути дозволу на його відкриття. Зараз, незалежно від ситуації, ми б виводили одне й те саме повідомлення про помилку для всього, що не дало б користувачеві жодної інформації!

По-четверте, ми використовуємо expect для обробки помилки, і якщо користувач запустить нашу програму, не вказавши достатньо аргументів, він отримає помилку index out of bounds від Rust, яка не пояснює проблему зрозуміло. Було б найкраще, якби весь код обробки помилок був в одному місці, щоб майбутнім підтримувачам доводилося звертатися лише до одного місця в коді, якщо логіку обробки помилок потрібно буде змінити. Наявність усього коду обробки помилок в одному місці також гарантуватиме, що ми виводимо повідомлення, які будуть зрозумілими для наших кінцевих користувачів.

Давайте розв’яжемо ці чотири проблеми, відрефакторивши наш проєкт.

Розділення відповідальностей у бінарних (binary) проєктах

Організаційна проблема розподілу відповідальності за кілька задач між функцією main є поширеною для багатьох бінарних (binary) проєктів. У результаті багато програмістів Rust вважають корисним розділяти окремі відповідальності бінарної (binary) програми, коли функція main починає ставати великою. Цей процес має такі кроки:

  • Розбийте вашу програму на файл main.rs і файл lib.rs та перенесіть логіку вашої програми до lib.rs.
  • Поки ваша логіка розбору командного рядка невелика, вона може залишатися у функції main.
  • Коли логіка розбору командного рядка починає ускладнюватися, виділіть її з функції main в інші функції або типи.

Обов’язки, які залишаються у функції main після цього процесу, мають бути обмежені такими:

  • Виклик логіки розбору командного рядка з переданими значеннями аргументів
  • Налаштування будь-якої іншої конфігурації
  • Виклик функції run у lib.rs
  • Обробка помилки, якщо run повертає помилку

Цей шаблон стосується розділення відповідальностей: main.rs обробляє запуск програми, а lib.rs обробляє всю логіку поточної задачі. Оскільки ви не можете напряму тестувати функцію main, ця структура дає змогу вам тестувати всю логіку вашої програми, виносячи її з функції main. Код, який залишиться у функції main, буде достатньо малим, щоб перевірити його правильність, прочитавши його. Давайте переробимо нашу програму, дотримуючись цього процесу.

Вилучення розбору аргументів

Ми вилучимо функціональність розбору аргументів у функцію, яку викликатиме main. Список (Listing) 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)
}

Ми все ще збираємо аргументи командного рядка у вектор, але замість того, щоб присвоювати значення аргументу з індексом 1 змінній query і значення аргументу з індексом 2 змінній file_path у функції main, ми передаємо весь вектор у функцію parse_config. Потім функція parse_config містить логіку, яка визначає, який аргумент у яку змінну потрапляє, і повертає значення назад до main. Ми все ще створюємо змінні query і file_path у main, але main більше не має відповідальності за визначення того, як співвідносяться аргументи командного рядка та змінні.

Ця переробка може здатися надмірною для нашої малої програми, але ми рефакторимо малими, поступовими кроками. Після внесення цієї зміни запустіть програму знову, щоб перевірити, що розбір аргументів усе ще працює. Добре часто перевіряти свій прогрес, щоб допомогти визначити причину проблем, коли вони виникають.

Групування значень конфігурації

Ми можемо зробити ще один маленький крок, щоб далі покращити функцію parse_config. На цей момент ми повертаємо кортеж, але потім одразу знову розбиваємо цей кортеж на окремі частини. Це ознака того, що, можливо, у нас ще немає правильної абстракції.

Інший показник, який свідчить, що є простір для покращення, — це частина config у parse_config, яка припускає, що два значення, які ми повертаємо, пов’язані між собою і є частиною одного значення конфігурації. Наразі ми не передаємо це значення у структурі даних, окрім як групуючи два значення в кортеж; натомість ми помістимо два значення в одну структуру і дамо кожному полю структури змістовну назву. Це полегшить майбутнім підтримувачам цього коду розуміння того, як різні значення пов’язані одне з одним і яке їхнє призначення.

Список(Listing) 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 }
}

Ми додали структуру під назвою Config, визначену так, щоб мати поля з назвами query і file_path. Підпис parse_config тепер вказує, що вона повертає значення Config. У тілі parse_config, де ми раніше повертали рядкові зрізи, які посилаються на значення String у args, тепер ми визначаємо Config як такий, що містить власні значення String. Змінна args у main є власником значень аргументів і лише дозволяє функції parse_config запозичити їх, що означає, що ми порушили б правила запозичення Rust, якби Config спробував узяти власність над значеннями в args.

Є кілька способів, якими ми могли б керувати даними String; найпростіший, хоча й дещо неефективний, шлях — викликати метод clone для значень. Це створить повну копію даних, якою володітиме екземпляр Config, що потребує більше часу та пам’яті, ніж зберігання посилання на дані рядка. Однак клонування даних також робить наш код дуже прямолінійним, оскільки нам не потрібно керувати часами життя посилань; за цих обставин пожертвувати трохи продуктивності заради простоти є виправданим компромісом.

Компроміси використання clone

Серед багатьох растацеанців (Rustaceans) є тенденція уникати використання 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. Список (Listing) 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 }
    }
}

Ми оновили 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 — це повідомлення про помилку, призначене для програмістів. Воно не допоможе нашим кінцевим користувачам зрозуміти, що їм слід зробити натомість. Давайте виправимо це зараз.

Покращення повідомлення про помилку

У списку (Listing) 12-8 ми додаємо перевірку у функцію new, яка перевірятиме, що зріз (slice) досить довгий, перш ніж звертатися до індексу 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

Цей вивід кращий: тепер у нас є розумне повідомлення про помилку. Однак у нас також є зайва інформація, яку ми не хочемо показувати нашим користувачам. Можливо, техніка, яку ми використали в списку (Listing) 9-13, не є найкращою для цього випадку: виклик panic! більше підходить для проблеми програмування, ніж для проблеми використання, як обговорювалося в Розділі 9. Натомість ми використаємо іншу техніку, про яку ви дізналися в Розділі 9 — повернення Result, що вказує або на успіх, або на помилку.

Повернення Result замість виклику panic!

Натомість ми можемо повертати значення Result, яке міститиме екземпляр Config у випадку успіху та описуватиме проблему у випадку помилки. Ми також збираємося змінити назву функції з new на build, тому що багато програмістів очікують, що функції new ніколи не зазнають невдачі. Коли Config::build передає інформацію до main, ми можемо використовувати тип Result, щоб сигналізувати, що виникла проблема. Потім ми можемо змінити main, щоб перетворювати варіант Err на більш практичну помилку для наших користувачів без навколишнього тексту про thread 'main' і RUST_BACKTRACE, який спричиняє виклик panic!.

Список (Listing) 12-9 показує зміни, які нам потрібно внести в значення, що повертається, функції, яку ми тепер називаємо Config::build, і в тіло функції, потрібне для повернення Result. Зверніть увагу, що це не скомпілюється, доки ми також не оновимо main, що ми зробимо в наступному списку (Listing).

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.

Ми внесли дві зміни в тіло функції: замість того, щоб викликати panic!, коли користувач не передає достатньо аргументів, ми тепер повертаємо значення Err, і ми обгорнули значення, що повертається Config, у Ok. Ці зміни роблять функцію такою, що відповідає її новому підпису типу.

Повернення значення Err з Config::build дає змогу функції main обробляти значення Result, повернуте з функції build, і завершувати процес більш чисто у випадку помилки.

Виклик Config::build та обробка помилок

Щоб обробити випадок помилки та вивести зрозуміле для користувача повідомлення, нам потрібно оновити main, щоб він обробляв Result, який повертає Config::build, як показано в списку (Listing) 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 })
    }
}

У цьому списку ми використали метод, який ще не розглядали детально: 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", який ми додали в списку (Listing) 12-9, нашому замиканню в аргументі err, що з’являється між вертикальними рисками. Код у замиканні потім може використовувати значення err, коли він виконується.

Ми додали новий рядок use, щоб ввести process зі стандартної бібліотеки в область видимості. Код у замиканні, який буде виконано у випадку помилки, складається лише з двох рядків: ми виводимо значення err, а потім викликаємо process::exit. Функція process::exit негайно зупинить програму і поверне число, яке було передано як код статусу виходу. Це подібно до обробки на основі panic!, яку ми використали в списку (Listing) 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

Тепер, коли ми завершили рефакторинг розбору конфігурації, перейдемо до логіки програми. Як ми зазначили в «Розділення відповідальностей у бінарних (binary) проєктах», ми вилучимо функцію run, яка міститиме всю логіку, що зараз є у функції main і не пов’язана з налаштуванням конфігурації чи обробкою помилок. Коли ми закінчимо, функція main буде лаконічною і її буде легко перевірити візуально, а для всієї іншої логіки ми зможемо написати тести.

Список (Listing) 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 містить усю решту логіки з main, починаючи з читання файлу. Функція run приймає екземпляр Config як аргумент.

Повернення помилок з run

Після того як решту логіки програми відокремлено у функцію run, ми можемо покращити обробку помилок, як ми це зробили з Config::build у списку(Listing) 12-9. Замість того щоб дозволяти програмі панікувати шляхом виклику expect, функція run повертатиме Result<T, E>, коли щось піде не так. Це дасть нам змогу далі консолідувати логіку обробки помилок у main у зручний для користувача спосіб. Список (Listing) 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<(), Box<dyn Error>>. Раніше ця функція повертала unit-тип (), і ми зберігаємо його як значення, яке повертається у випадку Ok.

Для типу помилки ми використали трейт-об’єкт Box<dyn Error> (і ми ввели std::error::Error в область видимості за допомогою use на початку). Ми розглядатимемо трейт-об’єкти в розділі 18. Поки що достатньо знати, що Box<dyn Error> означає, що функція повертатиме тип, який реалізує трейт Error, але нам не потрібно вказувати, який саме тип буде повернене значення. Це дає нам гнучкість повертати значення помилок, які можуть бути різних типів у різних випадках помилок. Ключове слово dyn є скороченням від dynamic.

По-друге, ми вилучили виклик expect на користь оператора ?, як ми обговорювали в розділі 9. Замість того щоб викликати panic! при помилці, ? поверне значення помилки з поточної функції, щоб викликач міг його обробити.

По-третє, функція run тепер повертає значення Ok у випадку успіху. Ми оголосили успішний тип функції run як () у підписі, що означає, що нам потрібно обгорнути значення unit-типу у значення 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 у списку (Listing) 12-10, але з невеликою різницею:

Filename: 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 однакові в обох випадках: ми виводимо помилку і завершуємо роботу.

Розбиття коду на бібліотечний крейт (library crate)

Наш проєкт minigrep виглядає добре! Тепер ми розділимо файл src/main.rs та помістимо частину коду у файл src/lib.rs. Таким чином ми зможемо тестувати код і матимемо файл src/main.rs з меншою кількістю обов’язків.

Давайте визначимо код, відповідальний за пошук тексту, у src/lib.rs, а не в src/main.rs, що дасть змогу нам (або будь-кому іншому, хто використовує наш бібліотечний крейт (library crate) minigrep) викликати функцію пошуку з більшої кількості контекстів, ніж наш бінарний (binary) minigrep.

Спочатку давайте визначимо підпис функції search у src/lib.rs, як показано в списку (Listing) 12-13, з тілом, яке викликає макрос unimplemented!. Ми докладніше пояснимо підпис, коли реалізуємо його.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

Ми використали ключове слово pub у визначенні функції, щоб позначити search як частину публічного API нашого бібліотечного крейту (library crate). Тепер у нас є бібліотечний крейт (library crate), який ми можемо використовувати з нашого бінарного крейту (binary crate) та який ми можемо тестувати!

Тепер нам потрібно ввести код, визначений у src/lib.rs, в область видимості бінарного крейту (binary crate) в src/main.rs і викликати його, як показано в списку (Listing) 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(())
}

Ми додаємо рядок use minigrep::search, щоб ввести функцію search з бібліотечного крейту в область видимості бінарного крейту. Потім, у функції run, замість того щоб виводити вміст файлу, ми викликаємо функцію search і передаємо значення config.query і contents як аргументи. Далі run використовуватиме цикл for, щоб вивести кожен рядок, повернений search, який відповідав запиту. Це також вдалий момент, щоб видалити виклики println! у функції main, які показували запит і шлях до файлу, щоб наша програма виводила лише результати пошуку (якщо не виникає помилок).

Зверніть увагу, що функція пошуку збиратиме всі результати у вектор, який повертає, ще до того, як відбудеться будь-який вивід. Така реалізація може повільно показувати результати під час пошуку у великих файлах, тому що результати не виводяться в міру їх знаходження; можливий спосіб виправити це за допомогою ітераторів ми обговоримо в розділі 13.

Фух! Це була велика робота, але ми підготували себе до успіху в майбутньому. Тепер обробляти помилки набагато простіше, і ми зробили код більш модульним. Відтепер майже вся наша робота буде у src/lib.rs.

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