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, которая не объясняет проблему чётко. Было бы лучше, если бы весь код обработки ошибок был в одном месте, чтобы будущие сопровождающие имели только одно место для консультации с кодом, если логика обработки ошибок потребует изменений. Наличие всего кода обработки ошибок в одном месте также обеспечит, что мы выводим сообщения, которые будут понятны нашим конечным пользователям.

Давайте решим эти четыре проблемы, рефакторируя наш проект.

Разделение ответственности для бинарных проектов

Проблема организации, заключающаяся в возложении ответственности за несколько задач на функцию 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.rs. Листинг 12-5 показывает новый начало main, который вызывает новую функцию parse_config, которую мы определим в src/main.rs на данный момент.

Filename: 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)
}
Listing 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.

Filename: 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 }
}
Listing 12-6: Рефакторинг 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 показывает изменения, которые нам нужно внести.

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}");

    // --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` 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. Если срез недостаточно длинный, программа паникует и отображает лучшее сообщение об ошибке.

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 {
    // --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 }
    }
}
Listing 12-8: Добавление проверки количества аргументов

Этот код похож на функцию 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 также, что мы сделаем в следующем листинге.

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 })
    }
}
Listing 12-9: Возврат 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! и вместо этого реализуем это вручную. Ненулевой статус выхода — это соглашение для сигнализации процессу, который вызвал нашу программу, что программа завершилась с состоянием ошибки.

Filename: 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 })
    }
}
Listing 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, чтобыBring 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. Пока мы просто делаем небольшое, постепенное улучшение, извлекая функцию. Мы по-прежнему определяем функцию в src/main.rs.

Filename: 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 })
    }
}
Listing 12-11: Извлечение функции run, содержащей остальную логику программы

Функция run теперь содержит всю оставшуюся логику из main, начиная с чтения файла. Функция run принимает экземпляр Config в качестве аргумента.

Возврат ошибок из функции run

С оставшейся логикой программы, отделённой в функцию run, мы можем улучшить обработку ошибок, как мы это сделали с Config::build в Листинге 12-9. Вместо того чтобы позволить программе паниковать, вызывая expect, функция run будет возвращать Result<T, E>, когда что-то пойдёт не так. Это позволит нам дальнейшим образом консолидировать логику вокруг обработки ошибок в main в удобном для пользователя виде. Листинг 12-12 показывает изменения, которые нам нужно внести в сигнатуру и тело run.

Filename: 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 })
    }
}
Listing 12-12: Изменение функции run на возврат Result

Мы внесли три значительных изменения здесь. Во-первых, мы изменили возвращаемый тип функции run на Result<(), Box<dyn Error>>. Эта функция ранее возвращала тип единицы, (), и мы сохраняем его как значение, возвращаемое в случае Ok.

Для типа ошибки мы использовали объект типажа Box<dyn Error> (и мы привели std::error::Error в область видимости с помощью оператора use вверху). Мы рассмотрим объекты типажа в Главе 18. Пока просто знайте, что Box<dyn Error> означает, что функция вернёт тип, который реализует типаж Error, но нам не нужно указывать, какой конкретный тип будет у возвращаемого значения. Это даёт нам гибкость возвращать значения ошибок, которые могут быть разных типов в разных случаях ошибок. Ключевое слово dyn — сокращение от динамический.

Во-вторых, мы убрали вызов 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 не возвращает значение, которое мы хотим развернуть так же, как 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.

Filename: 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(())
}
Listing 12-13: Перемещение Config и run в src/lib.rs

Мы широко использовали ключевое слово pub: на Config, на его полях и его методе build, и на функции run. Теперь у нас есть библиотечный крейт с публичным API, который мы можем тестировать!

Теперь нам нужно привести код, который мы переместили в src/lib.rs, в область видимости бинарного крейта в src/main.rs, как показано в Листинге 12-14.

Filename: 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);
    }
}
Listing 12-14: Использование библиотечного крейта minigrep в src/main.rs

Мы добавляем строку use minigrep::Config, чтобыBring тип Config из библиотечного крейта в область видимости бинарного крейта, и мы добавляем префикс имени крейта к функции run. Теперь вся функциональность должна быть соединена и должна работать. Запустите программу с cargo run и убедитесь, что всё работает правильно.

Фух! Это была большая работа, но мы подготовили себя к успеху в будущем. Теперь нам гораздо проще обрабатывать ошибки, и мы сделали код более модульным. Почти вся наша работа будет выполняться в src/lib.rs с этого момента.

Давайте воспользуемся этой newfound модульностью, сделав то, что было бы трудно со старым кодом, но легко с новым кодом: мы напишем несколько тестов!