$ sudo teach IT
Модуль 6 · Ошибки · Урок 6.2

Оператор ?

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

Теория + практика~20 минутСредний

В прошлый раз мы научили сумму возвращать Result: теперь parse_amount не молчит, а объясняет, что пошло не так. Но чтобы воспользоваться ответом, приходится писать match на каждом шагу, а в parse_command таких шагов несколько. Сегодня мы избавимся от лишнего шума: в Rust для этого есть один знак — ?.

Проблема: лестница из match

Представьте, что вы собираете посылку по списку: коробка, товар, чек, скотч. Если коробки нет, вы не станете вкладывать чек — остановитесь и скажете, чего не хватило. Так же должен работать разбор команды: первая же ошибка прекращает работу и уходит вызвавшему.

Пока мы умеем делать это только через match. Вот ветка «добавить», записанная отдельной функцией parse_add (она получает остаток строки после слова команды):

Rust · main.rs
fn parse_add(rest: &str) -> Result<Command, String> {
    match parse_amount(first_word(rest)) {
        Ok(amount) => {
            let rest = rest_after_first(rest);
            match Category::from_word(first_word(rest)) {
                Some(category) => {
                    let note = rest_after_first(rest);
                    Ok(Command::Add(Expense::new(amount, category, note)))
                }
                None => Err("Не знаю категорию".to_string()),
            }
        }
        Err(message) => Err(message),
    }
}

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

  • match parse_amount(first_word(rest)) — разбираем первое слово как сумму; результат — Result, поэтому в match две ветки: Ok и Err.
  • Ok(amount) => { ... } — сумма в порядке, идём дальше — и внутри ветки открываем второй match, уже по категории.
  • None => Err("Не знаю категорию".to_string()) — категории нет: возвращаем ошибку-строку. Метод to_string() превращает текст в String.
  • Err(message) => Err(message) — самая скучная строка: ошибка пришла, и мы просто передаём её дальше без изменений.

Всего два шага, а код уже уехал вправо. Добавьте ещё проверку — и получится лесенка, в которой тяжело найти, что где закрывается. А строка Err(message) => Err(message) ничего не решает: она только пересылает ошибку. Такой код Rust умеет писать за нас.

Оператор ?: «если ошибка, верни её сразу»

Простыми словами: знак вопроса после вызова говорит «получилось — бери результат и работай дальше, не получилось — немедленно выходи из функции и отдай эту ошибку тому, кто тебя вызвал». Это как сотрудник, который при проблеме сразу докладывает начальнику.

Официально это оператор ? (его ещё называют оператором распространения ошибки). Он стоит сразу после выражения с Result. Вот как он работает, если расписать его руками:

Rust · main.rs
let amount = match parse_amount(word) {
    Ok(n) => n,
    Err(message) => return Err(message),
};

Если внутри Ok — значение достаётся и попадает в amount. Если внутри Err — срабатывает return, и функция заканчивается, возвращая эту же ошибку. А теперь то же самое одной строкой:

Rust · main.rs
let amount = parse_amount(word)?;

Обе записи делают одно и то же. Так что ? — не магия, а сокращение для match с return.

Где можно ставить ?

Есть правило: ? умеет делать return только из функции, которая сама возвращает Result (или Option, об этом дальше). В нашей parse_add тип результата — Result<Command, String>, так что всё сходится. Перепишем её:

Rust · main.rs
fn parse_add(rest: &str) -> Result<Command, String> {
    let amount = parse_amount(first_word(rest))?;
    let rest = rest_after_first(rest);
    let category = match Category::from_word(first_word(rest)) {
        Some(category) => category,
        None => return Err("Не знаю категорию".to_string()),
    };
    let note = rest_after_first(rest);
    Ok(Command::Add(Expense::new(amount, category, note)))
}

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

  • let amount = parse_amount(first_word(rest))?; — если сумма плохая, parse_add тут же вернёт Err с её текстом, а следующие строки даже не начнутся.
  • let rest = rest_after_first(rest); — затенение, как и раньше: имя rest теперь указывает на остаток без суммы.
  • None => return Err(...) — для Option пока остаётся match — ниже научимся и с ним обходиться без него.
  • Ok(Command::Add(...)) — в самом конце оборачиваем готовую команду в Ok: функция возвращает Result, а не просто Command.

? работает и с Option

Знак вопроса дружит не только с Result. С Option он поступает так же, только по-своему: Some(значение) — достаёт значение, None — сразу возвращает None из функции. Условие то же: функция сама должна возвращать Option.

Допустим, нужно достать третье слово строки «добавить 350 еда»:

Rust · main.rs
fn category_word(line: &str) -> Option<&str> {
    let mut parts = line.split_whitespace();
    let _command = parts.next()?;
    let _amount = parts.next()?;
    let category = parts.next()?;
    Some(category)
}

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

  • let mut parts = line.split_whitespace(); — набор слов строки. Он mut, потому что каждый вызов next() сдвигает нас на слово дальше.
  • let _command = parts.next()?; — next() возвращает Option<&str>: есть слово — Some, слова кончились — None. Если слов нет, функция сразу вернёт None. Подчёркивание в имени говорит, что значение нам не нужно: мы его только пропускаем.
  • let category = parts.next()?; — третье слово. Если строка короче, ? прервёт функцию.
  • Some(category) — дошли до конца — все три слова на месте, возвращаем третье.

Три «может не оказаться» уложились в три ровные строки.

Превращаем ошибки: ok_or и map_err

Теперь вернёмся к Category::from_word. Она возвращает Option, а parse_command — Result. Поставить ? прямо на Option внутри функции с Result нельзя: у None нет текста ошибки, и Rust не знает, что вернуть в Err. Нужен переходник, который скажет: «если None, считай ошибкой вот это». Такой переходник — метод ok_or:

Rust · main.rs
let category = Category::from_word(word)
    .ok_or(format!("Не знаю категорию «{word}»"))?;

ok_or превращает Option<T> в Result<T, E>: Some(x) становится Ok(x), а None — Err(то, что вы передали). Дальше уже обычный ?.

Второй переходник нужен, когда ошибка уже есть, но не того вида. Помните, как parse::<u32>() отвечает на «abc»? Он возвращает Err(ParseIntError), а наша функция вернула бы Err(String). Метод map_err меняет только ошибку, оставляя Ok нетронутым:

Rust · main.rs
fn parse_amount(text: &str) -> Result<u32, String> {
    text.parse::<u32>()
        .map_err(|_| format!("Не число: {text}"))
}

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

  • text.parse::<u32>() — пробуем превратить текст в число; получаем Result<u32, ParseIntError>.
  • .map_err(|_| ...) — если внутри Err — подставляем свою ошибку. Запись |_| ... — маленькая функция-замыкание: подчёркивание значит «старая ошибка нам не нужна».
  • format!("Не число: {text}") — новая ошибка — уже строка, как требует тип Result<u32, String>.

Это упрощённый пример: проверок на пустую строку и ноль в нём нет, поэтому в проект мы его не переносим. В Копилке остаётся полная parse_amount из шага 1 ниже.

Тип ошибки обязан совпасть

? не умеет превращать любую ошибку во что угодно. Если функция возвращает Result<..., String>, то под каждым ? должна быть ошибка типа String. Для чужих типов — ParseIntError и подобных — сначала зовём map_err, для Option — ok_or.

А почему в main не ? ?

Раз знак вопроса так удобен, хочется поставить его и в main. Можно, если объявить fn main() -> Result<(), String>: тогда ошибка из ? завершит программу с сообщением. Обычная main возвращает «ничего» (()), и ? в ней не скомпилируется.

Но нам это и не нужно. У «Копилки» в main цикл: пользователь ошибся — программа должна объяснить, что не так, и ждать следующую команду, а не закрыться. Поэтому наверху ошибки разбираем через match: ветка Err(message) печатает текст, и цикл идёт дальше. А в функциях «по дороге» (parse_command, parse_amount) ? на своём месте. Про функции, которые возвращают ошибки ввода-вывода (io::Result), поговорим в последнем модуле, когда дойдём до файлов.

Практика: parse_command на вопросительных знаках

Переводим «Копилку» на Result. Откройте проект kopilka и файл src/main.rs. Работать будем в трёх местах, всё остальное (структуры, Category, print_status, first_word и остальные помощники) остаётся как есть.

Шаг 1. parse_amount остаётся прежним

Функция parse_amount из прошлого урока должна возвращать Result<u32, String> и стоять выше parse_command. Если ваша версия работает, оставьте её; для сверки вот она целиком:

Rust · main.rs
fn parse_amount(text: &str) -> Result<u32, String> {
    let text = text.trim();
    if text.is_empty() {
        return Err("Не указана сумма".to_string());
    }
    match text.parse::<u32>() {
        Ok(0) => Err("Сумма должна быть больше нуля".to_string()),
        Ok(amount) => Ok(amount),
        Err(_) => Err(format!("Не число: {text}")),
    }
}

Шаг 2. Новый parse_command

Замените прежнюю функцию parse_command (ту, что возвращала Option) на эту:

Rust · main.rs
fn parse_command(line: &str) -> Result<Command, String> {
    match first_word(line) {
        "добавить" => {
            let rest = rest_after_first(line);
            let amount = parse_amount(first_word(rest))?;
            let rest = rest_after_first(rest);
            let word = first_word(rest);
            if word.is_empty() {
                return Err("Не указана категория".to_string());
            }
            let category = Category::from_word(word)
                .ok_or(format!("Не знаю категорию «{word}»"))?;
            let note = rest_after_first(rest);
            let note = if note.is_empty() { "без заметки" } else { note };
            Ok(Command::Add(Expense::new(amount, category, note)))
        }
        "список" => Ok(Command::List),
        "отчёт" | "отчет" => Ok(Command::Report),
        "помощь" => Ok(Command::Help),
        "выход" => Ok(Command::Quit),
        "" => Err("Пустая команда".to_string()),
        other => Err(format!("Не знаю команду «{other}»")),
    }
}

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

  • Result<Command, String> — теперь функция либо отдаёт команду, либо текст ошибки.
  • parse_amount(first_word(rest))? — лестница из let ... else заменилась одним знаком вопроса: плохая сумма сразу прерывает функцию.
  • if word.is_empty() { return Err(...) } — для строки «добавить 350» слова-категории нет. Без этой проверки мы получили бы странное «Не знаю категорию «»», поэтому пустое слово отлавливаем отдельно.
  • Category::from_word(word).ok_or(...)? — переходник из Option в Result и сразу ?.
  • "" => Err(...) — пустая строка тоже получает свой понятный ответ.
  • other => Err(format!(...)) — имя other получает любое слово, которое мы не знаем, и мы подставляем его в сообщение. Раньше здесь был безликий None.

Шаг 3. Цикл в main печатает ошибки

В main разбор команды вызывается в цикле через match parse_command(&line). Раньше ветки были Some(Command::...) и None, теперь результат — Result, поэтому каждое Some(...) становится Ok(...), а последняя ветка None заменяется на Err(message). Тела веток не меняются. Целиком match выглядит так:

Rust · main.rs
        match parse_command(&line) {
            Ok(Command::Add(expense)) => {
                println!("Записано: {}", expense.describe());
                expenses.push(expense);
                let spent = total(&expenses);
                println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
                print_status(spent, MONTH_LIMIT);
            }
            Ok(Command::List) => print_list(&expenses),
            Ok(Command::Report) => print_report(&expenses),
            Ok(Command::Help) => print_help(),
            Ok(Command::Quit) => break,
            Err(message) => println!("Ошибка: {message}"),
        }

Проверка if line.trim().is_empty() { continue; } выше match пока остаётся, поэтому ветка "" => Err(...) в parse_command сейчас запасная: пустую строку main отсеивает раньше. В следующем уроке проверку уберём, и пустая строка получит своё объяснение.

Запустите проект: cargo run — и вводите команды. Строки, которые набираете вы, ниже отмечены знаком > (на самом деле его на экране нет):

Терминал
> добавить 350 еда обед в столовой
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> добавить abc еда
Ошибка: Не число: abc
> добавить 0 еда
Ошибка: Сумма должна быть больше нуля
> добавить 350
Ошибка: Не указана категория
> добавить 350 сон
Ошибка: Не знаю категорию «сон»
> танцы
Ошибка: Не знаю команду «танцы»
> выход
Итого потрачено: 350 руб. из 30000 руб.

Теперь «Копилка» не молчит и не падает: на любую неудачу она называет причину. Если вывод не совпал, проверьте, что все Some(...) в match стали Ok(...).

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

  • Что напечатает программа на «добавить 100 сон»? А на «добавить -5 еда»?
  • Что случится, если в parse_command заменить ? после parse_amount на .unwrap()? Почему так делать нельзя?
  • Попробуйте вернуть .ok_or(...) без знака вопроса. Какой тип получит переменная category и почему программа не соберётся?
  • Добавьте ещё одну проверку: заметка не длиннее 40 символов. Что нужно вернуть при нарушении, чтобы главный цикл ничего не менял?

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

1. ? в функции, которая ничего не возвращает

Вы написали let n = parse_amount("5")?; внутри функции без ->. Компилятор ругается:

Ошибка компилятора
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:5:30
  |
4 | fn show() {
  | --------- this function should return `Result` or `Option` to accept `?`
5 |     let n = parse_amount("5")?;
  |                              ^ cannot use the `?` operator in a function that returns `()`

Почему: ? должен куда-то вернуть Err, а функция возвращает «пустоту». Как исправить: либо поменять тип результата функции на Result<..., String> и добавить Ok(...) в конце, либо там, где ошибку надо обработать на месте (например, в main), вернуться к match.

2. ? на Option внутри функции с Result

Ошибка компилятора
error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s, in a function that returns `Result`
 --> src/main.rs:4:35
  |
3 | fn pick(w: &str) -> Result<Category, String> {
  | -------------------------------------------- this function returns a `Result`
4 |     let c = Category::from_word(w)?;
  |                                   ^ use `.ok_or(...)?` to provide an error compatible with `Result<Category, String>`

Почему: у None нет текста ошибки, а функции нужен Err(String). Компилятор сам подсказывает выход: допишите .ok_or(...) с нужным текстом — Category::from_word(w).ok_or("Нет такой категории".to_string())?.

3. Другой тип ошибки под знаком вопроса

Ошибка компилятора
error[E0277]: `?` couldn't convert the error to `String`
 --> src/main.rs:2:32
  |
1 | fn amount(text: &str) -> Result<u32, String> {
  |                          ------------------- expected `String` because of this
2 |     let n = text.parse::<u32>()?;
  |                  --------------^ the trait `From<ParseIntError>` is not implemented for `String`

Почему: parse возвращает ParseIntError, а функция обещает String. Как исправить: text.parse::<u32>().map_err(|_| format!("Не число: {text}"))?.

4. Потерянный текст ошибки

Код компилируется, но программа отвечает невнятно:

Rust · main.rs
let amount = text.parse::<u32>().map_err(|_| "Ошибка".to_string())?;

Любой плохой ввод теперь даёт просто «Ошибка» — пользователь не узнает ни что именно не так, ни какое слово мы не поняли. Почему так вышло: в map_err(|_| ...) мы выбросили всё, что знали. Как исправить: вставляйте в сообщение то, что мы знаем: само слово ({text}) и причину («не число», «не указана категория»). Так же и в main: печатайте {message}, а не фиксированную фразу.

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

  • выражение? — «получилось: продолжай, не получилось: немедленно верни ошибку из функции». Это сокращение для match с return.
  • ? можно использовать только в функции, которая возвращает Result или Option.
  • Над Option в функции с Result сначала ставим .ok_or(текст_ошибки); чужую ошибку приводим к нужной через .map_err(|_| ...).
  • Тип ошибки под ? должен совпадать с типом ошибки функции (у нас пока это String).
  • На самом верху, в main, ошибки разбираем через match: программа печатает причину и продолжает работу.
  • В тексте ошибки сохраняйте то, что поможет человеку: какое слово не поняли и почему.

Теперь вопрос на будущее: String в роли ошибки приятен тем, что его легко написать, но неточен — по типу не отличить «плохая сумма» от «неизвестной категории». В следующем уроке мы сделаем для ошибок собственное перечисление.

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