Программирование PC Rust: пример консольной программы ввода/вывода Mon, September 16 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: пример консольной программы ввода/вывода Печать
Добавил(а) microsin   

Эта глава (перевод главы 12 документации "The Rust Programming Language" [12]) закрепляет знания, полученные в предыдущих главах 1 .. 11, и также будут рассмотрены еще несколько функций стандартной библиотеки Rust. Мы создадим консольную утилиту, которая работает с файлом и вводом/выводом командной строки, чтобы попрактиковаться с некоторыми концепциями Rust, которые теперь есть у вас под рукой.

Скорость Rust, его безопасность, одиночный двоичный вывод, поддержка кроссплатформенности делают его идеальным языком программирования для создания инструментов командной строки. Поэтому для нашего проекта мы сделаем свою собственную версию классического инструмента поиска командной строки grep (название пошло от сокращения "Globally search a Regular Expression and Print"). В самом простом случае grep ищет указанный файл, содержащий указанную строку. Для этого утилита grep получает через свои аргументы путь до файла и строку. Затем она читает файл, ищет в нем строки, которые содержать аргумент строки, и печатает эти строки.

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

Andrew Gallant, один из участников комьюнити Rust, создал полнофункциональную, очень быструю версию grep, с именем ripgrep. Если сравнивать, то наша версия будет довольно простой, однако даст вам некоторые основные навыки, которые вам понадобятся для понимания реализации такого реального проекта, как ripgrep.

Наш grep-проект комбинирует в себе концепции, которые вы уже изучили:

• Организация кода (используется то, что вы узнали про модули в главе 7 [2]).
• Использование векторов и строк (коллекции в главе 8 [3]).
• Обработка ошибок (глава 9 [4]).
• Использование трейтов и времени жизни там, где это применимо (глава 10 [5]).
• Написание тестов (глава 11 [6]).

Мы также коротко пробежимся по closures, iterators и trait-объектам, что более подробно будет рассматриваться в главах 13 и 17.

[Обработка аргументов командной строки]

Давайте создадим новый проект, как и всегда, командой cargo new. Назовем наш проект minigrep, чтобы отличать его от утилиты grep, которая вероятно может быть установлена в вашей системе.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

Первое, что нужно сделать в minigrep - принять её два аргумента командной строки: имя файла (путь) и строку, которую нужно искать в файле. Т. е. мы хотим иметь возможность запускать нашу программу через cargo run, вводя аргументы для разрабатываемой нашей программы через два дефиса:

$ cargo run -- searchstring example-filename.txt

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

Чтение значений аргументов. Чтобы minigrep могла прочитать значения аргументов командной строки, которые мы ей передали, нам нужна функция std::env::args из стандартной библиотеки Rust. Эта функция возвратит итератор аргументов командной строки, переданных в minigrep. Мы рассмотрим итераторы полностью в главе 13. Пока все, что нам надо знать по поводу итераторов, это 2 детали: итераторы формируют последовательность значений, и мы можем вызвать метод collect на итераторе, чтобы превратить его в коллекцию, такую как вектор, которая содержит все элементы, сформированные итератором.

Код в листинге 12-1 позволяет вашей программе minigrep прочитать любые аргументы командной строки, которые ей переданы, и затем собрать их значения в вектор.

use std::env;

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

Листинг 12-1. Аргументы командной строки собираются в вектор и печатаются (файл src/main.rs).

Сначала мы приводим модуль std::env в область действия оператором use, после чего мы можем использовать его функцию args. Обратите внимание, что функция std::env::args вложена в 2 уровня модулей. Как мы уже обсуждали в главе 7 [2], в случаях, когда желаемая функция вложена в более чем один модуль, мы выбираем приведение родительского модуля в область действия вместо функции. Таким способом мы можем проще использовать другие функции из std::env. Это также менее неоднозначно, чем добавление use std::env::args, и затем вызова функции просто по имени args, потому что args могут быть легко приняты за функцию, определенную в текущем модуле.

Обратите внимание, что std::env::args будет паниковать, если любой из аргументов содержит недопустимый Unicode. Если ваша программа должна принимать аргументы, содержащие недопустимый Unicode, используйте вместо этого std::env::args_os. Эта функция возвратит итератор, который формирует значения OsString вместо значений String. Мы выбрали здесь использование std::env::args для упрощения, потому что значения OsString отличаются на разных платформах и с ними сложнее работать, чем со значениями String.

В первой строке main мы вызвали env::args, и немедленно используем collect для превращения итератора в вектор, в котором содержатся все значения, сформированные итератором. Мы можем использовать функцию collect для создания многих видов коллекций, так что мы явно аннотировали тип args, чтобы указать, что хотим получить вектор строк. Хотя мы очень редко нуждаемся в аннотации типов в Rust, collect это одна из функций, которую вам придется часто аннотировать, потому что Rust не может вывести вид коллекции, которую вы хотите использовать.

И наконец, мы печатаем вектор с помощью макроса debug. Попробуйте запустить этот код сначала без аргументов, и затем с двумя аргументами. Запуск без аргументов:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]

Запуск с двумя аргументами командной строки:

$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

Обратите внимание, что первое значение в векторе это "target/debug/minigrep", которое является именем нашего бинарника. Это соответствует поведению списка аргументов на языке C, что позволяет программам использовать свое имя во время их выполнения. Часто полезно иметь доступ к имени программы в случае, когда вы хотите печатать его в сообщениях, или хотите менять поведение программы в зависимости от того, какой псевдоним (alias) был использован для запуска программы. Но для целей этой главы мы будем игнорировать это, и будем сохранять только два аргумента, которые нам нужны.

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

use std::env;

fn main() { let args: Vec< String> = env::args().collect();
let query = &args[1]; let file_path = &args[2];
println!("Ищем {query}"); println!("В файле {file_path}"); }

Листинг 12-2. Создание переменных, чтобы хранить аргумент запроса и аргумента пути файла.

Как мы уже говорили, когда печатали вектор, имя программы попадает в первое значение вектора на args[0], так что мы будем начинать работать с аргументами по индексу 1. Первый аргумент minigrep принимает строку, которую мы ищем, так что мы поместим ссылку на первый аргумент в переменную query. Второй аргумент будет путь файла, так что мы поместим ссылку на второй аргумент в переменную file_path.

Мы временно напечатаем значения этих переменных, чтобы убедиться, что код работает так, как было задумано. Давайте запустим эту программу еще раз с аргументами test и sample.txt:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Ищем test
В файле sample.txt

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

[Чтение файла]

Теперь мы добавим функционал чтения файла, путь до которого указан в аргументе file_path. Во-первых, нам нужен простой образец файла для тестирования: мы будем использовать файл с небольшим количеством текста в нескольких строках с некоторыми повторяющимися словами. Листинг 12-3 содержит поэму Emily Dickinson, которая хорошо для этой цели подходит. Создайте файл с именем poem.txt в корневой папке нашего проекта, и введите туда текст поэмы "I’m Nobody! Who are you?".

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!

Листинг 12-3. Текст поэмы Emily Dickinson (файл poem.txt).

Отредактируйте файл src/main.rs и добавьте код для чтения файла, как показано в листинге 12-4.

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

fn main() { // -- вырезано -- println!("In file {file_path}");
let contents = fs::read_to_string(file_path) .expect("Необходимо было прочитать файл");
println!("С текстом:\n{contents}"); }

Листинг 12-4. Чтение содержимого файла, указанного во втором аргументе.

Здесь мы приводим в область действия соответствующие части стандартной оператором use: нам понадобится std::fs для работы с файлами.

В функции main новый оператор fs::read_to_string принимает file_path, открывает этот файл и возвратит std::io::Result< String> содержимого файла.

После этого мы временно добавим оператор println!, который напечатает содержимое файла после его чтения, тем самым мы проверим, как программа работает.

Запустим этот код с любой строкой в качестве первого аргумента командной строки (потому что мы еще не реализовали часть кода для поиска), и в качестве второго аргумента укажите файл poem.txt:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Ищем
В файле poem.txt
С текстом:
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!

Отлично! Код читает и печатает содержимое файла. Но в коде есть несколько недостатков. В настоящий момент функция main получила несколько обязанностей, однако как правило, функции понятнее, и их проще поддерживать, если каждая функция отвечает только за одну идею. Другая проблема состоит в том, что мы не обрабатываем ошибки так хорошо, как могли бы. Программа пока небольшая, так что эти недостатки еще не являются большой проблемой, однако по мере разрастания кода программы будет все сложнее эти недостатки исправить. Хорошей практикой будет начинать рефакторинг на ранней стадии разработки, потому что меньшие объемы кода организовывать намного проще. Мы сделаем это дальше.

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

Чтобы улучшить программу, мы исправим 4 проблемы, связанные со структурой программы и с тем, как она обрабатывает потенциальные ошибки. Во-первых, сейчас наша функция main выполняет 2 задачи: парсит аргументы и читает файлы. По мере того, как программа разрастется, количество отдельных задач функции main, которые она обрабатывает, будет увеличиваться. По мере того, как функция будет получать все больше обязанностей, становится все сложнее понимать, что и как она делает, её становится сложнее тестировать и изменять, не нарушая другие её части. Лучше всего разделить функциональность, чтобы каждая функция выполняла одну задачу.

Эта проблема также связана с другой проблемой: хотя query и file_path это конфигурационные переменные для нашей программы, переменные наподобие contents используются для реализации логики программы. Чем больше становится main, тем больше переменных нам нужно вводить в область действия; чем больше переменных у нас в области действия, тем сложнее отслеживать, для чего нужна каждая из этих переменных. Лучше всего сгруппировать конфигурационные переменные в одну структуру, чтобы стало понятнее их назначение.

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

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

Давайте решим эти 4 проблемы рефакторингом нашего проекта.

Разделение проблем для двоичных проектов. Организационная проблема выделения ответственности за несколько задач на функцию 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.

fn main() {
    let args: Vec< String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// -- вырезано -- }

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

Листинг 12-5. Извлечение из main функции parse_config.

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

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

Группирование значений конфигурации. Мы можем сделать еще один маленький шаг в сторону улучшения функции parse_config. В настоящий момент вы возвращаем кортеж (tuple), но затем мы снова сразу разбиваем этот на отдельные части. Это показывает, что скорее всего у нас недостаточно применена правильная абстракция.

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

Листинг 12-6 показывает улучшения для функции parse_config.

fn main() {
    let args: Vec< String> = env::args().collect();
let config = parse_config(&args);
println!("Ищем {}", config.query); println!("В файле {}", config.file_path);
let contents = fs::read_to_string(config.file_path) .expect("Необходимо было прочитать файл");
// -- вырезано -- }

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 (borrowing rules), если бы Config попыталась бы взять во владение значения в args.

Существует несколько способов управлять данными String; самый простой, хотя и несколько не эффективный, это вызов метода clone на значениях. Это сделает полную копию данных для экземпляра Config для владения, что займет больше времени и памяти, чем сохранение ссылки на данные строки. Однако клонирование данных также делает наш код очень простым, потому что нам не нужно управлять временем жизни ссылок; в этом случае отказ от незначительного повышения производительности в целях достижения простоты кажется целесообразным компромиссом.

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

Итак, теперь, когда целью функции стало создание экземпляра Config, мы можем поменять функцию parse_config так, чтобы она стала называться new, и была связана со структурой Config. Этим изменением мы сделаем код более идиоматическим. Мы можем создавать экземпляры типов в стандартной библиотеке, таких как String, вызовом String::new. Подобным образом, превратив parse_config в функцию new, связанную с Config, мы сможем создавать экземпляры Config вызовом Config::new. Листинг 12-7 показывает изменения, которые нам нужно сделать.

fn main() {
    let args: Vec< String> = env::args().collect();
let config = Config::new(&args);
// -- вырезано -- }

// -- вырезано --

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

// -- вырезано --
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("недостаточное количество аргументов");
        }
        // -- вырезано --

Листинг 12-8. Добавление проверки количества аргументов программы.

Этот код подобен функции Guess::new, которую мы написали в листинге 9-13 (см. [4]), где мы вызывали panic!, когда значение аргумента выходило за границы допустимых значений. Здесь вместо проверки диапазона значений мы проверяем, что длина args как минимум 3, и тогда остальная часть функции может работать в предположении, что это условие выполнено. Если в векторе args меньше трех элементов, то условие if будет true, и мы вызовем макрос panic! для немедленного завершения программы.

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

$ 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:
недостаточное количество аргументов
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Этот вывод лучше: теперь сообщение об ошибке сообщает пользователю о причине ошибки. Однако здесь также присутствует посторонняя информация, которую мы не хотели бы предоставлять пользователю. Возможно техника вызова panic! лучше подходит для проблем программирования, но не подходит для этой ситуации проблемы использования. Вместо этого мы будем использовать другую технику, которой научились в главе 9 (см. [4]) - возврат Result, который показывает успех или ошибку.

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

Листинг 12-9 показывает изменения, которые нам надо сделать, чтобы возвратить значение из функции. Здесь теперь вызывается Config::build, и тело функции должно возвратить Result. Обратите внимание, что этот код не скомпилируется, пока мы не обновим соответствующим образом main, что мы сделаем в следующем листинге.

impl Config {
    fn build(args: &[String]) -> Result< Config, &'static str> {
        if args.len() < 3 {
            return Err("недостаточное количество аргументов");
        }
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. Мы сделали два изменения в теле функции: вместо вызова panic!, когда пользователь не ввел достаточное количество аргументов командной строки, мы теперь возвратим значение Err, и мы обернули возвращаемое значение Config в вариант Ok. Эти изменения функции удовлетворяют её новой сигнатуре типа.

Возврат значения Err из Config::build позволят функции main обработать значение Result, возвращенное из функции build, и сделать выход из процесса более чистым в ситуации ошибки.

Вызов Config::build и обработка ошибок. Чтобы обработать ошибочный случай и напечатать более понятное для пользователя сообщение, нам нужно обновить функцию main, чтобы обработать значение Result, возвращенное из Config::build, как показано в листинге 12-10. Мы также берем ответственность за возврат из утилиты командной строки ненулевого значения, реализуя это вместо panic! вручную. Ненулевой статус выхода по соглашению это сигнал состояния ошибки процессу, вызвавшему вашу программу.

use std::process;

fn main() { let args: Vec< String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| { println!("Проблема парсинга аргументов: {err}"); process::exit(1); });
// -- вырезано --

Листинг 12-10. Выход с кодом ошибки, если создание Config закончилось неудачей.

В этом листинге мы использовали метод, который пока не рассмотрели подробно: unwrap_or_else, который в стандартной библиотеке определен на типе Result< T, E>. Использование unwrap_or_else позволит нам определить некоторую пользовательскую обработку ошибки без panic!. Если Result это значение Ok, то поведение этого метода подобно unwrap: он возвратит внутреннее значение обертки Ok. Однако, если значение Err, то этот метод вызовет код в closure, что является анонимной функцией, которую мы определяем, и передаст ей аргумент, переданный в unwrap_or_else. Более детально closure будут рассмотрены в главе 13. Пока вам просто нужно знать, что unwrap_or_else будет передавать внутреннее значение Err, которое в этом случае будет статической строкой "недостаточное количество аргументов", добавленной в листинге 12-9, для нашей closure в аргументе err, который появляется между вертикальными палочками. Этот код в closure может затем использовать значение err, когда запустится.

Мы добавили новую строку use, чтобы привести process из стандартной библиотеки в нашу область действия. Код в closure, который будет запущен в случае ошибки, состоит из двух строк: мы печатаем значение err, и затем вызываем process::exit. Функция process::exit немедленно остановит программу и возвратит число, которое было в неё передано, как код состояния ошибки (exit status code). Это подобно обработки ошибки, которое работает в макросе 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`
Проблема парсинга аргументов: недостаточное количество аргументов

Отлично! Этот вывод более дружественен для наших пользователей.

[Извлечение логики из main]

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

Листинг 12-11 показывает извлеченную функцию run. Пока что мы просто сделали маленькое, инкрементное улучшение излечением функции, и она пока все еще находится в src/main.rs.

fn main() {
    // -- вырезано --
println!("Ищем {}", config.query); println!("В файле {}", config.file_path);
run(config); }

fn run(config: Config) { let contents = fs::read_to_string(config.file_path) .expect("Необходимо было прочитать файл");
println!("С текстом:\n{contents}"); }

// -- вырезано --

Листинг 12-11. Извлечение логики программы в функцию run.

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

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

use std::error::Error;

// -- вырезано --

fn run(config: Config) -> Result< (), Box< dyn Error>> { let contents = fs::read_to_string(config.file_path)?;
println!("С текстом:\n{contents}");
Ok(()) }

Листинг 12-12. Изменение функции run для возврата Result.

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

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

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

В третьих, функция run теперь возвращает значение Ok в случае успеха. Мы декларировали успех функции run как () в её сигнатуре. Это означает, что нам нужно обернуть значение unit-типа в значении Ok. Этот синтаксис (()) может поначалу выглядеть несколько странным, однако использование () подобным образом работает идиоматически, чтобы показать, что мы вызываем run только для её побочных эффектов; она не возвращает значение, которое нам нужно.

Когда мы запустим этот код, он скомпилируется, но покажет предупреждение (warning):

$ 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` Ищем В файле poem.txt С текстом: 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, но с небольшим отличием:

fn main() {
    // -- вырезано --
println!("Ищем {}", config.query); println!("В файле {}", config.file_path);
if let Err(e) = run(config) { println!("Application error: {e}"); process::exit(1); } }

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

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

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

Наш проект 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.

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> { // -- вырезано -- } }

pub fn run(config: Config) -> Result< (), Box< dyn Error>> { // -- вырезано -- }

Листинг 12-13. Перемещение Config и run в src/lib.rs (файл src/lib.rs).

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

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

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

use minigrep::Config;

fn main() { // -- вырезано -- if let Err(e) = minigrep::run(config) { // -- вырезано -- } }

Листинг 12-14. Использование библиотечного крейта minigrep в src/main.rs.

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

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

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

[Разработка функционала библиотеки в условиях использования технологии тестов]

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

В этой секции мы добавим логику поиска в программу minigrep, используя процессы среды разработки на основе тестов (test-driven development, TDD), выполнив следующие шаги:

1. Напишем тест, который не пройдет, и запустим его, чтобы убедиться в его неудаче по ожидаемой причине.
2. Напишем или изменим некоторый код, чтобы новый тест прошел.
3. Выполним рефакторинг только что добавленного или измененного кода и убедимся после этого, что тесты продолжают успешно завершаться.
4. Повторим все начиная с шага 1!

Хотя это один из многих методик написания ПО, технология TDD может помочь в разработке кода. Написание теста перед написанием кода, который обеспечивает прохождение теста, помогает реализовать высокий охват теста на протяжении всего процесса разработки.

Мы проведем тестирование функциональности, которая фактически выполнит поиск строки запроса по содержимому файла, и создаст список строк, соответствующих запросу. Давайте реализуем это в функции с именем search.

Написание кода неудачного теста. Поскольку операторы println! нам больше не нужны, давайте уберем их из src/lib.rs и src/main.rs, когда мы с помощью них проверяли поведение программы. Затем в src/lib.rs мы добавим модуль tests с функцией test, как мы делали в главе 11 [6]. Функция test задает поведение, которое мы хотим получить от функции search: она получает запрос (query) и текст, в котором осуществляется поиск, и возвратит только строки из текста, которые содержат запрос на поиск. Листинг 12-15 показывает этот тест, который пока не скомпилируется.

#[cfg(test)]
mod tests { use super::*;
#[test] fn one_result() { let query = "duct"; let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents)); } }

Листинг 12-15. Создание неудачного теста для функции search, которую мы хотели бы получить (файл src/lib.rs).

Этот тест ищет строку "duct". Текст, по которому проходит поиск, состоит из трех строк, и только одна из них содержит "duct" (обратите внимание на обратный слеш после открывающей двойной кавычки, это говорит Rust не добавлять символ перевода строки в начало содержимого этого строкового литерала). Мы утверждаем (макросом assert_eq!), что значение, возвращаемое из функции search, содержит только строку, которую мы ожидаем.

Мы еще не можем запустить этот тест и посмотреть, как он провалится, потому что тест даже не компилируется: функция search пока не существует! В соответствии с принципами TDD, мы добавим просто немного кода, чтобы тест компилировался, и запускался, путем добавления определения функции search, которая всегда возвратит пустой вектор, как показано в листинге 12-16. Затем тест должен скомпилироваться и потерпеть неудачу, потому что пустой вектор не соответствует вектору, в котором находится строка "safe, fast, productive."

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

Листинг 12-16. Определение функции search в виде, достаточном для компиляции теста (файл src/lib.rs).

Обратите внимание, что мы определили явное время жизни 'a в сигнатуре функции search, и использовали это время жизни с аргументом contents и возвращаемым значением. Вспомните главу 10 [5], в ней параметры времени жизни указывают, какое время жизни аргумента связано с временем жизни возвращаемого значения. В этом случае мы показываем, что возвращаемый вектор должен содержать слайсы строки, которые ссылаются на слайсы аргумента contents (место аргумента query).

Другими словами, здесь мы говорим Rust, что данные, возвращаемые функцией search, будут жить, пока актуальны данные, переданные в функцию search аргументом contents. Это важный момент! Данные, на которые ссылается слайс, должны быть достоверными, чтобы ссылка была достоверна; если компилятор предполагает, что мы делаем строковые слайсы query вместо contents, то он неправильно выполнит проверку безопасности.

Если мы забудем применить lifetime-аннотации, и попробуем скомпилировать эту функцию, то получим ошибку:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec< &str> {
   |                      ----            ----          ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature
           does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search< 'a>(query: &'a str, contents: &'a str) -> Vec< &'a str> {
    |              ++++         ++                 ++               ++
For more information about this error, try `rustc --explain E0106`. error: could not compile `minigrep` (lib) due to 1 previous error

Rust не может знать, какой из двух аргументов нам нужен, поэтому необходимо прямое указание. Поскольку contents это аргумент, который содержит весь наш текст, и мы хотим возвратить совпадающие части этого текста, то мы знаем, что contents это тот аргумент, который должен быть соединен с возвращаемым значением через lifetime-синтаксис.

Другие языки программирования не требуют от вас соединять аргументы с возвращаемыми значениями в сигнатуре, но со временем эта практика станет проще. Вы можете захотеть сравнить этот пример с секцией "Проверка ссылок с помощью времени жизни" главы 10 (см. [5]).

Теперь давайте запустим тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ---- thread 'tests::one_result' panicked at src/lib.rs:44:9: assertion `left == right` failed left: ["safe, fast, productive."] right: [] note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures: tests::one_result
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`

Отлично, тест не прошел, точно как мы ожидали. Давайте сделаем так, чтобы тест прошел!

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

• Итерация по каждой строке переменной contents.
• Проверка, содержит ли строка нашу строку запроса поиска query.
• Если содержит, то добавление её в список возвращаемых значений.
• Если не содержит, то ничего не делать.
• Возвратить список результатов совпадения.

Давайте проработаем каждый шаг, начиная с итерации по строкам.

Итерация по строкам с помощью метода lines. В Rust есть вспомогательный метод для построчной обработки итерации строк, с удобным именем lines, работа этого метода показана в листинге 12-17. Обратите внимание, что это пока не скомпилируется.

pub fn search< 'a>(query: &str, contents: &'a str) -> Vec< &'a str> {
    for line in contents.lines() {
        // здесь какие-нибудь действия со строкой в переменной line
    }
}

Листинг 12-17. Итерация по каждой строке в contents (код в файле src/lib.rs).

Метод lines возвращает итератор. Мы поговорим про итераторы в главе 13, но помните, что мы уже видели этот способ применения итератора в листинге 3-5 [4], где был цикл с итератором, чтобы запустить некоторый код на каждом элементе в коллекции.

Поиск по каждой строке для запроса. Далее мы проверим, содержит ли текущая строка нашу искомую строку запроса query. К счастью, строки имеют полезный метод contains, который сделает этот поиск для нас! Добавьте вызов метода contains в функцию search, как показано в листинге 12-18. Обратите внимание, что это пока что не скомпилируется.

pub fn search< 'a>(query: &str, contents: &'a str) -> Vec< &'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // здесь какие-нибудь действия со строкой в переменной line
        }
    }
}

Листинг 12-18. Добавление функциональности, чтобы увидеть, содержит ли строка line искомую строку в query (файл src/lib.rs).

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

Сохранение строк с совпадениями. Чтобы завершить эту функцию, нам нужен способ сохранить совпавшие строки, которые мы хотим возвратить. Для этого мы можем создать мутируемый вектор перед циклом for, и вызвать метод push для сохранения строки в вектор vector. После цикла for мы возвратим вектор, как показано в листинге 12-19.

pub fn search< a>(query: &str, contents: &a str) -> Vec< &a str> {
    let mut results = Vec::new();
for line in contents.lines() { if line.contains(query) { results.push(line); } }
results }

Листинг 12-19. Сохранение строк с совпадениями, так что мы можем возвратить их (файл src/lib.rs).

Теперь функция search должна возвратить только строки, которые содержат query, и наш тест должен пройти. Давайте запустим тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Наш тест прошел, мы знаем теперь, что это работает!

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

Использование функции search в функции run. Теперь, когда функция search заработала и была протестирована, нам нужно вызывать search из нашей функции run. Нам нужно передать значение config.query и contents в функции run, которая читает из файла, в функцию search. Затем run будет печатать каждую строку, возвращенную из search:

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

Мы все еще используем цикл for для возврата каждой строки из search, и печати этой строки.

Теперь вся наша программа должна работать! Давайте это попробуем, сначала со словом "frog" которая должна возвратить только одну строку из поэмы Emily Dickinson:

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Круто! Теперь попробуем другое слово типа "body", которое должно находится в нескольких строках:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

И наконец убедимся, что не получим ни одну строку, когда попробуем найти слово, которое точно не встретится в поэме, такое как "monomorphization":

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Отлично! Мы создали свою собственную мини-версию классического инструмента grep, и на этом примере научились кое-чему в структурировании кода приложений. Мы также немного научились работать с вводом из файла и выводом в консоль, применили lifetime, тестирование, парсинг командной строки.

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

[Работа с переменными окружения]

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

Реализация неудачного теста поиска, который нечувствителен к регистру символов. Сначала мы добавим новую функцию search_case_insensitive, которая будет вызываться, когда в переменной окружения есть значение. Мы продолжим следовать TDD-процессу разработки, так что первым шагом опять будет написание неудачного теста. Добавим новый тест для функции search_case_insensitive, и переименуем старый тест one_result в case_sensitive, чтобы пояснить различия между этими двумя тестами, как показано в листинге 12-20.

#[cfg(test)]
mod tests { use super::*;
#[test] fn case_sensitive() { let query = "duct"; let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents)); }
#[test] fn case_insensitive() { let query = "rUsT"; let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!( vec!["Rust:", "Trust me."], search_case_insensitive(query, contents) ); } }

Листинг 12-20. Добавление нового неудачного теста для функции поиска, нечувствительного к регистру, которую мы собираемся добавить (файл src/lib.rs).

Обратите внимание, что мы также отредактировали и contents старого теста. Мы добавили новую строку с текстом "Duct tape", используя заглавную букву D, которая не должна совпадать с запросом "duct", когда мы выполняем поиск, чувствительный к регистру символов. Изменение старого теста таким способом поможет гарантировать, что мы не нарушим случайно функционал чувствительности к регистру поиска, который уже реализован. Этот тест должен пройти сейчас и должен будет нормально проходить, поскольку мы работаем над поиском без учета регистра.

Новый тест для поиска, не чувствительного к регистру, использует в своем запросе "rUsT". В функции search_case_insensitive, которую мы собираемся добавить, запрос "rUsT" должен срабатывать на строку, содержащую "Rust:" с заглавной R, и также сработать на строку "Trust me", несмотря на то, что у них разные варианты использования заглавных символов. Это наш неудачный тест, который не скомпилируется, потому что мы пока не определили функцию search_case_insensitive. Не стесняйтесь добавить скелетную реализацию этой функции, которая всегда возвращает пустой вектор, как мы это делали для функции search в листинге 12-16, чтобы увидеть, что тест компилируется и терпит неудачу.

Реализация функции search_case_insensitive. Функция search_case_insensitive, показанная в листинге 12-21, будет почти такая же, как функция search. Отличие только в том, что мы будем переводить в нижний регистр как запрос query, так и каждую строку. Поэтому при поиске они оба будут иметь один и тот же регистр символов.

pub fn search_case_insensitive< 'a>(
    query: &str,
    contents: &'a str,
) -> Vec< &'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();
for line in contents.lines() { if line.to_lowercase().contains(&query) { results.push(line); } }
results }

Листинг 12-21. Определение функции search_case_insensitive с переводом в нижний регистр как query, так и line, перед их сравнением (файл src/lib.rs).

Сначала мы преобразуем в нижний регистр строку query, и сохраняем её в затененной переменной с таким же именем. Вызов to_lowercase на query необходим, чтобы для всех строк, который введет пользователь в запросе "rust", "RUST", "Rust" или "rUsT", мы будем обрабатывать запрос как если бы он содержал "rust", и таким образом отключалась чувствительность к регистру при поиске. Хотя метод to_lowercase будет обрабатывать базовый Unicode, он не будет на 100% точным. Если бы мы писали реальное приложение, то здесь пришлось бы поработать несколько больше, но здесь мы ставим акцент на использовании переменных среды, а не на Unicode, так что оставим это как есть.

Обратите внимание, что query это теперь String, а не слайс строки, потому что вызов to_lowercase создаст новые данные вместо того, чтобы ссылаться на существующие данные. Предположим для примера, что в query находится "rUsT": этот слайс строки не содержит строкового символа u или t, так что должны выделить новую String, содержащую "rust". Когда мы теперь передадим query в качестве аргумента в метод contains, нам нужно добавить амперсанд спереди, потому что сигнатура contains определена для получения слайса строки.

Далее мы добавим вызов to_lowercase на каждой строке line, чтобы перевести все её символы в нижний регистр. После этого, как преобразованы в нижний регистр и line, и query, мы ищем совпадения, не обращая внимания, какой регистр символов в запросе поиска query.

Проверим, пройдут ли тесты с этой реализацией:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests test tests::case_insensitive ... ok test tests::case_sensitive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Отлично! Тесты успешно прошли. Теперь вызовем новую функцию search_case_insensitive из функции run. Сначала мы добавим опцию конфигурации для структуры Config, чтобы переключаться между вариантами поиска с чувствительностью и нечувствительностью к регистру символов. Добавление этого поля приведет к ошибками компилятора, потому что мы пока нигде не инициализировали это поле:

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

Мы добавили поле ignore_case, которое хранит в себе Boolean. Далее нам нужно, чтобы функция run проверяла значение поля ignore_case, и по его значению выбирала, какую из функций запускать: функцию search или функцию search_case_insensitive, как показано в листинге 12-22. Этот код пока не скомпилируется.

pub fn run(config: Config) -> Result< (), Box< dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case { search_case_insensitive(&config.query, &contents) } else { search(&config.query, &contents) };
for line in results { println!("{line}"); }
Ok(()) }

Листинг 12-22. Вызов либо search, либо search_case_insensitive, в зависимости от значения в config.ignore_case (файл src/lib.rs).

И наконец, нам нужно проверять переменную окружения. Функции для работы с переменными окружения находятся в модуле env стандартной библиотеки, так что мы приведем этот модуль в область действия оператором use в самом начале файла src/lib.rs. Затем мы используем функцию var из модуля env, чтобы проверить, есть ли любое установленное значение в переменной окружения IGNORE_CASE, как показано в листинге 12-23.

use std::env;
// -- вырезано --

impl Config { pub fn build(args: &[String]) -> Result< Config, &'static str> { if args.len() < 3 { return Err("недостаточное количество аргументов"); }
let query = args[1].clone(); let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config { query, file_path, ignore_case, }) } }

Листинг 12-23. Проверка на любое значение в переменной окружения IGNORE_CASE (файл src/lib.rs).

Здесь мы создаем новую переменную ignore_case. Для установки её значения мы вызываем функцию env::var и передаем в неё имя переменной окружения IGNORE_CASE. Функция env::var возвращает Result, который будет вариантом Ok, содержащим значение переменной окружения, если эта переменная окружения установлена в любое значение. Функция возвратит вариант Err, если переменная окружения не установлена.

Мы используем метод is_ok на Result, чтобы проверить, установлена ли переменная окружения. Если переменная окружения IGNORE_CASE установлена, то это значит включение поиска без учета регистра символов (case-insensitive search). Если переменная окружения IGNORE_CASE не установлена в любое значение, то is_ok возвратит false, и программа будет выполнять поиск с учетом регистра символов (case-sensitive search). Мы не обращаем внимания на значение переменной окружения, нам просто нужно знать факт, установлена она или нет, так что используется is_ok вместо unwrap, expect или любого другого метода, которые имеются на Result.

Мы передаем значение переменной ignore_case в экземпляр Config, так что функция run может прочитать её значение и принять решение, какой вид поиска запускать: вызывать search_case_insensitive или вызывать search, как реализовано в листинге 12-22.

Давайте проверим, как это работает! Сначала мы запустим нашу программу без установленной переменной окружения, и для query зададим значение "to", что совпадет с любой строкой, содержащей слово "to" с символами в нижнем регистре:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

Это все еще работает! Давайте теперь запустим программу, когда переменная окружения IGNORE_CASE установлена в 1, на том же запросе "to".

$ IGNORE_CASE=1 cargo run -- to poem.txt

Если вы используете PowerShell, то нужно установить переменную окружения и запустить программу отдельными командами:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

Это сделает переменную IGNORE_CASE постоянно установленной для вашей сессии шелла. Это можно отменить с помощью Remove-Item cmdlet:

PS> Remove-Item Env:IGNORE_CASE

Мы должны получить результаты поиска со строками, которые содержат "to" с символами как в верхнем, так и в нижнем регистре:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

Отлично, мы также видим строки, в которых содержится "To"! Наша утилита minigrep может теперь выполнять поиск, не обращая внимания на регистр символов, что активируется через установку переменной окружения IGNORE_CASE. Вы научились, как управлять опциями программы и с помощью аргументов командной строки, и через переменные окружения.

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

Модуль std::env содержит многие другие полезные фичи для работы с переменными окружения, проконсультируйтесь с документацией на этот модуль.

[Вывод сообщений об ошибках в стандартный поток ошибок вместо стандартного вывода]

В настоящий момент мы выводим все сообщения в терминал, используя макрос println!. В большинстве терминалов есть 2 вида вывода: стандартный вывод (standard output, stdout) для основной информации, и стандартный вывод ошибок (standard error, stderr) для вывод сообщений об ошибках. Это разделение вывода позволяет пользователям выбрать направление успешного вывода программы в файл, но при этом все еще печатать сообщения ошибки на экран терминала.

Макрос println! может печатать только в stdout, так что нам нужно что-то другое, чтобы печатать в stderr.

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

Программы командной строки по соглашению перенаправляют сообщения ошибки в поток stderr, чтобы мы все еще могли видеть ошибки на экране, если stdout был перенаправлен в файл. Наша программа пока не обладает таким хорошим поведением. Для демонстрации этого мы запустим программу с перенаправлением > и указанием пути до файла output.txt, куда мы хотим выполнить перенаправление вывода. Мы не передадим программе аргументов, что вызовет ошибку:

$ cargo run > output.txt

Синтаксис > говорит шеллу записывать содержимое стандартного вывода в файл (в нашем примере это файл output.txt) вместо экрана. Мы не увидим сообщения об ошибке, которое печаталось на экране, потому что оно попадет в файл output.txt, который содержит следующее:

Проблема парсинга аргументов: недостаточное количество аргументов

Итак, наше сообщение об ошибке печатается в стандартный вывод stdout. Было бы полезнее выводить его в stderr, чтобы только информационные сообщения попадали в файл. И мы это исправим.

Печать ошибок в stderr. Мы используем код в листинге 12-24, чтобы поменять, как будут печататься сообщения ошибок. Поскольку мы раньше уже выполнили рефакторинг, то весь код, печатающий ошибки, находится в одной функции main. Стандартная библиотека предоставляет макрос eprintln!, который печатает в поток stderr, так что давайте поменяем это в двух местах, где использовался макрос println!, чтобы вместо этого ошибки печатались с использованием макроса eprintln!.

fn main() {
    let args: Vec< String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| { eprintln!("Проблема парсинга аргументов: {err}"); process::exit(1); });
if let Err(e) = minigrep::run(config) { eprintln!("Application error: {e}"); process::exit(1); } }

Листинг 12-24. Вывод сообщений об ошибках в stderr вместо стандартного вывода с помощью макроса eprintln! (файл src/main.rs).

Давайте еще раз запустим программу без аргументов, и перенаправим стандартный вывод в файл:

$ cargo run > output.txt
Проблема парсинга аргументов: недостаточное количество аргументов

Теперь мы видим на экране сообщение ошибки, а файле output.txt ничего нет. Как раз такое поведение ожидается в программе командной строки.

Запустите программу снова, теперь с аргументами, что не вызовет ошибку, и снова с перенаправлением в файл:

$ cargo run -- to poem.txt > output.txt

На экране мы ничего не увидим, и файл output.txt будет содержать наши результаты поиска (файл output.txt):

Are you nobody, too?
How dreary to be somebody!

Это демонстрирует, как использовать стандартный вывод stdout для обычных информационных сообщений, и поток stderr для вывода сообщений об ошибках.

[Общие выводы]

Эта глава закрепляет знания, полученные в предыдущих главах. Также демонстрируется, как реализовать общие операции ввода/вывода в Rust. С помощью аргументов командной строки, файлов, переменных окружения и макроса eprintln! для печати ошибок, вы теперь подготовлены для создания приложений командной строки. В комбинации с концепциями предыдущих глав ваш код будет получаться хорошо организованным, с эффективным сохранением данных в подходящие структуры, хорошей обработкой ошибок и качественным тестированием.

Дальше мы разберем некоторые особенности Rust, на которые повлияли функциональные языки программирования: closures (замыкания) и iterators (итераторы).

[Ссылки]

1. Rust I/O Project Building a Command Line Program site:rust-lang.org.
2. Rust: управление проектами с помощью пакетов, крейтов и модулей.
3. Rust: коллекции стандартной библиотеки.
4. Rust: обработка ошибок.
5. Rust: generic-типы, traits, lifetimes.
6. Rust: написание автоматизированных тестов.
7. Rust: общая концепция программирования.

 

Добавить комментарий


Защитный код
Обновить

Top of Page