Начнём с самого простого и, при этом, самого важного вопроса...

Что мы будем разрабатывать?

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

С проблемой мы определились ещё в первом абзаце - хранить заметки. Можно обдумать некоторые детали, например:

  1. Где хранить заметки?

  2. Как выводить заметки?

  3. Какой пользовательский интерфейс будет у приложения?

На эти вопросы можно придумать множество разных ответов:

  1. Хранить заметки можно в: памяти, файле, базе данных...

  2. Выводить заметки можно: в порядке добавления, в алфавитном порядке, по датам...

  3. Варианты пользовательского интерфейса: в командной строке, графический, веб-интерфейс...

Остановимся на самых простых вариантах, чтобы приложение было максимально простым:

  1. Храним заметки в памяти. Это означает, что наши заметки будут существовать, только пока запущено приложение.

  2. Выводить их будем в порядке добавления, а значит, нам не потребуется дополнительная сортировка.

  3. Пользовательский интерфейс организуем через командную строку, а следовательно, мы сможем обойтись без сторонних библиотек, так как функционал для работы с командной строкой (стандартный ввод/вывод), обычно, присутствует в стандартной библиотеке* большинства языков.

*стандартная библиотека - набор программных сущностей, доступных для вызова из любой программы, написанной на этом языке и присутствующих во всех реализациях языка.

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

Декомпозиция задачи

Для начала определимся со способом хранения каждой заметки: пусть это будет обычная 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!

Формируем алгоритм

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

  1. Создаём вектор, в котором будут хранится заметки.

  2. Выводим пользовательское меню в терминале.

  3. Читаем ввод пользователя.

  4. Если пользователь ввёл команду "show", отображаем все заметки из вектора.

  5. Если пользователь ввёл команду "add", то читаем пользовательский ввод и добавляем новую заметку.

  6. Если пользователь ввёл что-то другое, выходим из программы.

Нам нужно, чтобы пользователь мог добавлять/просматривать заметки многократно, поэтому пункты 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(&notes),

            // Если была введена команда для добавления заметок - добавляем.
            "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() его отбросит.

  • &notes - ссылка на вектор с заметками. Ссылка позволяет читать данные из вектора.

  • &mut notes - мутабельная ссылка на вектор с заметками. В отличии от &note, позволяет менять данные вектора.

  • 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(&notes),

            // Если была введена команда для добавления заметок - добавляем.
            "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);
}

Итог

Выполнив ряд шагов, мы прошли от идеи до реализации простого прилжения:

  1. Формирование требований,

  2. Декомпозиция задачи,

  3. Формирование алгоритма,

  4. Написание кода.

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

Комментарии (18)


  1. webxed
    11.10.2022 11:54
    -2

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


    1. cartonworld
      11.10.2022 16:49
      +4

      [Ctrl + Enter] отправка сообщения об ошибке в выделенном фрагменте текста

      https://habr.com/ru/docs/help/hotkeys/


  1. kpmy
    11.10.2022 16:32
    +1

    То есть, когда я указываю &mut для аргумента, его тип меняется? Был &Vec<String> - указатель на read-only список, а будет указатель на модифицируемый список &mut Vec<String>?

    И в первом случае методы по добавлению будут недоступны?


    1. vitvakatu
      11.10.2022 16:53
      +2

      Да, все верно


    1. domix32
      12.10.2022 02:15
      +2

      error[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
      


  1. franzose
    11.10.2022 20:17
    +1

    Есть ли смысл вместо ссылки на вектор использовать ссылку на слайс?

    fn show_notes(notes: &[String]) {
    }
    
    // или даже так
    fn show_notes(notes: &[&str]) {
    }


    1. Cerberuser
      12.10.2022 08:58
      +3

      Второе - не всегда, т.к., чтобы получить &[&str], нужно иметь что-то типа Vec<&str>, его нельзя сделать напрямую из Vec<String>. Первое - да, всегда лучше, чем &Vec<String>, поскольку с точки зрения API эти два типа отличаются только возможностью узнать capacity вектора, но при этом при использовании среза у нас на один уровень указателей меньше.


      1. AnthonyMikh
        12.10.2022 22:09
        +1

        но при этом при использовании среза у нас на один уровень указателей меньше.

        Дело даже не в этом. Использование &Vec<String> в сигнатуре принуждает вызывающую сторону выделять память в хипе под вектор, а вот с &[String] можно сконструировать массив строк на стеке и отдать ссылку на него.


    1. PROgrammer_JARvis
      12.10.2022 19:23
      +9

      Как верно написал , просто так рассматривать &Vec<String> как &[&str] не получится. Причина в том, что слайс &[T] подразумевает последовательно идущие данные типа T (с учётом выравниания), в то время как у String представление включает в себя &str, но не только его, из-за чего сугубо такой варинт без копирования (в новую структуру с новым представлением) не реализуем.

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

      1. пусть вместо строго строки (а, фактически, нас волнует даже не String, а то, что мы получаем ссылку ан её str-составляющую) будет абстрактное нечто, что можно преобразовать в &str:

      fn show_notes<T: AsRef<str>>(notes: &Vec<T>) {
          // Выводим пустую строку.
          println!();
      
          // Для каждой заметки в заметках ...
          for note in notes {
              // выводим её на экран.
              println!("{}", note.as_ref())
          }
      }
      

      Я метода появился типовой параметр T, который должен быть чем-то, что может быть представлено как ссылка на str.

      1. на место вектора действительно можно поставить просто слайс (фактически, для вектора Vec<T> верно, что он AsRef[T], но тут в этом нет необходимости):

      fn show_notes<T: AsRef<str>>(notes: &[T]) {
          // Выводим пустую строку.
          println!();
      
          // Для каждой заметки в заметках ...
          for note in notes {
              // выводим её на экран.
              println!("{}", note.as_ref())
          }
      }
      
      1. наконец, вво имя красоты и компактности, воспользуемся синтаксическим сахаром, позволяющим описать п.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(&notes);
      
          let notes = vec!["baz", "qux"];
          show_notes(&notes);
      
          let notes = vec!["dora", "prgrm"];
          show_notes(&notes);
      }
      
      1. В качестве более продвинутого варианта, можно принимать даже не слайс (потому что непосредственной необходимости в последовательности данных у нас нет), а нечто, что можно последовательно обходить (итерироваться по этому), или даже то, почему можно устроить обход:

      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> и так далее.


      1. franzose
        12.10.2022 20:37

        @PROgrammer_JARvis@Cerberuserогромное спасибо за ответы, познавательно!


  1. mentin
    12.10.2022 08:06
    +1

    std::io::stdin().read_line(&mut buffer).unwrap();

    Что здесь обозначает вызов read_line я понял, и наверное поймут большинство знающих какой либо другой язык, объяснять не надо. А вот что такое unwrap() и зачем он тут нужен, совершенно не ясно, и в подробном разборе стоило бы упомянуть, а разъяснений не вижу.


    1. F3kilo Автор
      12.10.2022 15:06

      Действительно. Я даже продумал текст на эту тему, а добавить забыл(

      Добавлю сейчас.


      1. F3kilo Автор
        12.10.2022 15:23

        Готово. Теперь есть короткое описание того, как, в данном случае, используется механизм обработки ошибок в Rust.


        1. mentin
          13.10.2022 21:50

          Ага я уже нашел на stack overflow, но все равно спасибо, хорошее объяснение.


  1. Levitskyi
    12.10.2022 15:04
    +1

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

    Вот если бы еще тему GUI затронуть, и немного ООП на простых, но реальных примерах, да чтоб еще и с зачатками каких либо паттернов проектирования - счастью не было бы предела.


    1. F3kilo Автор
      12.10.2022 15:08
      +1

      Да, я планировал, если статья окажется полезной, продолжить в том же духе. Возможно, развивая далее приложение для заметок.


    1. zartarn
      13.10.2022 14:08

      К doc.rust-lang.ru/book есть еще github.com/rust-lang/rustlings — цикл задач, привязан к учебнику и на практике позволяет попробовать то, что только что прочитано.


  1. NerVik
    13.10.2022 21:10

    В таких статьях мне всегда не хватает раздела который бы рассказывал как на это все написать тесты