Читать сумму с клавиатуры в цикле мы уже умеем, а в прошлых уроках узнали, как владеют строками, как их заимствуют и режут на срезы. Пока всё это лежит отдельными кирпичиками. Сегодня сложим из них первую настоящую возможность «Копилки»: пользователь пишет целую команду одной строкой, а программа сама находит в ней слова, число и заметку.
Что мы сделаем
Пользователь вводит строки, а программа отвечает. Вот сессия, которую мы получим к концу урока (строки с командами набирает пользователь, остальное печатает программа):
Копилка: введите команду (добавить <сумма> <категория> [заметка] или выход)
добавить 350 еда обед в столовой
Команда: добавить
Сумма: 350
Категория: еда
Заметка: обед в столовой
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
добавить abc еда
Это не число, попробуйте ещё раз
выход
Последняя заметка: обед в столовой
Из прошлых модулей остаются MONTH_LIMIT, функции print_header и print_status, цикл loop с read_line и накопитель spent: каждая принятая трата увеличивает его. Заголовок теперь другой: print_header печатает строку-подсказку про команды вместо «Копилка 0.2». Убираем то, что свою работу сделало: учебную заметку note с print_note, функцию add_expense (она нужна была, чтобы потренировать &mut, и вместо неё снова пишем spent += amount;), а также percent и left: итоги по лимиту теперь будет показывать статус после каждой траты. Если у вас что-то из этого осталось, ничего страшного: компилятор лишь предупредит, что функция не используется.
Главное, что нам предстоит придумать, — как превратить одну длинную строку в четыре отдельных значения.
План разбора: отрезаем слово за словом
Представьте почтальона, который разбирает письмо с адресом «Москва Ленина 5 кв 12». Он не пытается понять всё сразу: отрывает первое слово («Москва»), откладывает его и смотрит на то, что осталось. Потом снова отрывает первое слово, и так до конца. Разбор строки на части по одному и тот же приёму называется парсингом (по-английски parse, «разобрать»).
С нашей строкой это выглядит так. Каждый раз мы берём первое слово и оставляем остаток:
| Шаг | Первое слово | Остаток |
|---|---|---|
| Вся строка | — | добавить 350 еда обед в столовой |
| 1 | добавить (команда) | 350 еда обед в столовой |
| 2 | 350 (сумма) | еда обед в столовой |
| 3 | еда (категория) | обед в столовой (заметка) |
Два нужных инструмента у нас уже есть из урока про строки и срезы. Напомним их; rest_after_first здесь записана короче (срез и trim_start() слиты в одно выражение), но делает то же самое:
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 => "",
}
}
Разбор по строкам
first_word— делит текст по пробелам и берёт первое слово; если слов нет, возвращает пустую строку.line.trim()— убирает пробелы и перевод строки по краям (новая переменная с тем же именем, затенение).line.find(' ')— ищет пробел:Some(i)— нашёлся на позицииi,None— слово одно, остаток пуст.line[i + 1..].trim_start()— срез от символа после пробела до конца, без пробелов в начале. Пробел занимает один байт, поэтому резать тут безопасно.
Теперь соберём из них одну функцию, которая сразу отдаёт обе части. В Rust функция может вернуть кортеж (мы встречали кортежи в первом модуле) — пару значений в скобках. Здесь это пара срезов: команда и всё остальное.
fn split_command(line: &str) -> (&str, &str) {
(first_word(line), rest_after_first(line))
}
fn main() {
let line = "добавить 350 еда обед в столовой";
let (command, rest) = split_command(line);
println!("Команда: {command}");
println!("Остаток: {rest}");
}
Разбор по строкам
-> (&str, &str)— на выходе пара срезов, оба указывают внутрь той же строки: ни одной буквы не скопировано.(first_word(line), rest_after_first(line))— последнее выражение без;— пара из двух вызовов.let (command, rest) = ...— сразу раскладываем пару по двум переменным.
Откуда компилятор знает, чей это срез
У функции один вход-ссылка, поэтому компилятор сам понимает: оба результата смотрят в ту же строку. Если срез переживёт строку, программа не соберётся — ниже мы увидим это вживую.
Сумма из текста
Сумма в команде приходит текстом: "350" — это три символа, а не число. Чтобы прибавить её к spent, нужно превратить текст в число типа u32. В модуле про ввод мы делали это прямо внутри цикла по шаблону match ... parse::<u32>(). Теперь вынесем шаблон в отдельную функцию, и её параметр станет понятным до конца.
fn parse_amount(text: &str) -> u32 {
match text.trim().parse::<u32>() {
Ok(n) => n,
Err(_) => 0,
}
}
Разбор по строкам
text: &str— «ссылка на кусок чужого текста»: подходит и вся строка, и срез из середины. Функция только смотрит.text.trim()— убирает пробелы и\nпо краям: иначеparseне поймёт число.Ok(n) => n— получилось число — отдаём его.Err(_) => 0— не получилось (буквы, пусто, минус) — отдаём 0; подчёркивание значит «подробности не нужны».
Ноль как знак «не вышло»
Сумма трат не бывает нулевой, поэтому 0 служит сигналом ошибки. Это упрощение: правильный способ сообщить об ошибке разберём в модуле про ошибки.
Заметка: String или &str
Заметка — это хвост строки: «обед в столовой». Функция rest_after_first отдаёт его срезом &str. Срез — это не отдельный текст, а окошко в чужую строку, как стикер «см. страницу 5»: он сообщает, где искать, но сам текста не хранит. Пока оригинал на месте, окошко работает.
В нашем цикле оригинал на месте не остаётся: строка line одна на весь цикл, и в начале каждого круга мы очищаем её через line.clear(), чтобы в неё записалась новая команда. Это как доска, которую стирают перед новой записью. Если вы сфотографировали надпись, фото останется, а вот стикер «см. доску» после стирания указывает в пустоту. Проверим на примере: хотим запомнить заметку последней траты и напечатать её после выхода из цикла.
let mut line = String::new();
let mut last_note = "";
loop {
line.clear();
io::stdin().read_line(&mut line).expect("не удалось прочитать строку");
last_note = rest_after_first(&line);
}
println!("Последняя заметка: {last_note}");
Компилятор такую программу не соберёт:
error[E0502]: cannot borrow `line` as mutable because it is also borrowed as immutable
--> src/main.rs:15:9
|
15 | line.clear();
| ^^^^^^^^^^^^ mutable borrow occurs here
...
22 | last_note = rest_after_first(&line);
| ----- immutable borrow occurs here
23 | }
24 | println!("Последняя заметка: {last_note}");
| --------- immutable borrow later used here
Читаем по смыслу. line.clear() хочет изменить строку («mutable borrow»). Но last_note всё ещё держит срез внутри той же строки и будет использован позже («immutable borrow later used here»). Это правило заимствования из прошлых уроков: пока кто-то смотрит в строку через &, менять её нельзя. Заметьте, что компилятор остановил нас до запуска: в других языках такая ошибка проявилась бы как «пропавшая» или испорченная заметка.
Решение: хранить заметку не как окошко, а как собственную копию. Для этого есть тип String (владелец текста) и метод to_string(), который делает из среза новую строку-владельца. У копии свой кусок памяти, и очистка line ей не страшна. Заодно сделаем так, чтобы пустая заметка не оставляла пустоты:
fn note_or_default(rest: &str) -> String {
let note = rest.trim();
if note.is_empty() {
"без заметки".to_string()
} else {
note.to_string()
}
}
Разбор по строкам
-> String— на входе смотрим на чужой текст, на выходе отдаём собственную строку вместе с владением.note.is_empty()— метод:true, если в тексте нет ни одного символа."без заметки".to_string()— текст из программы превращается вString; обе веткиifобязаны вернуть один тип.note.to_string()— настоящая копия, которая переживёт очисткуline.
Правило на каждый день
Только посмотреть и сразу использовать — берите &str. Нужно хранить дольше, чем живёт исходная строка — делайте String. В параметрах функций почти всегда &str, в переменных, которые копят данные, — String.
Цикл команд
Остаётся собрать цикл. Он делает один и тот же круг: очистить строку, прочитать новую, разобрать, ответить. Начало круга выглядит так:
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin()
.read_line(&mut line)
.expect("не удалось прочитать строку");
if bytes == 0 {
break;
}
let (command, rest) = split_command(&line);
if command == "выход" {
break;
}
// ... дальше разбираем остаток
}
Разбор по строкам
line.clear()— стираем доску: без этого новая строка дописалась бы к старой.if bytes == 0—read_lineсообщает, сколько байт прочитала; 0 значит «ввод закончился» (Ctrl+D, на Windows Ctrl+Z и Enter).command == "выход"—==сравнивает буквы, а не адреса в памяти.
Дальше тем же приёмом достаём из остатка сумму, категорию и заметку. Всё это — в полном коде ниже.
Практика: собираем main.rs
Откройте проект kopilka (папка, которую создала команда cargo new kopilka) и файл src/main.rs. Замените его содержимое целиком кодом ниже. Это полный файл: в нём уже есть все функции урока, а ввод суммы по одной строке сменился вводом команды.
use std::io;
const MONTH_LIMIT: u32 = 30_000;
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 split_command(line: &str) -> (&str, &str) {
(first_word(line), rest_after_first(line))
}
fn parse_amount(text: &str) -> u32 {
match text.trim().parse::<u32>() {
Ok(n) => n,
Err(_) => 0,
}
}
fn note_or_default(rest: &str) -> String {
let note = rest.trim();
if note.is_empty() {
"без заметки".to_string()
} else {
note.to_string()
}
}
fn print_status(spent: u32, limit: u32) {
if spent > limit {
println!("Статус: лимит превышен");
} else if spent * 100 >= limit * 80 {
println!("Статус: почти лимит");
} else {
println!("Статус: в норме");
}
}
fn print_header() {
println!("Копилка: введите команду (добавить <сумма> <категория> [заметка] или выход)");
}
fn main() {
print_header();
let mut spent: u32 = 0;
let mut last_note = String::new();
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin()
.read_line(&mut line)
.expect("не удалось прочитать строку");
if bytes == 0 {
break;
}
let (command, rest) = split_command(&line);
if command.is_empty() {
continue;
}
if command == "выход" {
break;
}
if command != "добавить" {
println!("Неизвестная команда: {command}");
continue;
}
let amount = parse_amount(first_word(rest));
if amount == 0 {
println!("Это не число, попробуйте ещё раз");
continue;
}
let rest = rest_after_first(rest);
let category = first_word(rest);
if category.is_empty() {
println!("Не хватает категории, попробуйте ещё раз");
continue;
}
let note = note_or_default(rest_after_first(rest));
println!("Команда: {command}");
println!("Сумма: {amount}");
println!("Категория: {category}");
println!("Заметка: {note}");
spent += amount;
last_note = note;
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
println!("Последняя заметка: {last_note}");
}
Разбор по строкам
print_header();— шапка вынесена в функцию, как в прошлом модуле, только текст другой: подсказка про команды.let mut last_note = String::new();— строка-владелец для последней заметки; она вне цикла и живёт до концаmain.if command.is_empty() { continue; }— пользователь просто нажал Enter: переходим к следующему кругу.parse_amount(first_word(rest))— первое слово остатка — сумма; срез передаётся без копирования.let rest = rest_after_first(rest);— затенение:restтеперь остаток без суммы.last_note = note;— владение строкой переходит отnoteкlast_note(перемещение); печатьnoteбыла выше.
Запустите проект и введите несколько строк:
cargo run
Попробуйте такие команды по очереди: добавить 350 еда обед в столовой, добавить abc еда, добавить 24000 дом, добавить 100, помощь, выход. Вывод (сами введённые строки в нём не показаны):
Команда: добавить
Сумма: 350
Категория: еда
Заметка: обед в столовой
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Это не число, попробуйте ещё раз
Команда: добавить
Сумма: 24000
Категория: дом
Заметка: без заметки
Потрачено: 24350 руб. из 30000 руб.
Статус: почти лимит
Не хватает категории, попробуйте ещё раз
Неизвестная команда: помощь
Последняя заметка: без заметки
Проверьте себя
- Что напечатает программа на строку
добавить 500 транспорт метро туда-обратнос лишними пробелами? Проверьте, что заметка выводится целиком. - Введите
добавить 29000 дом, а затемдобавить 2000 еда. Какой статус появится после второй команды и почему? - Переместите объявление
last_noteвнутрь цикла и посмотрите, на что пожалуется компилятор. Почемуprintln!после цикла больше не видит эту переменную? - Уберите
.to_string()в последней веткеnote_or_defaultи прочитайте ошибку: какой тип ожидался и какой найден?
Частые ошибки
Срез живёт дольше строки (E0502)
Что видно: error[E0502]: cannot borrow `line` as mutable because it is also borrowed as immutable на строке с line.clear() или read_line. Почему: вы сохранили &str из line в переменную, которая переживает очередной круг цикла. Как исправить: сделайте копию через .to_string() и храните String.
Лишняя пустая строка или «число с хвостом»
Что видно: после «Заметка: ...» в выводе появляется пустая строка, а сумма иногда не распознаётся. Почему: в конце строки из read_line остался перевод строки \n, и вы забыли trim() для последнего слова. Как исправить: обрезайте пробелы в начале разбора (как в rest_after_first) и перед parse.
Программа не заканчивается, когда ввод закрыт
Что видно: вы подаёте в программу файл или нажимаете Ctrl+D, а она зависает и греет процессор. Почему: на конец ввода read_line возвращает 0 и пустую строку, а пустая команда ведёт к continue. Как исправить: проверка if bytes == 0 { break; } сразу после чтения.
Что важно запомнить
- Разбор команды — это повторение одного приёма: отрезать первое слово и работать с остатком (
first_wordиrest_after_first). - Функция может вернуть пару в скобках, например
(&str, &str), а вызывающий код раскладывает её черезlet (a, b) = ...;. &str— окошко в чужой текст: ничего не копирует, но живёт только пока жив оригинал.- Пока срез используется, исходную строку менять нельзя (E0502); чтобы хранить дольше, делаем
Stringчерезto_string(). - Параметры функций с текстом пишем как
&str; переменные, которые копят текст, —String. read_lineвернёт 0, когда ввод закончился; этот случай надо обработать, иначе цикл не остановится.
Почитать дальше
Глава о владении в официальной книге: doc.rust-lang.org/book/ch04-00-understanding-ownership.html, раздел о срезах — ch04-03-slices.html.
Пока трата живёт как четыре отдельные переменные (amount, category, note, command), и их неудобно хранить и передавать. В следующем модуле соберём их в одну структуру Expense — одну запись о трате.