$ sudo teach IT
Модуль 3 · Владение · Урок 3.4

Практикум: разбор команды

Вы научите «Копилку» разбирать строку вида «добавить 350 еда обед в столовой» на команду, сумму, категорию и заметку.

Практикум~30 минутНовичок

Читать сумму с клавиатуры в цикле мы уже умеем, а в прошлых уроках узнали, как владеют строками, как их заимствуют и режут на срезы. Пока всё это лежит отдельными кирпичиками. Сегодня сложим из них первую настоящую возможность «Копилки»: пользователь пишет целую команду одной строкой, а программа сама находит в ней слова, число и заметку.

Что мы сделаем

Пользователь вводит строки, а программа отвечает. Вот сессия, которую мы получим к концу урока (строки с командами набирает пользователь, остальное печатает программа):

Вывод
Копилка: введите команду (добавить <сумма> <категория> [заметка] или выход)
добавить 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 еда обед в столовой
2350 (сумма)еда обед в столовой
3еда (категория)обед в столовой (заметка)

Два нужных инструмента у нас уже есть из урока про строки и срезы. Напомним их; rest_after_first здесь записана короче (срез и trim_start() слиты в одно выражение), но делает то же самое:

Rust · main.rs
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 функция может вернуть кортеж (мы встречали кортежи в первом модуле) — пару значений в скобках. Здесь это пара срезов: команда и всё остальное.

Rust · main.rs
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>(). Теперь вынесем шаблон в отдельную функцию, и её параметр станет понятным до конца.

Rust · main.rs
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(), чтобы в неё записалась новая команда. Это как доска, которую стирают перед новой записью. Если вы сфотографировали надпись, фото останется, а вот стикер «см. доску» после стирания указывает в пустоту. Проверим на примере: хотим запомнить заметку последней траты и напечатать её после выхода из цикла.

Rust · main.rs
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 ей не страшна. Заодно сделаем так, чтобы пустая заметка не оставляла пустоты:

Rust · main.rs
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.

Цикл команд

Остаётся собрать цикл. Он делает один и тот же круг: очистить строку, прочитать новую, разобрать, ответить. Начало круга выглядит так:

Rust · main.rs
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. Замените его содержимое целиком кодом ниже. Это полный файл: в нём уже есть все функции урока, а ввод суммы по одной строке сменился вводом команды.

Rust · 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 — одну запись о трате.

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