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, и если пользователь запустит программу, не передав достаточно аргументов, он получит от Rust ошибку index out of bounds, которая толком не объясняет проблему. Лучше, чтобы весь код обработки ошибок был в одном месте: тогда тем, кто будет поддерживать код, придётся заглядывать только в одно место, если логику обработки ошибок понадобится поменять. Заодно так мы точно будем выводить сообщения, понятные конечным пользователям.

Решим эти четыре проблемы, проведя рефакторинг проекта.

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

Проблема с тем, что на функцию main взвалено несколько задач, свойственна многим бинарным проектам. Поэтому многие программисты на Rust, когда main начинает разрастаться, разделяют обязанности бинарной программы. Делается это так:

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

После этого у функции main должны остаться только такие обязанности:

  • вызвать логику разбора командной строки со значениями аргументов;
  • выполнить прочую настройку;
  • вызвать функцию run из lib.rs;
  • обработать ошибку, если run её вернула.

Суть этого шаблона — разделение ответственности: main.rs отвечает за запуск программы, а lib.rs — за всю логику решаемой задачи. Функцию main нельзя протестировать напрямую, а при такой структуре вся логика программы вынесена из main, и её можно тестировать. Код, оставшийся в main, будет настолько маленьким, что в его правильности можно убедиться, просто прочитав его. Переделаем нашу программу по этой схеме.

Выносим разбор аргументов

Вынесем разбор аргументов в функцию, которую будет вызывать main. В листинге 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);

    // --пропуск--

    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: Выносим из main функцию parse_config

Мы по-прежнему собираем аргументы командной строки в вектор. Но теперь main не раскладывает их сама (аргумент с индексом 1 — в query, с индексом 2 — в file_path), а передаёт весь вектор в функцию 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");

    // --пропуск--

    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 их позаимствовать. Значит, если бы Config попыталась забрать владение значениями из args, мы нарушили бы правила заимствования Rust.

Управлять данными String можно по-разному. Самый простой, хоть и не самый эффективный путь — вызвать у значений метод clone. Так Config получит собственную полную копию данных, что требует больше времени и памяти, чем хранение ссылки на строку. Зато клонирование делает код очень простым: не нужно управлять временами жизни ссылок. В данной ситуации пожертвовать небольшой долей производительности ради простоты — разумный компромисс.

Цена clone

Многие растацеаны стараются не решать проблемы владения через clone из-за его цены во время выполнения. В главе 13 вы узнаете более эффективные способы для таких ситуаций. Но сейчас вполне можно скопировать пару строк, чтобы двигаться дальше: копирование происходит лишь один раз, а путь к файлу и строка запроса очень маленькие. Лучше рабочая, хоть и не самая эффективная программа, чем попытка сверхоптимизировать код с первого захода. С опытом вам будет проще сразу писать самое эффективное решение, а пока вызывать 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, её можно превратить из обычной функции в функцию 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}");

    // --пропуск--
}

// --пропуск--

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` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' (6023615) 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 {
    // --пропуск--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --пропуск--

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

        Config { query, file_path }
    }
}
Листинг 12-8: Добавляем проверку количества аргументов

Этот код похож на функцию Guess::new из листинга 9-13, где мы вызывали panic!, если аргумент value выходил за пределы допустимых значений. Только здесь мы проверяем не диапазон значений, а длину args: в нём должно быть хотя бы три элемента, и остальная часть функции может на это рассчитывать. Если элементов меньше трёх, условие в if истинно, и мы вызываем макрос 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' (6023776) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Так лучше: теперь сообщение об ошибке осмысленное. Но в выводе есть и лишняя информация, которую пользователям показывать не нужно. Возможно, приём из листинга 9-13 здесь не лучший: как обсуждалось в главе 9, panic! больше подходит для ошибок программирования, чем для ошибок использования. Вместо этого воспользуемся другим приёмом из главы 9 — вернём Result, который означает либо успех, либо ошибку.

Возвращаем Result вместо вызова panic!

Можно возвращать значение Result, в котором при успехе будет экземпляр Config, а при ошибке — описание проблемы. Заодно переименуем функцию из new в build: многие программисты ожидают, что функции new никогда не завершаются неудачей. Через тип Result функция Config::build сможет сообщить main, что возникла проблема. А 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: Config::build возвращает Result

Функция build возвращает Result: при успехе — экземпляр Config, при ошибке — строковый литерал. Значениями ошибок у нас всегда будут строковые литералы со временем жизни 'static.

В теле функции мы сделали два изменения: вместо вызова panic!, когда пользователь передал недостаточно аргументов, теперь возвращается значение Err, а возвращаемое значение Config мы обернули в Ok. С этими изменениями функция соответствует своей новой сигнатуре.

Теперь, когда Config::build умеет возвращать Err, функция 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);
    });

    // --пропуск--

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

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

// --пропуск--

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;

// --пропуск--


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 в начале файла). О трейт-объектах мы поговорим в главе 18. Пока достаточно знать, что Box<dyn Error> означает: функция вернёт тип, реализующий трейт Error, но какой именно — указывать не нужно. Это даёт гибкость: в разных ситуациях можно возвращать ошибки разных типов. Ключевое слово dyn — сокращение от dynamic (динамический).

Во-вторых, вместо вызова expect теперь оператор ?, о котором мы говорили в главе 9. Вместо паники при ошибке ? вернёт значение ошибки из текущей функции, и её обработает вызывающий код.

В-третьих, при успехе функция 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)]` (part of `#[warn(unused)]`) 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, а это значение может означать, что произошла ошибка. Мы не проверяем, была ли ошибка, и компилятор напоминает, что здесь, скорее всего, должен быть код обработки ошибок! Исправим это.

Обрабатываем в main ошибки из run

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

Файл: src/main.rs

use std::env;
use std::error::Error;
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);
    });

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

Здесь вместо unwrap_or_else используется if let: он проверяет, вернула ли run значение Err, и если да — вызывает process::exit(1). В отличие от Config::build, которая возвращает экземпляр Config, run не возвращает значения, которое нужно было бы «развернуть» (unwrap). При успехе run возвращает (), так что нас интересует только обнаружение ошибки, и unwrap_or_else, возвращающий развёрнутое значение (а это был бы просто ()), не нужен.

Тела if let и unwrap_or_else в обоих случаях одинаковы: выводим ошибку и завершаемся.

Выносим код в библиотечный крейт

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

Определим код поиска текста в src/lib.rs, а не в src/main.rs: тогда мы (или кто угодно, кто пользуется нашей библиотекой minigrep) сможем вызывать функцию поиска не только из бинарника minigrep.

Сначала определим в src/lib.rs сигнатуру функции search, как в листинге 12-13, а в теле вызовем макрос unimplemented!. Подробнее сигнатуру разберём, когда будем писать реализацию.

Файл: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Листинг 12-13: Определяем функцию search в src/lib.rs

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

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

Файл: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --пропуск--
use minigrep::search;

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

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

// --пропуск--


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(())
}
Листинг 12-14: Используем функцию search из библиотечного крейта minigrep в src/main.rs

Строка use minigrep::search подключает функцию search из библиотечного крейта в область видимости бинарного. Затем в функции run вместо вывода содержимого файла мы вызываем search, передавая аргументами config.query и contents. А run в цикле for выводит каждую строку, которую вернула search, то есть каждую строку, совпавшую с запросом. Самое время убрать и вызовы println! в main, которые выводили запрос и путь к файлу, — чтобы программа выводила только результаты поиска (если не произошло ошибок).

Обратите внимание: функция поиска сначала собирает все результаты в вектор, который возвращает, и лишь потом что-то выводится. При поиске в больших файлах результаты могут появляться медленно, ведь они выводятся не по мере нахождения. Как это можно исправить с помощью итераторов, мы обсудим в главе 13.

Уф! Работы было много, но мы заложили основу для будущих успехов. Теперь обрабатывать ошибки гораздо проще, а код стал модульнее. Дальше почти вся работа будет происходить в src/lib.rs.

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