За этот модуль у «Копилки» появились свои типы: структура 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, если нет. Сначала скелет, без ветки «добавить»:
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, который обязан выйти из функции».
"добавить" => {
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, не число —Nonelet Some(amount) = ... else { return None; };— если число получилось, оно лежит вamount; если нет — сразу выходим изparse_commandсNonelet 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.
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; ссылка&lineRust превращает сам, как в уроке про строкиSome(Command::Add(expense))— образец вскрывает две упаковки сразу: команда понятнаSome, и это именно «добавить», а трата из неё достаётся в переменнуюexpensespent += expense.amount;— копим общую сумму; списка пока нет, и сумма живёт в одной переменнойSome(Command::Quit) => break— веткаmatchтоже может выйти из цикла:breakпрерывает ближайшийloopSome(_)— «команда понятна, но какая — неважно»: сюда попадут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 и подсказка про помощь.
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 и подведём по ним итоги.