Рефакторинг для улучшения модульности и обработки ошибок

Для улучшения программы мы исправим 4 имеющихся проблемы, связанных со структурой программы и тем как обрабатываются потенциальные ошибки. Во-первых, функция 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.

Файл: 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, теперь мы определяем 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 показывает изменения, которые мы должны сделать.

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

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

Файл: 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 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, что мы и сделаем в следующем листинге.

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

Мы внесли два изменения в тело функции build: вместо вызова 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 сокращение для слова «динамический».

Во-вторых, мы убрали вызов 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 [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust говорит, что наш код проигнорировал Result значение и значение Result может указывать на то, что произошла ошибка. Но мы не проверяем, была ли ошибка и компилятор напоминает нам, что мы, вероятно, хотели здесь выполнить некоторый код обработки ошибок! Давайте исправим эту проблему сейчас.

Обработка ошибок, возвращённых из run в main

Мы будем проверять и обрабатывать ошибки используя методику, аналогичную той, которую мы использовали для Config::build в листинге 12-10, но с небольшой разницей:

Файл: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Мы используем if let вместо unwrap_or_else чтобы проверить, возвращает ли run значение Err и вызывается process::exit(1), если это так. Функция run не возвращает значение, которое мы хотим развернуть методом unwrap, таким же образом как Config::build возвращает экземпляр Config. Так как run возвращает () в случае успеха и мы заботимся только об обнаружении ошибки, то нам не нужно вызывать unwrap_or_else, чтобы вернуть развёрнутое значение, потому что оно будет только ().

Тело функций if let и unwrap_or_else одинаковы в обоих случаях: мы печатаем ошибку и выходим.

Разделение кода на библиотечный крейт

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

Давайте перенесём весь код не относящийся к функции 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, который мы можем протестировать!

Теперь нам нужно подключить код, который мы переместили в src/lib.rs, в область видимости бинарного крейта внутри src/main.rs, как показано в листинге 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.

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