Реструктуризация (рефакторинг, refactoring) кода для улучшения его модульности и получение возможности отслеживания возможных ошибок в работе программы

Наше текущее решение имеет 4 проблемы, которые мы будем устранять. Решать эти задачи будем путём реструктуризации кода.

Во-первых, функция main на данный момент решает две задачи - анализирует переменные командной строки и открывает файлы. Для нашей небольшой программы это не является проблемой, но при увеличении функционала её будет всё труднее и труднее читать и отлаживать. Наилучшим решением будет разделение функционала на несколько частей.

Вторая проблема - это переменные которые хранят данные командной строки. Наилучшим решением было бы сгруппировать их в структуру.

Третья проблема - это отслеживание возможных ошибок. Метод expect сообщает одну из возможных причин проблем при открытии файла, а их может быть намного больше.

Четвёртая проблема связана с распределенностью кода, отслеживающий ошибки и отсутствия такового в тех местах, где потенциальная ошибка может быть. Необходима централизовано отслеживать ошибки и корректно реагировать, сообщая пользователем достоверную информацию о причинах ошибки.

Приступим к рефакторинг!

Рекомендации по распределению исходного кода

Накопленные сообществом Rust опыт позволил составить рекомендации по распределению исходного кода в бинарных проектах. Рекомендации представляются в виде последовательности шагов:

  • Резделите код программы на два файла main.rs и lib.rs. Перенесите всю логику работы программы в файл lib.rs.
  • Т.к. код необходимый для анализа переменных командной строки достаточно мал, его можно оставть в функции main.rs.
  • Если же код анализа переменной командной строки усложняется - можно перенести его в файл lib.rs.
  • Содержание функционала в main можно ограничить следующими задачами:
    • анализ переменных командной строки,
    • установка конфигурационных настроек приложения,
    • вызов функции run из файлаlib.rs,
    • Если функция run возвращает ошибку - обработать данную ошибку.

Данные рекомендации - логичное типовое решения по разделению функционала: в файле main.rs содержит код запуска программы, в файле lib.rs содержится вся логика работы программы. Т.к. в силу конвенция проекта Cargo нельзя производить тестирование функции main, весь код, который необходимо протестировать перенести в библиотечные файлы исходного кода (в функцию lib.rs и модули (при необходимости)). Код, который находится в функции main файла main.rs должен быть максимально компактным и понятным, не требующей специального тестирования для проверки своей корректности. Далее, мы реализуем рефакторинг, следуя этим рекомендациям.

Группировка функционала анализа переменных командной строки

Прежде всего, мы сгруппируем функционал анализа переменных командной строки и будем хранить его в файле src/lib.rs. Код 12-5 Демонстрирует первое приближение решение поставленной задачи. Код анализа функционала анализа переменных командной строки перенесён в функцию parse_config внутри файла src/main.rs:

Filename: src/main.rs

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

    let (query, filename) = parse_config(&args);

    // ...snip...
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

Код 12-5: Создание функции parse_config для чтения данных из косоли в переменные (в кортеж) и её вызов в функции main

Мы всё ещё храним данные из командной строке в векторе, наш код стал более осмысленным (вместо неинформативного индекса массива мы уже используем переменную query, название которой описывает содержание данных. Переменная filename также описывает свое содержание. Функция parse_config получает на вход вектор, содержащий все переменные командной строки. Функция parse_config содержит логику выборки данных из вектора, сопоставляя ячейки вектора с переменными.

Эта функция кажется избыточное для нашей маленькой программки, но такова уж особенность рефакторинга. Рефакторинг - это постепенное, многоэтапное улучшение структуры кода.

Группировка конфигурационных переменных

Далее, мы продолжим наши улучшения. На данные момент мы создали функцию возвращающую кортеж, но далее он превращается в переменные.

Следующий индикатор улучшений для рефакторинга - группа связанных переменных. Было бы удобно использовать структуру для группировки.

Обратите внимание: некоторые разработчики называют это решение (одержимостью примитивными данными primitive obsession) - это анти-шаблон использования примитивных значений, в то время как использование структур было бы более информативным решением.

Код (12-6) показывает код нашего очередного улучшения. Создаём структуру Config. Внутри создаём поля query и filename соответственно. Мы также изменяем содержание и тип выходных данные метода parse_config. Связи с этими изменениями места хранения переменных, переписываем код обращения к данным этих переменных:

Filename: src/main.rs

use std::env;
use std::fs::File;

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

    let config = parse_config(&args);

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

    let mut f = File::open(config.filename).expect("file not found");

    // ...snip...
}

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

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

Код 12-6: Рефакторинг функции parse_config. Теперь функция возвращает экземпляр структуры Config

Мы изменили описание функции parse_config. Теперь она возвращает экземпляр структуры Config. Обратите внимание, что типы полей структуры - это экземпляры String. Таким образом мы разорвали связь между входными данными и данными хранящимися в экземпляре структуры Config. Такое решение оправдано правилами заимствования. Чтобы их не нарушать - для экземпляра Config нужны свои контейнеры текстовых данных.

Операция клонирование считается не эффективной с точки зрения использования памяти. В тоже время у неё есть ясные плюсы так как отсутствие необходимости следить за временем жизни переменных. Клонирование также делает код более понятным для чтения.

Компромиссы при использовании метода clone

Существует тенденция в среде программистов Rust отказа от использования clone, т.к. это понижает эффективность работы кода. В Главе 13, в той части которая посвящена итераторам, вы изучите более эффективные методы, которые могут подойти в подобной ситуации. Сейчас же использование метода удобно и оправдано clone. Намного важнее иметь рабочую программу, пусть даже с немного неэффективна. program Становясь всё более опытным программистом Rust вам будет всё проще и проще выбрать наилучшее решение.

Мы обновили код метода main. Мы добавили создание нового экземпляра структуры Config, как возвращаемое значение функции parse_config в переменную config. Теперь переменные query и filename являются полями структуры Config и имеют тип данных String.

Мы реализовали логическое объединение переменных query и filename. Теперь код стал более понятным и удобным в использовании.

Создание конструктора для структуры Config

Мы провели рефакторинг кода, перенеся функционал считывания переменных в функцию parse_config и реализовали группировку переменных в экземпляр структуры Config. Главная задача функции этой функции стало создание экземпляра структуры Config. Осозав это мы можем переименовать функцияю в new и связать её с создаваемой структурой. Реализовав это мы сделаем ещё один шаг в сторону улучшения нашего кода. Такой подход будет соответствовать принятым в стандартной библиотеки конвенциям создания экземпляра структуры, такой как, например, String. С помощью функции String::new создаётся новый экземпляр этой структуры. Реализовал этот рефакторинг мы сможем вызвать переименованную написав Config::new.Код 12-7 демонстрирует, как это можно сделать:

Filename: src/main.rs

 use std::env;

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

    let config = Config::new(&args);

    // ...snip...
}

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

// ...snip...

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

Listing 12-7: Трансформация функции parse_config в функцию структуры Config::new

Мы изменили код в инициализации переменной config. Вместо функции parse_config вызываем функция структуру Config::new. Обратите внимание на местонахождение функции new. Она находится в блоке impl. Пожалуйста, проверьте, как работаёт новая версия нашей программы!

Проблема обработки ошибок и пути её решения

Вы наверное обратили внимание, что пока корректность работы нашей программы очень сильно зависит от количества введённых входных данных. Если сделать ошибку при вводе данных (например, не ввести ничего или ввести недостаточно данных) программа завершиться ошибкой:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1
but the index is 1', /stable-dist-rustc/build/src/libcollections/vec.rs:1307
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Хуже того, описание ошибки не будет понятно пользователю программы, не знающего особенностей её реализации. Приступим к улучшению нашей программы в этой её части!

Реализация корректного сообщение об ошибке

В коде программы 12-8 мы добавим проверку входных данных в функцию new. Если длинна среза меньше 3, произойдет генерация контролируемой ошибки с помощью макроса panic!, который сообщит причину ошибки. Описание будет достаточно информативным для пользователей программы:

Filename: src/main.rs

// ...snip...
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    // ...snip...

Код программы 12-8: Добавление проверки количества аргументов входных даных программы

Это решение мы уже использовали в коде программы 9-8, где в функции Guess::new производили проверку значения аргумента value и вызывали макрос panic!, если он не соответствовал критериям проверки. В этом коде мы решили проверить длину среза. Если это значение меньше 3, то условие проверки будет выполнено и ошибка будет найдена, программа в этом месте остановит ход своего выполнения.

Пожалуйста, проверьте как срабатывает ошибка при вводе недостаточного количества аргументов командной строки!

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:29
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Такое сообщение об ошибке будет ясно для пользователя программы не знакомым с исходным кодом. Всё же данное решение не совсем корректно, т.к. panic! лучше применять по назначению - для обработки программных ошибок нежели ошибок ввода. Лучшим решением было бы использование перечисления Result, которое мы изучали в Главе 9 - возврат результата или ошибки и её описание.

Замена возвращаемого типа данных функции new. Использование перечисления Result

Продолжим наши улучшения. Реализуем возврат значения перечисления Result в методе new. Теперь при вызове из метода main функции Config::new мы может получить различную информацию - результат успешной работы или описание ошибки. Имея эти данные мы можем, например, преобразовать значение Err в удобное для использования в логике нашей программы действие (без использования макроса panic!).

Код программы 12-9 показывает изменённый код:

Filename: src/main.rs

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

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

        Ok(Config { query, filename })
    }
}

Код 12-9: возврат значения перечисленияResult из функции Config::new

Функция new теперь возвращает Result с экземпляром структуры Config при корректном количестве входных данных или Err c &'static str. Пожалуйста, обратитесь ещё к информации в Главе 10, рассказывающей о статической переменной времени жизни - таков тип строковых литералов. И этот тип данные будет находится, как значение внутри Err.

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

Обратите внимание, что возвращение из функции описания ошибки Err даёт возможность реализовать логику программы таким образом, чтобы не завершать работы всей программы.

Вызов функции Config::new и обработка возвращаемых значений

Для обработки возвращаемого значения обновлённой функции new в коде функции main необходимо сделать изменения. Код программы 12-10 демонстрирует эти изменения. Обратите внимание, что в мы реализовали завершение программы специальным образом, для того чтобы передать процессу вызвавшей нашу программ, что произошла ошибка.

Filename: src/main.rs

use std::process;

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

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

    // ...snip...

Код программы 12-10: Завершение программы с кодом ошибки, если при создании вызове функции Config::new произошла ошибка

Обратите, пожалуйста, внимание, что в этом коде программы мы использовали метод, который мы ещё Вам не объясняли: unwrap_or_else, который определён в перечислении Result<T, E> стандартной библиотеки. Использование unwrap_or_else позволяет удобное решение обработки ошибки без использования макроса panic! (non-panic! error handling). Если значение перечисления Result будет Ok, то этот метод ведёт себя также как и unwrap - он возвращает внутреннее значение Ok. Если же значением перечисления является Err, срабатывает код замыкания closure, которое является анонимной функцией (мы поговорим об этом аспекте языка Rust в Главе 13). Сейчас вам нужно знать, что внутреннее значение Err передаётся в аргумент err, который расположен между вертикальными линиями. Анонимная функция может иметь доступ к данной переменной она используется внутри блока.

Также обратите внимание, что мы импортировали модуль process из стандартной библиотеки. Код внутри анонимной функции состоит из двух строк: печать на консоль описания ошибки и вызов функции process::exit. Функция process::exit прекращает работу программы и возвращает номер ошибки (в данном случае 1). Это решение напоминает вызов макроса panic!, но отличается от него возвращение значения кода ошибки. Пожалуйста, проверьте работу программы после данного рефакторинга!

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

Превосходно! Такая работа программы наиболее дружелюбна для пользователей и достаточно информативна.

Продолжаем рефакторинг функции main

После того, как мы реализовали обработку входных данных, преступим к улучшению логики нашей программы. Целью нашего улучшения кода будет перенесение кода отвечающего за логику работы нашей программы в функцию run (Как было описана в секции этой главы "Концепция разделения кода"). После реализации данного рефакторинга функция main станет проще для проверок корректности работы и у нас появится возможность тестирования функционала нашей программы.

Код 12-11 демонстрирует перенос логики программы в функцию run. Пока мы сделали первое приближение к поставленной цели, т.к. код всё ещё сосредоточен в файле src/main.rs:

Filename: src/main.rs

fn main() {
    // ...snip...

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

    run(config);
}

fn run(config: Config) {
    let mut f = File::open(config.filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents)
        .expect("something went wrong reading the file");

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

// ...snip...

Код программы 12-11: Перенос в функцию run логику программы

Теперь функция run содержит всю логику программы, начиная с чтения файла кончая выводом результата. Обратите внимание, что функция 'run' имеет входной аргумент экземпляр структуры Config.

Реакторинг функции run. Добавление возвращаемого значения.

Как мы уже знаем по опыты реализации функции new, весьма удобно, когда функция возвращает информацию о своей работе и не запускает макрос panic!. Реализуем тот же подход и в случае с функцией run, реализуем возврат значения перечисления Result<T, E>. Это позволит реализовать в вызывающей функции подходящую обработку выходных данных. Код улучшения 12-12 нашей функции run:

Filename: src/main.rs

use std::error::Error;

// ...snip...

fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

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

    Ok(())
}

Код программы12-12: Внесения изменений в функцию run. Теперь она возвращает значение перечисления function to return Result<T, E>

Обратите внимание, какие мы сделали изменения в функции. Перовое, мы описали тип возвращаемого значения данной функцией - Result<(), Box<Error>>. Ранее функция просто возвращала неявным образом пустой кортеж (). Сейчас же мы указали это явно, а также описали формат сообщения об ошибке Box.

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

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

Третье изменение - это добавление возвращаемого значения - это значение Ok причисления Result. В результате значение Ok будет содержать в себе пустой кортеж (). Данное решение ещё называют "заглушкой" - синтаксическая конструкция не несущая в себе каких-либо данных - просто индикатор, соблюдающиеся необходимы конвенции.

Если вы запустите данный код на выполнение, то увидите предупреждающее сообщение:

warning: unused result which must be used, #[warn(unused_must_use)] on by
default
  --> src/main.rs:39:5
   |
39 |     run(config);
   |     ^^^^^^^^^^^^

Данное сообщение говорить об игнорировании результатов работы функции. Такое поведение трактуется как подозрительное, требующее к себе дополнительного внимания, предпосылка к ошибке. Мы исправим наш код в следующем разделе на следующем этапе рефакторинга.

Исправление замечания компилятора

Мы решим эту задачу также, как и в случае функции Config::new (12-10), но с небольшими отличиями:

Filename: src/main.rs

fn main() {
    // ...snip...

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

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

        process::exit(1);
    }
}

Мы используем синтаксическую конструкцию if let для проверки возвращаемого значения функцией run. Т.к. run не возвращает информация для анализ (это может быть только информации об ошибке) её та мы и будем анализировать.

Содержание блока кода if let такое же как и функции unwrap_or_else: печатаем информацию об ошибке и заканчиваем работу.

Разеделение кода внутри библиотечного пакета(a Library Crate)

Продолжаем наш рефакторинг. Теперь приступим к переносу кода программы в файл библиотеки нашего пакета (src/lib.rs). Далее будем тестировать наш код.

Итак, что мы переносим в файл src/lib.rs:

Let’s move everything that isn’t the main function from src/main.rs to a new file, src/lib.rs:

  • Определение функции run.
  • Необходимый импорт в строки use.
  • Определение структуры Config.
  • Определение функции Config::new.

Содержание файла src/lib.rs должно иметь следующее содержание (12-13) (для краткости некоторые строчки кода опущены):

Filename: src/lib.rs

use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        // ...snip...
    }
}

pub fn run(config: Config) -> Result<(), Box<Error>> {
    // ...snip...
}

Код программы 12-13: Перемещение определения структуры Config и функции run в файл src/lib.rs

Мы добавили спецификатор доступа pub к структуре Config, а также её полям, к методу new и функции run. Теперь у нас есть API, функционал которой мы сможем протестировать.

Теперь нам нужно добавить строку extern crate minigrep. Далее мы добавляем строку use minigrep::Config в область видимость и префикс к функции run. Код программы 12-14:

Filename: src/main.rs

extern crate minigrep;

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // ...snip...
    if let Err(e) = minigrep::run(config) {
        // ...snip...
    }
}

Код программы 12-14: Подключение пакета minigrep в область src/main.rs

Запустим команду cargo run test poem.txt и проверим работу пакета.

Работает! Ура! Мы проделали большую работу. Такую программу легко отлаживать и сделать код модульным.

Теперь можно приступать к тестированию созданного нами пакета.