Начнём с самого простого и, при этом, самого важного вопроса...
Что мы будем разрабатывать?
Первый этап, практически, любой разработки - выработка требований. Нужно определиться, какую проблему будет решать наше приложения и какой набор возможностей для этого будет предоставлять.
С проблемой мы определились ещё в первом абзаце - хранить заметки. Можно обдумать некоторые детали, например:
Где хранить заметки?
Как выводить заметки?
Какой пользовательский интерфейс будет у приложения?
На эти вопросы можно придумать множество разных ответов:
Хранить заметки можно в: памяти, файле, базе данных...
Выводить заметки можно: в порядке добавления, в алфавитном порядке, по датам...
Варианты пользовательского интерфейса: в командной строке, графический, веб-интерфейс...
Остановимся на самых простых вариантах, чтобы приложение было максимально простым:
Храним заметки в памяти. Это означает, что наши заметки будут существовать, только пока запущено приложение.
Выводить их будем в порядке добавления, а значит, нам не потребуется дополнительная сортировка.
Пользовательский интерфейс организуем через командную строку, а следовательно, мы сможем обойтись без сторонних библиотек, так как функционал для работы с командной строкой (стандартный ввод/вывод), обычно, присутствует в стандартной библиотеке* большинства языков.
*стандартная библиотека - набор программных сущностей, доступных для вызова из любой программы, написанной на этом языке и присутствующих во всех реализациях языка.
Итак, у нас получился список возможностей нашего приложения. Теперь нам нужно свести каждую задачу к набору действий, которыми нас обеспечивает выбранный язык программирования, его стандартная библиотека, либо сторонние библиотеки. Этот процесс называют декомпозицией задачи.
Декомпозиция задачи
Для начала определимся со способом хранения каждой заметки: пусть это будет обычная Rust-строка (тип String
). На самом деле, Rust-строки не совсем обычные. Они хранят свои данные в UTF-8 кодировке, предоставляя возможность использовать в тексте не только любой язык, но и различные вспомогательные символы, например, эмодзи.
Далее, нам нужно найти способ хранить введённые пользователем заметки. Для хранения данных обычно используют структуры данных. Существует множество различных структур данных. Каждая предоставляет различные возможности по добавлению/удалению элементов и доступу к ним. Сейчас нам нужно уметь добавлять новые заметки в конец списка и выводить их все разом в порядке добавления. Для этого мы будем использовать одну из самых простых структур данных - вектор (тип Vec
). Он представляет собой непрерывную в памяти последовательность элементов, размер которой может расти по мере добавления элементов в конец последовательности. Как раз, то, что нужно.
Теперь опредилимся с выводом заметок. Мы решили использовать интерфейс коммандной строки для взаимодействия с пользователем. Для этого воспользуемся макросом println!
, который предоставляет Rust для передачи строк в стандартный вывод. Макросы в раст - очень мощный и довольно сложный инструмент. Разбор этого механизма выходит за рамки статьи. Остановимся на том, что использование макроса println!
позволяет передавать произвольное число параметров, которые будут подставлены в строку-шаблон для последующего вывода на экран. Пример:
println!("Меня зовут {}. Мне {} лет.", "Иван", 30);
// Вывод:
// Меня зовут Иван. Мне 30 лет.
//
// Фигурные скобки заменились на текстовую интерпретацию значений,
// перечисленных после строкового шаблона
Последнее, чего нам не хватает - это механизм, позволяющий читать пользовательский ввод в нашем интерфейсе командной строки. Для этого воспользуемся абстракцией над стандартным вводом, которую предоставляет стандартная библиотека - типом Stdin
. Данный тип позволяет получать данные из стандартного ввода, как в виде сырого потока байт, так и интерпретируя ввод в виде строк. Нам интересен последний вариант.
Теперь, имея инструментарий для всех необходимых нам действий, можно приступить к созданию поректа и написанию кода.
Установка инструментов разработки Rust
Все операции над исходным кодом Rust производит с помощью набора приложений, установить которые можно воспользовавшись инструкцией с официального сайта.
Создание проекта
Теперь, когда все необходимые инструменты установлены, воспользуемся менеджером пакетов cargo для создния проекта:
cargo new notes_list
Данная команда создаст директорию с названием notes_list, в которую поместит следующие элементы:
Cargo.toml
файл, содержащий базовую конфигурацию проекта.Поддиректорию
src
с файломmain.rs
, содержащим исходный код hello world приложения.Пустой git репозиторий.
Перейдя в директорию notes_list
, можно убедиться, что созданный нами шаблонный проект собирается и запускается:
cargo run
В результате, мы должны увидеть информацию о сборке и запуске приложения:
Compiling notes_list v0.1.0 (/home/f3/work/otus/notes_list)
Finished dev [unoptimized + debuginfo] target(s) in 0.44s
Running `target/debug/notes_list`
А также вывод приложения:
Hello, world!
Формируем алгоритм
Для начала, сформируем общий алгоритм работы программы:
Создаём вектор, в котором будут хранится заметки.
Выводим пользовательское меню в терминале.
Читаем ввод пользователя.
Если пользователь ввёл команду "show", отображаем все заметки из вектора.
Если пользователь ввёл команду "add", то читаем пользовательский ввод и добавляем новую заметку.
Если пользователь ввёл что-то другое, выходим из программы.
Нам нужно, чтобы пользователь мог добавлять/просматривать заметки многократно, поэтому пункты 2-6 будем выполнять циклично, повторяя их, пока не будет выполнено условие пункта 6 для выхода из программы.
Пишем код
Опишем в общем виде логику программы, согласно сформированному алгоритму:
fn main() {
// Создаём мутабельный вектор, в котором будут храниться заметки и
// сохраняем его в переменную `notes`.
let mut notes = Vec::new();
// Запускам цикл, который будет выполнять операции многократно,
// пока не дойдёт до операции `break` - выход из цикла.
loop {
// Вызываем функцию для вывода меню.
print_menu();
// Читаем строковую команду, введёную пользователем и
// сохраняем её в переменную `command`.
let command = read_input();
// Сравниваем команду с шаблонами и указываем действие для каждого:
match command.trim() {
// Если была введена команда для отображения заметок - отображаем.
"show" => show_notes(¬es),
// Если была введена команда для добавления заметок - добавляем.
"add" => add_note(&mut notes),
// Если пользователь ввёл что-нибудь другое - выходим из цикла.
_ => break,
}
}
}
Остановимся подробнее на некоторых моментах:
main()
- функция, с которой начнётся выполнение нашей программы. Программа завершиться, когда функция main закончит выполняться.let
- ключевое слово для объявления переменной.let mut
- объявление переменной, которую мы планируем менять.Vec::new()
- вызов метода new для типаVec
. Данный метод вернёт нам экземпляр типаVec
без содержимого.loop {...}
- цикл, выполняющийся бесконечно. Для выхода из него используется ключевое словоbreak
, либо возврат из функции (return
).match {...}
- операция соспоставления с шаблоном. После ключевого словаmatch
указывается то, что будем сопоствалять. В фигурных скобках, по очереди, указываются шаблоны (слева от=>
) и операции (справа от=>
), которые следует выполнять при совпадении с указанным шаблоном.command.trim()
- обрезаем служебные символы в конце пользовательского ввода. При вводе в терминал строки и нажатии Enter, к строке добавляется символ переноса строки. Он нам не нужен и команда trim() его отбросит.¬es
- ссылка на вектор с заметками. Ссылка позволяет читать данные из вектора.&mut notes
- мутабельная ссылка на вектор с заметками. В отличии от¬e
, позволяет менять данные вектора.break
позволяет выйти из цикла. В нашем случае из циклаloop
.После выхода из цикла завершится и функция
main
, а следовательно, и работа программы.
При попытке собрать приложение:
cargo build
Мы увидим ряд ошибок, в которых компилятор сообщает нам что не может найти функции:
print_menu()
,read_input()
,show_notes()
,add_note()
.
Исправим это, реализовав их.
fn print_menu() {
println!();
println!();
println!("**** PROGRAM MENU ****");
println!("Enter command:");
println!("'show' - show all notes");
println!("'add' - add new note");
println!("other - exit");
}
Функция print_menu()
не принимает аргументов и ничего не возвращает.
В каждой строке этой функции выводим на экран сообщения с помощью макроса println!
.
В начале выводи две пустые строки, чтобы отделить текст от предудущего.
fn read_input() -> String {
// Создаём мутабельную строку, в которую будем читать пользовательский ввод.
let mut buffer = String::new();
// Получаем объект типа `Stdin` из функции `stdin()` и вызываем метод
// `read_line()` для чтения пользовательского ввода.
// В метод передаём мутабельную ссылку на ранее созданый буфер,
// в который будут записаны данные.
std::io::stdin().read_line(&mut buffer).unwrap();
// Возвращаем буфер с пользовательским вводом из функции.
buffer
}
Функция read_input()
не принимает аргументов и возвращает строку.
Конструкция std::io::stdin()
интерпретируется как вызов функции stdin
из модуля io
находящегося в модуле std
(стандартная библиотека). Таким образом, запрашиваем у стандартной библиотеки объект, отвечающий за стандартный ввод. Далее, у этого объекта вызывается метод read_line()
, читающий строку из стандартного ввода. Метод read_line()
возвращает перечисление Result
, в котором, при успехе, содержится количество считанных байт, а в случае неудачи - ошибка. Чтение данных из стандартного ввода в строку может закончиться неудачей, если, например, данные не являются корректной UTF-8 строкой. У полученного объекта типа Result
мы вызываем метод unwrap()
, который:
В случае успеха, возвращает количество прочитанных байт, уже без обёртки. Оно нам не интересно и не используется в коде.
При ошибке, происходит, так называемая, паника. Работа программы завершается с ошибкой.
Таким образом, у нас нет возможности прочитать, и после этого использовать некорректные данные.
fn show_notes(notes: &Vec<String>) {
// Выводим пустую строку.
println!();
// Для каждой заметки в заметках ...
for note in notes {
// выводим её на экран.
println!("{}", note)
}
}
Цикл for
позволяет пройти по всем элементам вектора. На каждой итерации выводим заметку на экран.
fn add_note(notes: &mut Vec<String>) {
// Сообщаем пользователю, что можно вводить заметку.
println!();
println!("Enter note:");
// Читаем пользовательский ввод.
let input = read_input();
// Получаем подстроку без служебных символов и преобразуем её в строку.
let note = input.trim().to_string();
// Добавлем заметку в конец вектора.
notes.push(note);
}
Пользуемся написанной ранее функцией read_input()
для чтения ввода пользователя.input.trim().to_string()
- Обрезаем с помощью метода trim()
служебные символы у строки, содержащей пользовательский ввод и преобразуем полученную подстроку в строку, с помощью метода to_string()
.
Теперь наше приложение будет работать и предоставлять все ожидаемые возможности. В этом можно убедиться запустив его (cargo run) и следуя его инструкциям.
Полный код:
fn main() {
// Создаём мутабельный вектор, в котором будут храниться заметки и
// сохраняем его в переменную `notes`.
let mut notes = Vec::new();
// Запускам цикл, который будет выполнять операции многократно,
// пока не дойдёт до операции `break` - выход из цикла.
loop {
// Вызываем функцию для вывода меню.
print_menu();
// Читаем строковую команду, введёную пользователем и
// сохраняем её в переменную `command`.
let command = read_input();
// Сравниваем команду с шаблонами и указываем действие для каждого:
match command.trim() {
// Если была введена команда для отображения заметок - отображаем.
"show" => show_notes(¬es),
// Если была введена команда для добавления заметок - добавляем.
"add" => add_note(&mut notes),
// Если пользователь ввёл что-нибудь другое - выходим из цикла.
_ => 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();
// Получаем объект типа `Stdin` из функции `stdin()` и вызываем метод
// `read_line()` для чтения пользовательского ввода.
// В метод передаём мутабельную ссылку на ранее созданый буфер,
// в который будут записаны данные.
std::io::stdin().read_line(&mut buffer).unwrap();
// Возвращаем буфер с пользовательским вводом из функции.
buffer
}
fn show_notes(notes: &Vec<String>) {
// Выводим пустую строку.
println!();
// Для каждой заметки в заметках ...
for note in notes {
// выводим её на экран.
println!("{}", note)
}
}
fn add_note(notes: &mut Vec<String>) {
// Сообщаем пользователю, что можно вводить заметку.
println!();
println!("Enter note:");
// Читаем пользовательский ввод.
let input = read_input();
// Получаем подстроку без служебных символов и преобразуем её в строку.
let note = input.trim().to_string();
// Добавлем заметку в конец вектора.
notes.push(note);
}
Итог
Выполнив ряд шагов, мы прошли от идеи до реализации простого прилжения:
Формирование требований,
Декомпозиция задачи,
Формирование алгоритма,
Написание кода.
На курсе Rust developer мы рассматриваем все концепции языка, переходя от простых, описанных выше, к более сложным, таким как трейты, обобщённое программирование, умные указатели, многопоточность, архитектура и другие.
Комментарии (18)
kpmy
11.10.2022 16:32+1То есть, когда я указываю &mut для аргумента, его тип меняется? Был &Vec<String> - указатель на read-only список, а будет указатель на модифицируемый список &mut Vec<String>?
И в первом случае методы по добавлению будут недоступны?domix32
12.10.2022 02:15+2error[E0596]: cannot borrow `*notes` as mutable, as it is behind a `&` reference --> src/main.rs:8:5 | 7 | fn add_note(notes: &Vec<String>) { | ------------ help: consider changing this to be a mutable reference: `&mut Vec<String>` 8 | notes.push("test".to_string()); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `notes` is a `&` reference, so the data it refers to cannot be borrowed as mutable
franzose
11.10.2022 20:17+1Есть ли смысл вместо ссылки на вектор использовать ссылку на слайс?
fn show_notes(notes: &[String]) { } // или даже так fn show_notes(notes: &[&str]) { }
Cerberuser
12.10.2022 08:58+3Второе - не всегда, т.к., чтобы получить
&[&str]
, нужно иметь что-то типаVec<&str>
, его нельзя сделать напрямую изVec<String>
. Первое - да, всегда лучше, чем&Vec<String>
, поскольку с точки зрения API эти два типа отличаются только возможностью узнать capacity вектора, но при этом при использовании среза у нас на один уровень указателей меньше.AnthonyMikh
12.10.2022 22:09+1но при этом при использовании среза у нас на один уровень указателей меньше.
Дело даже не в этом. Использование
&Vec<String>
в сигнатуре принуждает вызывающую сторону выделять память в хипе под вектор, а вот с&[String]
можно сконструировать массив строк на стеке и отдать ссылку на него.
PROgrammer_JARvis
12.10.2022 19:23+9Как верно написал , просто так рассматривать
&Vec<String>
как&[&str]
не получится. Причина в том, что слайс&[T]
подразумевает последовательно идущие данные типаT
(с учётом выравниания), в то время как уString
представление включает в себя&str
, но не только его, из-за чего сугубо такой варинт без копирования (в новую структуру с новым представлением) не реализуем.Но есть гораздо более гибкий вариант сделать метод более универсальным без необходимости в лишних копированиях, к которому мы придём в несколько шагов улучшения изначального метода:
пусть вместо строго строки (а, фактически, нас волнует даже не
String
, а то, что мы получаем ссылку ан еёstr
-составляющую) будет абстрактное нечто, что можно преобразовать в&str
:
fn show_notes<T: AsRef<str>>(notes: &Vec<T>) { // Выводим пустую строку. println!(); // Для каждой заметки в заметках ... for note in notes { // выводим её на экран. println!("{}", note.as_ref()) } }
Я метода появился типовой параметр
T
, который должен быть чем-то, что может быть представлено как ссылка наstr
.на место вектора действительно можно поставить просто слайс (фактически, для вектора
Vec<T>
верно, что онAsRef[T]
, но тут в этом нет необходимости):
fn show_notes<T: AsRef<str>>(notes: &[T]) { // Выводим пустую строку. println!(); // Для каждой заметки в заметках ... for note in notes { // выводим её на экран. println!("{}", note.as_ref()) } }
наконец, вво имя красоты и компактности, воспользуемся синтаксическим сахаром, позволяющим описать п.1 более компактно:
fn show_notes(notes: &[impl AsRef<str>]) { // Выводим пустую строку. println!(); // Для каждой заметки в заметках ... for note in notes { // выводим её на экран. println!("{}", note.as_ref()) } }
По итогу, имеем метод, который принимает слайс, содержащий что-то, что можно представить как ссылку на
str
, работающий как соString
, так соstr
, так и с произвольными типами, реализующимиAsRef<str>
(при этом лежащими в любом контейнере, который представим как слайс):fn main() { let notes = vec!["foo".to_string(), "bar".to_string()]; show_notes(¬es); let notes = vec!["baz", "qux"]; show_notes(¬es); let notes = vec!["dora", "prgrm"]; show_notes(¬es); }
В качестве более продвинутого варианта, можно принимать даже не слайс (потому что непосредственной необходимости в последовательности данных у нас нет), а нечто, что можно последовательно обходить (итерироваться по этому), или даже то, почему можно устроить обход:
fn show_notes(notes: impl IntoIterator<Item = impl AsRef<str>>) { // Выводим пустую строку. println!(); // Для каждой заметки в заметках ... for note in notes { // выводим её на экран. println!("{}", note.as_ref()) } }
за счёт чего будет ещё большая гибкость:
let mut notes = HashMap::new(); notes.insert("a", "x"); notes.insert("b", "y"); show_notes(notes.keys()); show_notes(notes.values());
Разумеется, это не следует из этой статьи, поскольку в ней трейты не рассматривались, но, с точки зрения общего развития (и заманивания в секту Раста :) ) считаю это достаточно интересным.
Если интересен вопрос производительности, то, де факто, компилятор выполнит мономорфизацию, а именно создаст реализацию метода под каждый использующийся с ним тип (не каждый потенциально доступный, а именно каждый, который реально используется в программе), то есть, фактически, для оригинального варианта там будет всё тот же
&Vec<String>
, для других&[String]
,&Vec<&str>
и так далее.
mentin
12.10.2022 08:06+1std::io::stdin().read_line(&mut buffer).unwrap();
Что здесь обозначает вызов
read_line
я понял, и наверное поймут большинство знающих какой либо другой язык, объяснять не надо. А вот что такоеunwrap()
и зачем он тут нужен, совершенно не ясно, и в подробном разборе стоило бы упомянуть, а разъяснений не вижу.F3kilo Автор
12.10.2022 15:06Действительно. Я даже продумал текст на эту тему, а добавить забыл(
Добавлю сейчас.
Levitskyi
12.10.2022 15:04+1Очень понравился подобный формат статьи, будучи совсем новичком в Расте обнаружил для себя кучу полезного.
Вот если бы еще тему GUI затронуть, и немного ООП на простых, но реальных примерах, да чтоб еще и с зачатками каких либо паттернов проектирования - счастью не было бы предела.
F3kilo Автор
12.10.2022 15:08+1Да, я планировал, если статья окажется полезной, продолжить в том же духе. Возможно, развивая далее приложение для заметок.
zartarn
13.10.2022 14:08К doc.rust-lang.ru/book есть еще github.com/rust-lang/rustlings — цикл задач, привязан к учебнику и на практике позволяет попробовать то, что только что прочитано.
NerVik
13.10.2022 21:10В таких статьях мне всегда не хватает раздела который бы рассказывал как на это все написать тесты
webxed
Перейдя в директорию
notes_list
, можно убеться, что созданный нами шаблонный проект собирается и запускается:cartonworld
https://habr.com/ru/docs/help/hotkeys/