$ sudo teach IT
Модуль 4 · Свои типы · Урок 4.4

Практикум: команды копилки

Вы соберёте цикл, который превращает набранную строку в Command и выполняет её через match.

Практикум~30 минутСредний

За этот модуль у «Копилки» появились свои типы: структура Expense с методами new и describe, перечисление Category с функциями from_word и title и перечисление Command. Пока слово-команда выбирается длинной цепочкой match прямо в main, а функция run печатает заглушки. Сегодня переложим разбор в отдельную функцию parse_command, которая превращает набранную строку в Command, а цикл в main станет короче и выполнит команду сам. Функция run при этом не понадобится.

Что мы построим

Запустите готовую программу и посмотрите, как она будет общаться. Строки, которые вводит пользователь, здесь набраны обычным текстом, остальное — ответы «Копилки»:

Вывод
Копилка 0.4
Наберите помощь, чтобы увидеть команды.
добавить 350 еда обед в столовой
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
помощь
Команды:
  добавить <сумма> <категория> [заметка] - записать трату
  ...
танцуй
Не понял команду. Наберите помощь
выход
Итого потрачено: 350 руб. из 30000 руб.

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

parse_command: из строки в Command

Представьте сортировщика на почте. Он не читает письмо целиком — смотрит на первое слово адреса и кладёт конверт в нужный ящик. Если адрес неразборчив, письмо уходит в корзину «не доставлено». Наша функция делает то же самое: смотрит на первое слово строки и по нему выбирает вариант Command. Такую работу программисты называют разбором (по-английски parsing), а функцию — парсером.

Результат разбора — Option<Command>. Мы уже знаем смысл этой коробки: Some(команда), если строка понятна, и None, если нет. Сначала скелет, без ветки «добавить»:

Rust · main.rs
fn parse_command(line: &str) -> Option<Command> {
    match first_word(line) {
        "список" => Some(Command::List),
        "отчёт" | "отчет" => Some(Command::Report),
        "помощь" => Some(Command::Help),
        "выход" => Some(Command::Quit),
        _ => None,
    }
}

Разбор по строкам

  • line: &str — строка, как её ввели; мы её только читаем, поэтому ссылка
  • match first_word(line) — берём первое слово (функция из прошлого модуля) и сверяем его с образцами — текстовыми литералами
  • "отчёт" | "отчет" — вертикальная черта значит «или»: люди пишут и с ё, и без, а команда одна
  • Some(Command::Help) — вариант без данных кладём в Some: «команда понятна, вот она»
  • _ => None — всё остальное — не команда; ветка _ нужна, потому что слов на свете больше, чем наших образцов

Теперь самая интересная ветка — «добавить». Ей нужны три вещи: сумма, категория и заметка. Если с суммой или категорией что-то не так, команда не получится, и мы вернём None. Для этого пригодится запись let ... else, которую мы разбирали вместе с Option: «положи значение в переменную, а если его нет — выполни блок после else, который обязан выйти из функции».

Rust · main.rs
"добавить" => {
    let rest = rest_after_first(line);
    let Some(amount) = first_word(rest).parse::<u32>().ok() else {
        return None;
    };
    let rest = rest_after_first(rest);
    let Some(category) = Category::from_word(first_word(rest)) else {
        return None;
    };
    let note = rest_after_first(rest);
    let note = if note.is_empty() { "без заметки" } else { note };
    Some(Command::Add(Expense::new(amount, category, note)))
}

Разбор по строкам

  • let rest = rest_after_first(line); — отбрасываем слово «добавить»: остаётся «350 еда обед в столовой»
  • first_word(rest).parse::<u32>().ok() — берём первое слово остатка и пробуем превратить в число; .ok() делает из результата обычный Option: число — Some, не число — None
  • let Some(amount) = ... else { return None; }; — если число получилось, оно лежит в amount; если нет — сразу выходим из parse_command с None
  • let rest = rest_after_first(rest); — сдвигаем остаток ещё на слово; старый rest затеняется новым
  • Category::from_word(first_word(rest)) — слово «еда» превращается в Category::Food; неизвестное слово даёт None, и мы выходим тем же приёмом
  • let note = ... if note.is_empty() — всё, что осталось, — заметка; пустую заменяем на «без заметки», чтобы в кавычках не стояла пустота
  • Some(Command::Add(Expense::new(amount, category, note))) — собираем трату, прячем её внутрь Command::Add, а команду кладём в Some

Почему без знака вопроса

Для таких цепочек в Rust есть оператор ?, он сократил бы каждый let ... else до одной строки. Но он работает вместе с Result, а с ним мы познакомимся в модуле про ошибки. Пока пишем длиннее, зато каждый шаг виден.

Главный цикл: match решает, что делать

Парсер готов, осталось подключить его к циклу чтения. Раньше мы после каждой строки писали цепочку if. Теперь результат разбора кладём в match — и компилятор следит, чтобы мы не забыли ни Some, ни None.

Rust · main.rs
match parse_command(&line) {
    Some(Command::Add(expense)) => {
        spent += expense.amount;
        println!("Записано: {}", expense.describe());
        println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
        print_status(spent, MONTH_LIMIT);
    }
    Some(Command::Help) => print_help(),
    Some(Command::Quit) => break,
    Some(_) => println!("Скоро научимся"),
    None => println!("Не понял команду. Наберите помощь"),
}

Разбор по строкам

  • parse_command(&line) — line — это String, а функции нужен &str; ссылка &line Rust превращает сам, как в уроке про строки
  • Some(Command::Add(expense)) — образец вскрывает две упаковки сразу: команда понятна Some, и это именно «добавить», а трата из неё достаётся в переменную expense
  • spent += expense.amount; — копим общую сумму; списка пока нет, и сумма живёт в одной переменной
  • Some(Command::Quit) => break — ветка match тоже может выйти из цикла: break прерывает ближайший loop
  • Some(_) — «команда понятна, но какая — неважно»: сюда попадут List и Report, которые мы научимся выполнять в следующем модуле
  • None — строка не разобралась — вежливо подсказываем про помощь

Трата переезжает

Строка Some(Command::Add(expense)) не копирует трату, а перемещает её из команды в переменную expense — владение переходит к нам. Дальше с ней можно работать как с обычной локальной переменной, но передавать «насовсем» в две разные стороны нельзя. Как это выглядит, показано ниже, в частых ошибках.

Ещё одна деталь: пользователь может нажать Enter на пустой строке. Чтобы не сыпать «Не понял команду», в цикле стоит проверка if line.trim().is_empty() { continue; } — пустую строку просто пропускаем.

Подсказка пользователю: print_help

Функция print_help ничего не принимает и ничего не возвращает: она только печатает список команд. Вынесли мы её в отдельную функцию, чтобы ветка match осталась короткой и чтобы потом было где добавить новые строки. Весь её текст вы увидите в полном коде ниже.

Практика: собираем main.rs целиком

Откройте проект kopilka (если его нет — cargo new kopilka) и замените всё содержимое src/main.rs кодом ниже. Он собирает всё, что мы знаем: в начале — константа и типы, затем разбор, затем main. От прошлого урока остались типы, first_word, rest_after_first, print_status и цикл чтения. Исчезли run и цепочка слов в main (их заменяет parse_command с match), а вместе с ними split_command, parse_amount и note_or_default: их работу делает parse_command. Шапка из print_header заменена двумя строками println! в начале main: теперь в ней версия 0.4 и подсказка про помощь.

Rust · main.rs
use std::io;

const MONTH_LIMIT: u32 = 30_000;

#[derive(Debug, Clone, Copy, PartialEq)]
enum Category {
    Food,
    Transport,
    Home,
    Fun,
    Other,
}

impl Category {
    fn from_word(word: &str) -> Option<Category> {
        match word {
            "еда" => Some(Category::Food),
            "транспорт" => Some(Category::Transport),
            "дом" => Some(Category::Home),
            "отдых" => Some(Category::Fun),
            "другое" => Some(Category::Other),
            _ => None,
        }
    }

    fn title(&self) -> &'static str {
        match self {
            Category::Food => "еда",
            Category::Transport => "транспорт",
            Category::Home => "дом",
            Category::Fun => "отдых",
            Category::Other => "другое",
        }
    }
}

#[derive(Debug)]
struct Expense {
    amount: u32,
    category: Category,
    note: String,
}

impl Expense {
    fn new(amount: u32, category: Category, note: &str) -> Self {
        Expense {
            amount,
            category,
            note: note.to_string(),
        }
    }

    fn describe(&self) -> String {
        format!(
            "{} руб., {}, «{}»",
            self.amount,
            self.category.title(),
            self.note
        )
    }
}

enum Command {
    Add(Expense),
    List,
    Report,
    Help,
    Quit,
}

fn first_word(line: &str) -> &str {
    line.split_whitespace().next().unwrap_or("")
}

fn rest_after_first(line: &str) -> &str {
    let line = line.trim();
    match line.find(' ') {
        Some(i) => line[i + 1..].trim_start(),
        None => "",
    }
}

fn parse_command(line: &str) -> Option<Command> {
    match first_word(line) {
        "добавить" => {
            let rest = rest_after_first(line);
            let Some(amount) = first_word(rest).parse::<u32>().ok() else {
                return None;
            };
            let rest = rest_after_first(rest);
            let Some(category) = Category::from_word(first_word(rest)) else {
                return None;
            };
            let note = rest_after_first(rest);
            let note = if note.is_empty() { "без заметки" } else { note };
            Some(Command::Add(Expense::new(amount, category, note)))
        }
        "список" => Some(Command::List),
        "отчёт" | "отчет" => Some(Command::Report),
        "помощь" => Some(Command::Help),
        "выход" => Some(Command::Quit),
        _ => None,
    }
}

fn print_help() {
    println!("Команды:");
    println!("  добавить <сумма> <категория> [заметка] - записать трату");
    println!("  список - показать траты (скоро)");
    println!("  отчёт - итоги по категориям (скоро)");
    println!("  помощь - показать эту подсказку");
    println!("  выход - закончить работу");
    println!("Категории: еда, транспорт, дом, отдых, другое");
}

fn print_status(spent: u32, limit: u32) {
    if spent > limit {
        println!("Статус: лимит превышен");
    } else if spent * 100 >= limit * 80 {
        println!("Статус: почти лимит");
    } else {
        println!("Статус: в норме");
    }
}

fn main() {
    println!("Копилка 0.4");
    println!("Наберите помощь, чтобы увидеть команды.");

    let mut spent: u32 = 0;
    let mut line = String::new();

    loop {
        line.clear();
        let bytes = io::stdin()
            .read_line(&mut line)
            .expect("не удалось прочитать строку");
        if bytes == 0 {
            break;
        }
        if line.trim().is_empty() {
            continue;
        }

        match parse_command(&line) {
            Some(Command::Add(expense)) => {
                spent += expense.amount;
                println!("Записано: {}", expense.describe());
                println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
                print_status(spent, MONTH_LIMIT);
            }
            Some(Command::Help) => print_help(),
            Some(Command::Quit) => break,
            Some(_) => println!("Скоро научимся"),
            None => println!("Не понял команду. Наберите помощь"),
        }
    }

    println!("Итого потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
}

Запускаем из папки проекта:

Терминал
cargo run

Программа ждёт ввода. Набирайте строки из таблицы и сверяйте ответы. Чтобы закончить, наберите выход (или нажмите Ctrl+D на macOS и Linux, Ctrl+Z и Enter на Windows).

Вы вводитеПрограмма отвечает
добавить 350 еда обед в столовойЗаписано: 350 руб., еда, «обед в столовой»; затем «Потрачено» и «Статус: в норме»
добавить 24000 дом арендаЗаписано: 24000 руб., дом, «аренда»; Статус: почти лимит
добавить 100 отдыхЗаписано: 100 руб., отдых, «без заметки»
добавить 500Не понял команду. Наберите помощь (нет категории)
добавить 90 космосНе понял команду. Наберите помощь (такой категории нет)
добавить abc едаНе понял команду. Наберите помощь (сумма не число)
помощьсписок команд и категорий
список или отчётСкоро научимся
выходИтого потрачено: ... руб. из 30000 руб. и конец работы

Если хочется проверить без клавиатуры, подайте ввод через конвейер: в терминале macOS или Linux это printf 'помощь\nвыход\n' | cargo run -q.

Проверьте себя

  • Введите добавить 24000 дом, затем добавить 5000 еда. Какой статус увидите после второй строки? (Подумайте заранее: 29000 из 30000 — это больше 80 %, но меньше лимита.)
  • Введите Помощь с большой буквы. Что ответит программа и почему?
  • Добавьте в parse_command ещё одно слово для выхода, например "quit", через |. Работает?
  • Измените "без заметки" на другой текст и убедитесь, что он попал в «Записано».

Частые ошибки

Трату использовали после того, как отдали

Что видно: компилятор ругается error[E0382]: borrow of moved value: `expense` и подчёркивает строку, где вы читаете поле или зовёте метод. Почему: выше вы отдали expense дальше (присвоили другой переменной или передали в функцию по значению), и владельцем стал кто-то другой. Expense не умеет копироваться. Как исправить: сначала используйте трату, потом отдавайте; либо передавайте ссылку &expense, как в describe(&self).

Команда с большой буквы не узнаётся

Что видно: «Помощь» или «Добавить 350 еда» отвечают «Не понял команду». Почему: сравнение строк чувствительно к регистру, «Помощь» и «помощь» для Rust разные слова. Как исправить: приведите первое слово к нижнему регистру: let command = first_word(line).to_lowercase(); и пишите match command.as_str() — as_str лишь даёт строке-владельцу вид &str, чтобы сравнить её с образцами.

«добавить 350» не работает, а почему — не сказано

Что видно: на строку без категории или с опечаткой в категории программа отвечает общим «Не понял команду». Почему: Option умеет сказать только «значения нет», но не «почему». В прошлых уроках мы отвечали точнее («Это не число», «Не знаю категорию»), потому что проверяли каждое слово отдельно; parse_command эту подробность теряет, зато вся логика разбора собрана в одном месте. Как исправить: сейчас — проверять по таблице выше; по-настоящему — в модуле про ошибки, где вместо None вернём Err с объяснением.

Забыли ветку в match

Что видно: error[E0004]: non-exhaustive patterns: `None` not covered. Почему: match обязан покрыть все случаи. Как исправить: добавьте ветку None => ... или общую _ => ....

Что важно запомнить

  • parse_command(line: &str) -> Option<Command> превращает строку в команду; None значит «строка не разобралась».
  • Разбор идёт по первому слову: match first_word(line); несколько слов на один вариант объединяет |.
  • let Some(x) = ... else { return None; }; достаёт значение или выходит из функции, не вкладывая код в лишние скобки.
  • Образец Some(Command::Add(expense)) вскрывает обе упаковки сразу; трата при этом перемещается в переменную.
  • Ветка Some(_) ловит все остальные понятные команды, None — непонятный ввод; break в ветке завершает цикл.
  • Сумма и статус пока живут в переменной spent: ни списка трат, ни отчёта по категориям ещё нет.

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

На следующий урок