В предыдущей статье мы написали простое приложение для хранения заметок в памяти. В данной статье мы доработаем его. Теперь приложение будет хранить заметки в файле. Попутно разберёмся со следующими концепциями:
процедурный и объектно-ориентированный подходы,
инкапсуляция,
структуры и методы,
модули,
тестирование.
От процедурного подхода к объектно-ориентированному
Первая версия приложения была реализована с использованием процедурного подхода. Мы написали четыре процедуры (помимо main
), которые описывают логику нашего приложения:
print_menu()
,read_input()
,show_notes(notes: &Vec)
,add_note(notes: &mut Vec)
.
Такой подход практически напрямую отображает алгоритм работы нашего приложения, за счёт чего отлично подходит для первого знакомства с языком. С другой стороны, процедурный подход имеет свои недостатки. Например, если мы захотим заменить реализацию хранилища заметок с вектора на файл, то нам придётся менять все процедуры, которые принимают вектор и работают с ним. Объектно-ориентированный подход, выглядит немного сложнее для понимания, зато имеет ряд приемуществ.
Объектно-ориентированный подход основан на трёх принципах:
Инкапсуляция,
Полиморфизм,
Наследование.
Обсуждение полиморфизма и наследования оставим для следующих статей, а пока, нам пригодиться только инкапсуляция - возможность скрывать детали реализации за публичным интерфейсом. Под публичным интерфейсом будем понимать набор данных и операций, которые предоставляет объект.
Объектно-ориентированный подход позволяет спрятать детали реализации некоторой сущности (объекта). За счёт этого, код, использующий объект, вынужден полагаться только на публичный интерфейс этого объекта. Таким образом, если менять детали реализации объекта, не меняя его интерфейс, то код, полагающийся только на публичный интерфейс, будет работать, и не потребует каких-либо изменений.
Более того, объектно-ориентированный подход упрощает использование механизма, который значительно снижает вероятность ошибки при изменении кода. Этот механизм называется тесты. Идея довольно проста:
Пишем код, который проверяет, правильно ли операции из публичного интерфейса выполняют свои задачи. Например, проверяет, что метод добавления заметки, действительно, добавил заметку в конец списка.
Выполняем данный код при каждом изменении, чтобы убедиться, что ничего не сломали.
План работы
Для демонстрации описанного выше, выполним следующие шаги:
-
Создадим тип
Notes
, объекты которого будут предоставлять публичный интерфейс с операциями:добавление заметки,
получения списка заметок.
Реализуем эти операции используя, как и раньше, вектор для хранения списка заметок в памяти.
Напишем тесты, которые будут проверять корректность поведения нашего типа.
Изменим код нашего приложения таким образом, чтобы оно использовало объект типа Notes для хранения заметок.
Изменим реализацию типа
Notes
так, чтобы для хранения заметок использовался файл, а не вектор. Проверим корректность изменений, выполнив тесты.
Создадим тип Notes
Один из механизмов создания нового типа в Rust - это структура. Структуры позволяют объединить логически связанные данные (их называют полями) и определить операции над этими данными (методы).
Весь код будем дописывать в файл main.rs
.
Создадим структуру Notes
, временно оставив её без полей:
/// Структура для хранения заметок.
struct Notes {}
Комментарии, которые начинаются с трёх слешей - документационные комментарии. Они описывают сущность, которая расположена следом за комментарием. Инструментарий Rust позволяет генерировать HTML документацию на основе документационных комментариев. Также, их поддерживают большинство сред разработки, отображая текст такого комментария в виде документации к сущности, к которой он относится.
Опишем набор методов этой структуры, с которыми мы будем работать. Реализацию пока опустим:
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new() -> Self {
Self {} // В фигурных скобках, обычно, инициализируют поля, но сейчас их пока нет.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
todo!() // Аварийно завершает работу приложения с сообщением "not implemented".
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
todo!() // Аварийно завершает работу приложения с сообщением "not implemented".
}
}
Внутри блока impl Notes {}
перечислены методы, которые предоставляет наша структура. Методы с модификатором pub
составляют её публичный интерфейс. Сейчас структура не содержит не публичных (приватных) методов.
new()
будет использоваться для создания объекта типаNotes
. Этот метод ничего не принимает, так как нам не нужны какие-либо данные для инициализации пустого списка заметок. Метод возвращает объект типаNotes
, вместо которого используем ключевое словоSelf
.Self
- это псевдоним для типа, для которого мы описываем методы. В текущем контексте, не имеет значения, что использовать:Self
илиNotes
. В более сложных случаях, использованиеSelf
даёт некоторые преимущества. Поэтому, для единообразия, принято использоватьSelf
и в простых случаях.add(&mut self, new_note: String)
будет использоваться для добавления новой заметки. Он принимает два аргумента. Первый из них&mut self
- мутабельная ссылка на объект структуры. Эта ссылка позволяет нам читать и изменять поля структуры, а также вызывать другие методы, которые принимают ссылку наself
. Второй аргумент - это новая заметка, которую мы будем добавлять в список.list(&self) -> Vec<String>
будет возвращать текущий список заметок. Метод принимает ссылку на объект структуры. Ссылки без ключевого словаmut
называют иммутабельными, так как они не позволяют менять объект на который ссылаются. Тем не менее, мы можем читать данные объекта по иммутабельной ссылке, что нам и требуется для получения списка заметок.
Сейчас реализован только метод new()
. Остальные используют макрос todo!()
, который позволяет отложить реализацию метода на будущее. Если макрос todo!()
будет выполнен, то работа приложения аварийно завершиться с сообщением о том, что приложение пыталось выполнить операцию, которая ещё не реализована. Добавим в нашу структуру Notes
реализацию функционала хранения заметок в памяти с использованием вектора:
/// Структура для хранения заметок.
struct Notes {
data: Vec<String>, // Теперь структура содержит вектор заметок
}
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new() -> Self {
Self { data: Vec::new() } // Инициализируем поле data, записывая в него пустой вектор.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
self.data.push(new_note) // Добавляем заметку в вектор.
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
self.data.clone() // Возвращаем копию списка заметок.
}
}
Теперь структура содержит поле data, в котором хранится вектор заметок. Конструкции вида self.some_name
предоставляет доступ к сущности (полю, методу, ...) с именем some_name
, относящемуся к структуре, для которой мы реализуем метод. Например, self.data
даёт нам доступ к вектору, который хранится в структуре Notes
, позволяя вызывать методы вектора, такие как, уже знакомый нам, push()
, или метод получения копии вектора clone()
. Просто вернуть из метода self.data
не позволяет нам концепция владения, о которой мы поговорим в следующих статьях. Поэтому мы копируем данные вектора.
Изменим логику нашего приложения, чтобы оно использовало нашу новую структуру:
fn main() {
// Создаём мутабельный объект типа Notes, в котором будут храниться заметки и
// сохраняем его в переменную `notes`.
let mut notes = Notes::new();
loop {
print_menu();
let command = read_input();
match command.trim() {
"show" => show_notes(¬es.list()), // Для получения списка заметок используем метод list().
"add" => notes.add(read_input()), // Для добавления заметки используем метод add().
_ => break,
}
}
}
fn print_menu() {
println!();
println!();
println!("**** PROGRAM MENU ****");
println!("Enter command:");
println!("'show' - show all notes");
println!("'add' - add new note");
println!("other - exit");
}
fn read_input() -> String {
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer).unwrap();
buffer
}
fn show_notes(notes: &Vec<String>) {
println!();
for note in notes {
println!("{}", note)
}
}
/// Структура для хранения заметок.
struct Notes {
data: Vec<String>, // Теперь структура содержит вектор заметок
}
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new() -> Self {
Self { data: Vec::new() } // Инициализируем поле data, записывая в него пустой вектор.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
self.data.push(new_note) // Добавляем заметку в вектор.
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
self.data.clone() // Возвращаем копию списка заметок.
}
}
Для лаконичности кода, старые комментарии были убраны. Прокомментированы только изменённые строки. Подробнее об изменениях:
let mut notes = Notes::new();
Создание объекта notes мутабельным позволяет нам вызывать методы, принимающие первым аргументом &mut self. Без модификатора mut, мы не смогли бы вызывать метод add().show_notes(¬es.list())
. Функцияshow_notes()
принимает ссылку на вектор строк. Поэтому мы должны передавать туда ссылку на результат методаlist()
, который возвращает вектор строк.функция
add_note()
была удалена за ненадобностью. Весь её функционал теперь выполняет методadd()
структурыNotes
.
Запустим код и убедимся, что программа работает корректно:
cargo run
Прячем детали реализации
В текущем коде у нас есть доступ к деталям реализации нашего хранилища из функции main. Например, сразу после создания объекта notes
, мы можем добавить строку в его вектор:
let mut notes = Notes::new();
notes.data.push(String::new());
А это именно то, что мы хотели запретить. Ведь, если вектор общедоступен, то при замене его на файл, придётся менять весь код, который этот вектор использует. Тут нам на помощь приходит ещё один механизм Rust - модули.
Модули позволяют объединять логически связанные сущности в группы. В рамках одного модуля приватные данные и методы разных сущностей доступны друг-другу. Поэтому функция main
имеет доступ к полю data
, например. Но, если поместить структуру Notes
в другой модуль, то поле data станет недоступным. Так и поступим:
fn main() {
// Добавляем к имени нашего типа Notes модуль notes, в котором его следует искать.
let mut notes = notes::Notes::new();
loop {
print_menu();
let command = read_input();
match command.trim() {
"show" => show_notes(¬es.list()),
"add" => notes.add(read_input()),
_ => break,
}
}
}
fn print_menu() {
println!();
println!();
println!("**** PROGRAM MENU ****");
println!("Enter command:");
println!("'show' - show all notes");
println!("'add' - add new note");
println!("other - exit");
}
fn read_input() -> String {
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer).unwrap();
buffer
}
fn show_notes(notes: &Vec<String>) {
println!();
for note in notes {
println!("{}", note)
}
}
// Теперь структура содержится в модуле
mod notes {
/// Структура для хранения заметок.
pub struct Notes {
data: Vec<String>, // Теперь структура содержит вектор заметок
}
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new() -> Self {
Self { data: Vec::new() } // Инициализируем поле data, записывая в него пустой вектор.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
self.data.push(new_note)
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
self.data.clone()
}
}
}
Теперь, при попытке обратиться к полю data
, получим сообщение об ошибке: попытка обращения к приватному полю.
Пишем тесты
Прежде чем мы заменим вектор на файл, напишем тесты, которые проверяют корректность работы структуры Notes
. В Rust присутствует встроенная система тестирования. Для её использования в модуль notes добавим:
#[cfg(test)] // Компилируем этот модуль только при запуске тестов. В самом приложении он не нужен.
mod tests { // Модуль, содержащий тесты.
use super::Notes; // Используем тип notes из родительского модуля.
#[test]
fn add_note() {
// Создадим новый объект типа Notes.
let mut notes = Notes::new();
// Добавляем заметку.
let note = String::from("hello");
notes.add(note.clone());
// Проверяем, добавилась ли заметка.
assert_eq!(¬e, notes.list().last().unwrap());
}
#[test]
fn notes_len() {
// Количество новых заметок
const COUNT: usize = 10;
let mut notes = Notes::new(path);
// Количество заметок до добавления новых.
let initial_size = notes.list().len();
// Для значений counter с 0 до COUNT ...
for counter in 0..COUNT {
// ... добвляем заметку - текстовое представление значения счётчика.
notes.add(counter.to_string())
}
let notes_list = notes.list();
// Проверяем, что добавилось ровно COUNT заметок.
assert_eq!(notes_list.len() - initial_size, COUNT);
}
}
Подробнее о коде:
Тесты пишем таким образом, чтобы они работали и в случае, если после создания нового объекта Notes он уже будет содержать заметки. Загружать из файла, например.
#[cfg(test)]
Условная компиляция. Модуль tests будет компилироваться только для запуска тестов. Он не используется для работы приложения и поэтому его не нужно компилировать, когда мы хотим собрать приложение.use super::Notes;
Импорт типаNotes
из родительского модуля. ТипNotes
содержится не в модулеtests
, а во внешнем. Поэтому, для его использования, надо либо каждый раз указывать полный путьlet notes = super::Notes::new();
, либо один раз импортировать, что мы и сделали.#[test]
- помечаем функцию, как тест. Такие функции запускаются при запуске тестов.assert_eq!(a, b)
- утверждение, что a равно b. Если хотя бы одно утверждение в тесте окажется ложным, то тест считается проваленным.const COUNT: usize = 10;
Объявляем внутри функции константу типаusize
(целое число) равную 10.for counter in 0..COUNT {...}
Запускаем цикл, в котором будетCOUNT
итераций со значениями counter c0
поCOUNT-1
, включительно.
Теперь, выполнив команду cargo test
, мы увидим, что оба наших теста завершились успехом:
running 2 tests
test notes::tests::add_notes ... ok
test notes::tests::notes_len ... ok
Имея в наличии тесты, мы можем спокойно менять реализацию типа Notes
. Ведь, если мы что-то сломаем, то запуск тестов нам это покажет.
Храним заметки в файле
Заменим реализацию Notes
на следующую:
// Структура для хранения заметок.
pub struct Notes {
path: String, // Храним в структуре путь к файлу, с которым будем работать.
}
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new(path: String) -> Self {
Self { path } // Сохраняем путь к файлу с которым будем работать.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
// Открываем файл
let mut file = std::fs::OpenOptions::new() // Создаём экземпляр типа OpenOptions.
.append(true) // Будем добавлять данные в файл.
.create(true) // Создавать его, в случае отсутствия.
.open(&self.path) // Открываем файл.
.unwrap(); // Аварийно завершаем работу, если не удалось открыть файл.
// Пишем в файл байты строки, предварительно обрезая служебные символы.
file.write_all(new_note.trim().as_bytes()).unwrap();
// Добавляем в файл символ конца строки, по которому будем разделять заметки.
file.write_all(b"\n").unwrap();
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
// Читаем содержимое файла. Если не удалось - используем вместо него пустую строку.
let file_content = std::fs::read_to_string(&self.path).unwrap_or(String::new());
// Разбиваем содержимое построчно, преобразуем каждую подстроку в строку, и собираем всё в вектор.
file_content.lines().map(String::from).collect()
}
}
После этих изменений компилятор начнёт сообщать об ошибках, связанных с тем, что мы всё-таки, немного изменили публичный интерфейс структуры Notes
. Метод new()
теперь принимает путь к файлу, в котором будут храниться заметки. Поправим код, чтобы убрать эти ошибки. Полный код:
fn main() {
// Путь к файлу с заметками.
let path = String::from("notes.txt");
// Создаём новый объект типа Notes.
let mut notes = notes::Notes::new(path);
loop {
print_menu();
let command = read_input();
match command.trim() {
"show" => show_notes(¬es.list()),
"add" => notes.add(read_input()),
_ => break,
}
}
}
fn print_menu() {
println!();
println!();
println!("**** PROGRAM MENU ****");
println!("Enter command:");
println!("'show' - show all notes");
println!("'add' - add new note");
println!("other - exit");
}
fn read_input() -> String {
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer).unwrap();
buffer
}
fn show_notes(notes: &Vec<String>) {
println!();
for note in notes {
// выводим её на экран.
println!("{}", note)
}
}
mod notes {
use std::io::Write;
/// Структура для хранения заметок.
pub struct Notes {
path: String,
}
impl Notes {
/// Создание нового объекта типа Notes.
pub fn new(path: String) -> Self {
Self { path } // Инициализируем поле data, записывая в него пустой вектор.
}
/// Добавление заметки.
pub fn add(&mut self, new_note: String) {
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)
.unwrap();
file.write_all(new_note.trim().as_bytes()).unwrap();
file.write_all(b"\n").unwrap();
}
/// Получение списка заметок.
pub fn list(&self) -> Vec<String> {
let file_content = std::fs::read_to_string(&self.path).unwrap_or(String::new());
file_content.lines().map(String::from).collect()
}
}
#[cfg(test)]
mod tests {
use super::Notes;
#[test]
fn add_note() {
let path = String::from("test_notes1.txt"); // Путь к файлу с заметками.
let mut notes = Notes::new(path.clone());
let note = String::from("hello");
notes.add(note.clone());
assert_eq!(¬e, notes.list().last().unwrap());
}
#[test]
fn notes_len() {
const COUNT: usize = 10;
let path = String::from("test_notes2.txt"); // Путь к файлу с заметками.
let mut notes = Notes::new(path);
let initial_size = notes.list().len();
for counter in 0..COUNT {
notes.add(counter.to_string())
}
let notes_list = notes.list();
assert_eq!(notes_list.len() - initial_size, COUNT);
}
}
}
Теперь для создания экземпляра Notes
мы указываем путь к файлу, где будут храниться заметки. Как видно из кода, логика работы приложения не изменилась. Поменялся лишь вызов метода new()
.
В тестах используются разные файлы, чтобы тесты могли работать независимо.
Запустив тесты, увидим, что они по-прежнему успешно выполняются. А значит, весьма вероятно, что и само приложение работает корректно. Убедимся в этом, выполнив cargo run
. Теперь, после выхода из приложения, заметки не удаляются, так как они хранятся в файле.
Итог
Мы выполнили задачи, поставленные в начале статьи:
-
Создали тип
Notes
, объекты которого предоставляют публичный интерфейс с операциями:добавление заметки,
получения списка заметок.
Реализовали эти операции используя, вектор для хранения списка заметок в памяти.
Написали тесты, которые проверяют корректность поведения нашего типа.
Изменили код нашего приложения таким образом, чтобы оно использовало объект типа
Notes
для хранения заметок.Изменили реализацию типа
Notes
так, чтобы для хранения заметок использовался файл, а не вектор.Проверили работоспособность новой реализации.
Как видно, нам не удалось полностью избежать изменений кода, использующего структуру Notes
. При переходе от вектора к файлу мы изменили метод new()
, который является частью публичного интерфейса. В следующих статьях рассмотрим инструменты Rust, которые позволяют отделять часть функционала типа, ограничивая список доступных для использования методов. Это позволяет писать код, не зависящий от конкретного типа используемого объекта, что и называют полиморфизмом. Забегая вперёд, речь пойдёт о трейтах и дженериках.
В заключение приглашаю всех на бесплатный урок, где узнаем, что Rust - мультипарадигменный современный язык, который можно применять не только для написания бэкендов, но и для фронтенд-разработки. Это позволяет использовать только один язык для создания полноценного веб-приложения. Поговорим о том, что Rust успешно занял развивающуюся нишу компиляции в WebAssembly. Увидим, что Rust может использоваться не только как основной, но и как вспомогательный инструмент для уже существующих веб-проектов.
Комментарии (5)
CaptainCrocus
19.10.2022 10:19+3плюс нумерацию частей в заголовке, задающую порядок статей, пожалуйста, — очень удобно потом искать/читать. Спасибо
sairus777
19.10.2022 14:07Странно, что вы упоминаете интерфейс и инкапсуляцию без демонстрации объективной необходимости, ЗАЧЕМ так делать: "вот мы просто так скроем из main потроха структуры,но могли бы и не делать этого".
Инструменты должны обуславливаться задачей, а не просто соглашениями и правилами языка (иначе решение задачи мы променяем на карго-культ).
При этом игнорируете то место, где наличие интерфейса действительно могло бы понадобиться - это переключение между разными способами хранения заметок.
F3kilo Автор
20.10.2022 08:34+1В статье я стараюсь объяснить, зачем мы инкапсулируем данные. Возможно, действительно, стоило это подкрепить примером.
Переключение между реализациями (полиморфизм) - тема слудующей статьи. Эта и так получилась довольно объёмной.
OldFisher
Я понимаю, есть хаброхабы, теги и всё такое, но всё же было бы полезно упоминать Rust в заголовке.