Neeraj Avinash, изучающий основы языка Rust, опубликовал код в Rust Programming Language Group (Группе языка программирования Rust) на LinkedIn. Я подумал, что этот код станет хорошей основой для статьи. На его примере мы рассмотрим, как поэтапно улучшить код Rust, и укажем новичкам на ошибки, которых можно избежать с самого начала программирования. Выбор этой программы в качестве материала обучения обусловлен ее простотой, поэтому не обращайте внимание на очевидные недостатки.
Введение
Я решил не публиковать здесь весь код, пусть он и короткий, чтобы не спугнуть читателей, желающих быстро пробежаться по тексту статьи. Полный вариант предоставлен в исходном коммите по ссылке. В статье предлагаются только фрагменты кода с последующими текстовыми пояснениями. Такой подход позволит лучше понять сам процесс, а не просто увидеть итоговый результат. Каждый раздел статьи формирует коммит данного пул-реквеста.
Отсутствие идиом Rust
Опытным программистам Rust режет глаз тот факт, что следующая функция возвращает кортеж, а не Result<>
:
fn add_student() -> (Student, bool)
Данный подход не только не является идиоматическим, но и вводит в заблуждение читателя кода. Непонятно, что подразумевает логическое значение bool
. В ответ на вывод этой функции придется написать что-то сложное, как показано ниже:
// Добавление студента на курс
let (st, err) = add_student();
// Проверка наличия ошибки. В случае ошибки продолжить цикл
if !err {
continue;
}
Получается 5 строк с комментариями, объясняющими код. Это считается плохой практикой, так же как и короткие имена переменных.
Рефакторинг
Сначала проведем рефакторинг этих фрагментов. То, что было:
fn add_student() -> (Student, bool) {
// ...
let mut st = Student {
name: "".to_string(),
age: 0,
};
// ...
if student_name.len() < 3 {
// ...
return (st, false);
}
// ...
(st, true)
}
преобразуем в более идиоматический и читаемый вариант:
fn add_student() -> Result<Student, &'static str> {
// ...
if student_name.len() < 3 {
// ...
return Err("Student's name too short");
}
// ...
let age = age.parse.map_err(|_| "Cannot parse student's age")?;
Ok(Student {
name: student_name,
age
})
}
Понятно, что возврат статических строк в качестве ошибок не относится к разряду привычных практик, но вполне подойдет для данного примера.
Метод .map_err()
позволяет преобразовать экземпляр типа, содержащийся в значении перечисления Err(e)
, в такой, который поддерживается функцией.
В этом случае объявленным типом является &'static str
(эквивалент Rust для идиомы типа const char*
в С), чем объясняется совпадение текстов в кавычках. Оператор ?
— одна из лучших функциональностей Rust. Он проверяет стоящий перед ним экземпляр Result<>
. Если значение равно Err(e)
, возвращает результат, в противном случае продолжает работу. В старом коде встречался макрос try!()
.
В итоге проверка ожидаемого вывода функции выглядит так:
let student = if let Ok(student) = add_student() {
student
} else {
continue;
}
student_db.push(student.clone());
Это неидеальное условие, поскольку оно фактически исключает любую ошибку. Действуя таким образом, мы исходим из предположения допустимости такого подхода, но предусматриваем обработку перечисления Err(e)
на индивидуальной основе.
Ошибка новичков: бесконечный цикл
В исходном коде есть две проблемы с циклом loop
:
- В контексте по-прежнему присутствует инкрементный счетчик, отличающийся неоправданной изменчивостью.
- Условие
exit_var
отображает “вывод” перед выходом.
Обе эти практики входят в число распространенных ошибок новичков. Улучшим код, преобразовав его из:
let mut i: i8 = 1;
loop {
в:
for i in 1usize.. {
исходя из предположения, что подойдет типичный 64-битный размер usize
.
Затем удаляем:
i += 1;
и таким образом упрощаем код, улучшая его читаемость.
Далее выводим из цикла вызов отображения списка полученных студентов:
if exit_var == "q" {
println!("Exiting...");
display_students_in_course(&student_db);
break;
}
и превращаем его в:
/// ...
if exit_var == "q" {
break;
}
}
println!("Exiting...");
display_students_in_course(&student_db);
Инкапсуляция
Эта программа напоминает мой опыт программирования в 80-х годах, когда мы писали такие программы на BASIC. Но BASIC был базовым императивным языком, в котором отсутствовали многие доступные в наши дни функциональности, например функции. Но в результате прямолинейный подход к осмыслению проблемы привел к прямолинейному коду. Именно таким является код, над которым мы здесь работаем: лобовой способ достичь ожидаемого результата.
Сначала он работает, но очень быстро становится неудобным в сопровождении. Проблема решается путем обучения студентов объектно-ориентированному программированию (ООП). Несмотря на то, что университеты учат неправильно, не вдаваясь в детали, мы воспользуемся рядом принципов ООП для улучшения кода.
Говоря простыми словами, инкапсуляция — это изоляция базовых элементов для предотвращения нежелательного доступа и сокрытия деталей реализации от пользователя.
По своей сути Rust не является языком ООП, поскольку модель его типов и типажей ближе к функциональным, чем к настоящим языкам ООП. Но этого вполне достаточно для проведения инкапсуляции в простой программе.
Далее рассмотрим процесс применения модулей для рефакторинга, даже если это может и не потребоваться для программы такого небольшого размера.
Рефакторинг
Начнем со следующей строки кода:
let mut student_db: Vec<Student> = Vec::new();
Она создает пустой изменяемый вектор. Однако этот тип особо не разъясняет, что разрешается делать пользователю этой простой базы данных. В нашем случае совсем немного.
Создаем модуль src/db.rs
и включаем в него следующий код:
use super::Student;
pub struct StudentDatabase {
db: Vec<Student>
}
impl StudentDatabase {
pub fn new() -> Self {
Self {
db: vec![]
}
}
}
Затем в начало src/main.rs
добавляем:
mod db;
чтобы этот модуль учитывался при компиляции.
Но этот простой код только инициализирует внутренний вектор. Данного примера вполне достаточно, чтобы быстро понять, как типы могут использовать возможности методов. В отличии от других языков, идиоматическое решение Rust касательно метода new()
заключается в том, что он является простейшим конструктором экземпляра в стеке.
Если что-то непонятно, то стоит поизучать применение стека в программах, написанных на языках без сборки мусора. Особо не вдаваясь в детали, отметим лишь, что другие языки, например C++ и Java, задействуют new()
для выделения памяти в куче.
Для дальнейшей работы потребуется возможность добавлять студентов. Сделаем это, освободив пользователя от инстанцирования типа Student
посредством пользователя API (служит одним из способов, не всегда идеальным, но здесь распространяться на эту тему не будем). Пишем новый код для добавления impl StudentDatabase
:
pub fn add(&mut self, name: String, age: u8) {
self.db.push(Student {
name,
age
})
}
допуская возможность грубого сбоя кода, что соответствует методу .push()
структуры std::Vec
, — и, как следствие, вероятность вызова паники. Обратите внимание, что Rust может сопоставлять имена аргументов функций с именами полей используемого типа.
Это сокращенная форма инициализации поля, которая упрощает код, улучшает его читаемость и не требует написания name: name
.
Отметим еще один момент: аргумент name
является “потребляющим” (англ. consuming). В семантике Rust с его помощью мы перемещаем экземпляр строки в область видимости тела функции. Отсюда и необходимость .clone()
в исходном коде. Это не самый эффективный способ работы со строками, но другие варианты здесь рассматривать не будем.
Для полного завершения рефакторинга в src/main.rs
остается добавить еще один метод. Обычно к рефакторингу приступают сразу, позволяя редактору показывать ошибки в коде, но во избежание путаницы на данном этапе мы подготовим все необходимые компоненты заранее. Поехали:
pub fn display(&self) {
for student in self.db.as_slice() {
println!("Name: {}, Age: {}", student.name, student.age);
}
}
На данном этапе для выполнения всех требований существующего кода необходимо знать длину базы данных. На следующем этапе улучшим инкапсуляцию, чтобы избавиться от такой необходимости. Однако это полезная функция в публичном API базы данных.
Код для проверки длины:
pub fn len(&self) -> usize {
self.db.len()
}
Теперь применяем новый код к src/main.rs
. Сначала заменяем:
let mut student_db: Vec<Student> = Vec::new();
на:
let mut student_db = db::StudentDatabase::new();
Затем удаляем:
display_students_in_course(&student_db);
из тела условия максимальной длины. Определение этой функции также удаляется во избежание предупреждений о “мертвом” коде.
Далее заменяем ту же самую строку в нижней части тела main()
на:
student_db.display();
После этого исходное добавление в вектор:
student_db.push(student.clone());
меняем на:
student_db.add(student.name.clone, student.age);
Этот код выявляет недостаток ранее принятого решения относительно аргументов для add()
. В языках, поддерживающих перегрузку, мы легко смогли бы сделать это обоими способами. Но Rust требует явных имен методов, поэтому пока оставим эту тему. Сосредоточимся на функции add_student()
, которая не добавляет студентов и поэтому носит некорректное имя. Сначала переименовываем:
// Функция для добавления нового студента в DB
fn add_student() -> Result<Student, &'static str> {
на:
fn input_student() -> Result<Student, &'static str> {
и вызов тоже.
Код этой функции многословен в описании выполняемых действий:
let student_name = &input[..input.len() - 1].trim();
// ...
let age = input.trim();
age.to_string().pop(); // Удаление символа новой строки
let age = age.parse().map_err(|_| "Cannot parse student's age")?;
Поэтому корректируем его и приводим к более лаконичному идиоматическому варианту Rust:
let student_name = input.trim();
// ...
let age = input.trim().parse().map_err(|_| "Cannot parse student's age")?;
В получившемся результате хорошо заметен повторяющийся шаблон:
let mut input = String::new();
let _ = stdin().read_line(&mut input);
где игнорируется результат read_line()
. Такое игнорирование считается плохой практикой.
Быстро исправляем, добавляя следующую функцию и заменяя ею повторяющийся код:
fn prompt_input<T: FromStr>(prompt: &str) -> Result<T, &'static str> {
println!("{}: ", prompt);
let mut input = String::new();
let _ = stdin().read_line(&mut input);
input.trim().parse().map_err(|_| "Cannot parse input")
}
В итоге получаем компактную функцию input_student()
:
fn input_student() -> Result<Student, &'static str> {
print!("#### Adding New Student ####\n");
let student_name: String = prompt_input("Enter Student Name")?;
// Проверка длины имени Student, включающее как минимум 3 символа
if student_name.len() < 3 {
println!(
"Student name cannot be less than 3 characters. Record not added.\n Please try again"
);
return Err("Student's name too short");
}
let age = prompt_input("Age of the Student")?;
Ok(Student {
name: student_name.to_string(),
age,
})
}
До идеала еще далеко, но значительное улучшение уже на лицо.
В данном разделе мы рассмотрели основы инкапсуляции, которая помогает поддерживать код по принципу DRY (Don’t Repeat Yourself — Не повторяйся). Постарались изолировать функцию от деталей реализации. Получившийся код далек от совершенства (если он вообще может быть таковым). Но он иллюстрирует суть предпринятых шагов, а остальное оставим на потом.
Модульные тесты
В исходных примерах отсутствует одна из очень хороших практик — модульные тесты, которые служат первым этапом проверки качества кода. Они способны перехватить множество ошибок и недочетов, обнаружение и исправление которых будет обходиться все дороже и дороже при их переходе на последующие этапы. Такая тенденция часто наблюдается в ранних проектах, так как поначалу они могут себе позволить только ручное тестирование.
Разделение важных частей логики кода на отдельные модули позволяет приступить к внедрению модульных тестов. В ответ на это кто-то может возразить, что хороший код начинается с модульных тестов. Но на самом деле это утопический взгляд, поскольку в большинстве своем код в реальной жизни начинается с чернового наброска прототипа. Поэтому отложим дискуссии в сторону.
Rust — очень удобный язык для написания модульных тестов. Базовые механизмы встроены. Хотя они и неидеальны в каждом отдельно взятом аспекте, но вполне подходят для начала тестирования без особых заморочек.
Добавляем в нижнюю часть src/db.rs
:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_to_database() {
let mut db_ut = StudentDatabase::new();
db_ut.add("Test Student".to_string(), 34);
assert_eq!(db_ut.len(), 1);
}
}
Очень простой, но хороший первоначальный тест. Выполнение cargo test
приводит к следующему результату:
Compiling student v0.1.0 (/home/teach/rust-student-mini-project)
Finished test [unoptimized + debuginfo] target(s) in 0.16s
Running unittests src/main.rs (target/debug/deps/student-f5f1fdf375ff16cf)
running 1 test
test db::tests::add_to_database ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Как видно, тест пройден.
Но другая часть API отображает содержимое базы данных. Таким образом, мы сталкиваемся с проблемой вывода из этой функции. Теоретически возможно получить вывод в тесте, но это сложно и выходит за рамки статьи.
Обратимся к практике под названием обратное внедрение зависимостей. Ее суть заключается в предоставлении тестируемому модулю интерфейсов для всего, от чего он может зависеть. По сути, речь идет об улучшении тестируемости и, как следствие, возможности переиспользования кода.
Для этого меняем метод .display()
базы данных StudentDatabase
на:
pub fn display_on(&self, output: &mut impl std::io::Write) -> io::Result<()> {
for student in self.db.as_slice() {
write!(output, "Name: {}, Age: {}\n", student.name, student.age)?;
}
Ok(())
}
затем вызов в src/main.rs
на:
student_db.display_on(&mut stdout()).expect("Unexpected output failure");
И создаем следующий тест:
#[test]
fn print_database() {
let mut db_ut = StudentDatabase::new();
db_ut.add("Test Student".to_string(), 34);
db_ut.add("Foo Bar".to_string(), 43);
let mut output: Vec<u8> = Vec::new();
db_ut.display_on(&mut output).expect("Unexpected output failure");
let result = String::from_utf8_lossy(&output);
assert_eq!(result, "Name: Test Student, Age: 34\nName: Foo Bar, Age: 43\n");
}
Оба теста проходят:
running 2 tests
test db::tests::add_to_database ... ok
test db::tests::print_database ... ok
Заключение
Итак, в несколько этапов мы усовершенствовали код. Сначала он выглядел так, словно его написал подросток-новичок на заре своего обучения программированию. Затем мы привели его в состояние, близкое к уровню младшего инженера с двухмесячным стажем на первом месте работы. Цель статьи — не обидеть кого-нибудь, а подчеркнуть роль опыта в проектировании базовой структуры кода. Написать спагетти-код просто, гораздо сложнее тщательно спланировать структуру кода с учетом принципа разделения ответственности. Имея 25-летний опыт программирования за плечами, я все еще учусь делать это как можно лучше. Хороший программист никогда не перестает учиться.
Я знаю, что в варианте после рефакторинга есть недостатки. Но статья утратит свой изначальный смысл, если довести код до надлежащего состояния. Кроме того, в этом случае становится гораздо сложнее объяснять вносимые изменения. Читатели сами могут попрактиковаться и найти допущенные недочеты, а также подумать, как дополнительные модульные тесты могут помочь избежать ошибок.
Я внес основные изменения в код, требующий улучшения, и для информации сопроводил их пояснениями. Ни одно из показанных решений не является окончательным, но большинство из них близки к тому, что сделал бы любой опытный программист в первую очередь. Однако у вас есть право иметь собственное мнение по этому поводу.
Читайте также:
- Как создать HTTP-фреймворк «Hello World!» на Rust
- Почему я перехожу с Python на Rust
- Рост производительности машинного обучения с Rust. Часть 2
Читайте нас в Telegram, VK и Дзен
Перевод статьи Michał Fita: Rust Refactoring for Beginners