JavaRush /Курсы /C++ SELF /std::expected<T, E> как модель «успех/ошибка»

std::expected<T, E> как модель «успех/ошибка»

C++ SELF
23 уровень , 2 лекция
Открыта

1. Проблема «магических значений»

Когда вы пишете первые программы, очень хочется сделать так: «если не получилось — верну -1». Это звучит логично ровно до момента, когда -1 становится валидным значением (например, пользователь реально ввёл -1, потому что «хочу отменить»), или когда вы возвращаете 0 и внезапно “ноль” тоже бывает нормальным ответом. В этот момент код превращается в угадайку: «это реальный результат или ошибка?».

Ещё хуже, когда “ошибка” выражается строкой "error", а потом вы забываете, где именно вы её проверяли, и начинаете делать вычисления с "error" (да, компилятор вас остановит, но настроение уже испорчено). Поэтому нам нужен тип, который прямо говорит: «внутри либо полезный результат, либо информация об ошибке».

С этой мыслью мы и приходим к std::expected<T, E>.

Мини-таблица: optional vs expected vs «вернуть -1»

В этом месте обычно полезно остановиться и честно сравнить модели, потому что новички часто пытаются «впихнуть optional везде», а потом удивляются, что им не хватает информации.

Модель результата Что означает “плохой исход” Есть ли причина ошибки? Как выглядит типичный вызов
“магическое значение” (-1, "") “не получилось, догадайся” нет
int x = f(); if (x == -1) ...
std::optional<T> “значения нет” нет (или спрятана где-то ещё)
auto x = f(); if (!x) ...
std::expected<T, E> “либо значение, либо ошибка” да (в E)
auto r = f(); if (!r) use(r.error());

Смысл expected в том, что вызывающий код не должен угадывать, почему не получилось, и не должен выдумывать «особые значения» там, где они конфликтуют с реальными данными.

2. Что такое std::expected<T, E> и как его возвращать

std::expected<T, E> — это объект, который хранит либо значение типа T (успех), либо значение типа E (ошибка). То есть это не «значение + флаг», который вы сами обязаны синхронизировать, а готовая библиотечная модель результата операции.

Важно прочувствовать смысл: expected — это не «возможно пусто» (как optional), а «либо получилось, либо есть причина, почему не получилось». Причина — это и есть E. В простых учебных примерах роль E может играть enum class (код ошибки) или даже std::string (сообщение), но лучше привыкать, что E — это данные, с которыми можно что-то делать.

Чтобы пользоваться std::expected, обычно нужен заголовок <expected> и стандарт C++23.

#include <expected>

Если у вас в окружении <expected> не находится, это означает, что стандартная библиотека/компилятор ещё не включили поддержку (или вы не тем стандартом собираете проект). Тогда временно придётся моделировать тот же контракт через std::variant<T, E> — идея будет той же, просто синтаксис чуть менее удобный.

Создание результата: успех “сам”, ошибка — через std::unexpected

Если вы хотите вернуть успех, вы просто возвращаете T. Это выглядит естественно: «у меня получилось число — вернул число».

Если вы хотите вернуть ошибку, вы возвращаете явное состояние ошибки через std::unexpected<E>{...}. Это важный психологический момент: когда вы видите unexpected, вы (и код-ревьюер) сразу понимаете: «ага, здесь автор намеренно возвращает ошибку».

У std::unexpected<E> есть аксессор error() (то есть «достать ошибку»), и сам факт наличия такого аксессора — часть стандартной библиотеки, а не наша выдумка.

Мини-пример: парсим «одну цифру» (нарочно простая задача, чтобы сфокусироваться на expected).

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotDigit };

[[nodiscard]] std::expected<int, ParseErr> parse_one_digit(std::string_view s) {
    if (s.empty()) {
        return std::unexpected<ParseErr>{ParseErr::Empty};
    }
    char c = s[0];
    if (c < '0' || c > '9') {
        return std::unexpected<ParseErr>{ParseErr::NotDigit};
    }
    return static_cast<int>(c - '0');
}

Обратите внимание на [[nodiscard]]: мы уже знакомились с этой идеей раньше (не игнорировать результат), и с expected она становится особенно полезной. Если вы проигнорируете expected, вы буквально проигнорируете возможную ошибку.

3. Как использовать expected: проверка, доступ и «проброс» ошибки

Когда вы получаете std::expected<T, E>, у вас есть два базовых шага: сначала проверить, успех ли это, и только потом доставать данные.

Проверка делается так:

  • if (res) — успех
  • if (!res) — ошибка

или более явно:

  • res.has_value()

А дальше уже вы решаете, что делать:

  • при успехе используем res.value() (или *res);
  • при ошибке используем res.error().

Очень важно: value() нельзя вызывать, если там ошибка. Это не «вернёт что-то», это нарушит контракт использования (и в зависимости от реализации может закончиться аварийным завершением). Поэтому мы придерживаемся дисциплины: сначала if, потом доступ.

#include <expected>
#include <iostream>
#include <string_view>

enum class ParseErr { Empty, NotDigit };
std::expected<int, ParseErr> parse_one_digit(std::string_view s);

int main() {
    auto r = parse_one_digit("x");

    if (!r) {
        std::cout << "parse failed\n"; // parse failed
        return 1;
    }

    std::cout << "value=" << r.value() << "\n"; // value=...
    return 0;
}

Да, это похоже на optional, но с важной разницей: здесь у вас всегда есть «объяснение», что именно пошло не так (в виде ParseErr).

«Пробросить ошибку наверх»: ранний return становится ещё полезнее

Когда вы начинаете возвращать expected, у вас появляется супер-полезный стиль программирования: если функция не может продолжить работу корректно — она не придумывает дефолт, а возвращает ошибку наверх. А вызывающий код решает, что делать.

Это отлично дружит с ранним return: меньше вложенных if, меньше «лесенок», больше линейного чтения.

Например, сделаем функцию, которая по тексту команды «done 12» возвращает id задачи (или ошибку парсинга). Пока без сложных типов ошибок: просто коды.

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber };

[[nodiscard]] std::expected<int, ParseErr> parse_int(std::string_view s);

[[nodiscard]] std::expected<int, ParseErr> parse_done_id(std::string_view arg) {
    auto id = parse_int(arg);
    if (!id) {
        return std::unexpected<ParseErr>{id.error()};
    }
    return id.value();
}

Этот пример специально «чуть скучный», потому что показывает механику. Да, кажется, что мы «просто переслали ошибку». Но в реальном коде между parse_int и return часто есть дополнительные проверки: например, «id должен быть положительным». И вот там expected начинает сиять.

Добавим проверку id > 0 и новый код ошибки:

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber, NonPositive };

[[nodiscard]] std::expected<int, ParseErr> parse_positive_id(std::string_view s) {
    auto id = parse_int(s);
    if (!id) {
        return std::unexpected<ParseErr>{id.error()};
    }
    if (id.value() <= 0) {
        return std::unexpected<ParseErr>{ParseErr::NonPositive};
    }
    return id.value();
}

Теперь у нас контракт стал богаче, а вызывающему коду не нужно гадать, почему именно мы «не смогли».

4. Пример в CLI-приложении TaskLite: парсим число без исключений

Чтобы тема не осталась «в вакууме», давайте продолжим единый учебный контекст. Представим, что у нас есть простое консольное приложение TaskLite: пользователь вводит команды, а мы храним список задач в std::vector. Мы не делаем ничего сверхъестественного: никаких файлов, никаких классов, просто структура данных и ввод/вывод.

Пусть задача выглядит так:

#include <string>

struct Task {
    int id{};
    std::string text;
    bool done{false};
};

Одна из самых частых «точек боли» в таком приложении — парсинг числа (например, id). Если пользователь ввёл «abc» вместо «12», нам нужно корректно сообщить об ошибке, а не делать вид, что всё нормально.

Сделаем функцию parse_int, которая возвращает expected<int, ParseErr>.

Мы можем использовать std::from_chars, который парсит числа без исключений (и мы уже знакомились с этой идеей ранее). Тогда expected становится идеальной «обёрткой результата»: либо число, либо наша ошибка.

#include <charconv>
#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber };

[[nodiscard]] std::expected<int, ParseErr> parse_int(std::string_view s) {
    if (s.empty()) {
        return std::unexpected<ParseErr>{ParseErr::Empty};
    }

    int value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::unexpected<ParseErr>{ParseErr::NotNumber};
    }

    return value;
}

Здесь мы проверяем две вещи: что парсинг вообще успешен, и что строка целиком была числом (без хвоста типа "12abc").

Обработка результата в main: ошибки — отдельно, обычный вывод — отдельно

Когда вы пишете CLI, очень хочется печатать ошибки «где случилось». Но со временем это превращается в кашу: одна функция печатает одно, другая другое, третья вообще молчит.

У нас цель проще: хотя бы сделать так, чтобы expected проверялся в одном стиле. Для начала достаточно дисциплины: «получил expected → проверил → при ошибке сообщил пользователю».

Пусть у нас есть команда «done ID», и мы читаем ID как строку:

#include <iostream>
#include <string>

int main() {
    std::string id_text;
    std::cin >> id_text;

    auto id = parse_positive_id(id_text);
    if (!id) {
        std::cout << "bad id\n"; // bad id
        return 1;
    }

    std::cout << "ok, id=" << id.value() << "\n"; // ok, id=...
    return 0;
}

Да, сообщение пока примитивное. Мы сознательно не усложняем форматирование ошибок здесь, потому что это отдельная большая тема: «как сделать человеку понятное сообщение, а программе — удобный код ошибки». Сейчас нам важно, что ошибка не потерялась, и что она выражена типом, а не договорённостью.

5. Когда expected — хороший выбор, а когда лучше другое

В начале легко впасть в желание: «о, теперь всё через expected!». Но хорошая инженерия — это не культ одного типа, а понимание смысла.

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

std::optional<T> лучше, когда причина не важна. Например, поиск в контейнере: либо нашли, либо нет, и «почему нет» чаще всего не нужно объяснять отдельным кодом.

std::variant лучше, когда исходов больше, чем «успех/ошибка», и они действительно разные по смыслу. Например, «результат вычисления» может быть «число», «предупреждение + число», «ошибка», «нужно подтверждение». Это уже не бинарная модель.

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

6. Типичные ошибки при работе с std::expected

Ошибка №1: вызывать value() без проверки результата.
Это самый частый баг новичка: «я же уверен, что тут всё хорошо». Уверенность держится ровно до первого запуска на плохом вводе. Привыкайте к ритуалу: сначала if (!res), потом работа с res.value().

Ошибка №2: возвращать ошибку неявно и терять читабельность.
Иногда пишут что-то вроде return ParseErr::NotNumber и надеются, что компилятор сам поймёт «это ошибка». Но у expected хорошая привычка — делать ошибку явной: std::unexpected<E>{...}. Такой код читается как предупреждающий знак: «внимание, здесь неуспех».

Ошибка №3: смешивать expected и «дефолтные значения» в одном контракте.
Плохой стиль — вернуть 0 «если не распарсилось», а ещё где-то рядом вернуть unexpected. Это снова превращает API в угадайку. Если вы выбрали expected для функции, держите контракт строгим: успех — это реальное значение, ошибка — это std::unexpected.

Ошибка №4: делать тип ошибки слишком бедным, а потом печатать «bad input» везде.
Если все ошибки сливаются в один код, вы теряете пользу expected: вы всё равно не знаете, что произошло. Лучше иметь хотя бы несколько осмысленных причин (Empty, NotNumber, OutOfRange) и различать их на уровне enum class. А если позже захотите хранить больше контекста — это нормально, но начинать стоит с понятных кодов.

Ошибка №5: игнорировать [[nodiscard]] и позволять выкидывать результат.
expected — это сигнал «проверь меня». Если вы не добавляете [[nodiscard]], можно случайно написать parse_int(s); и ничего не сделать с результатом. Компилятор промолчит, а программа будет вести себя странно. С [[nodiscard]] у вас появляется шанс поймать это сразу, ещё до запуска.

1
Задача
C++ SELF, 23 уровень, 2 лекция
Недоступна
Одна цифра
Одна цифра
1
Задача
C++ SELF, 23 уровень, 2 лекция
Недоступна
Честный парсер
Честный парсер
1
Задача
C++ SELF, 23 уровень, 2 лекция
Недоступна
Проверка возраста
Проверка возраста
1
Задача
C++ SELF, 23 уровень, 2 лекция
Недоступна
Строка калькулятора
Строка калькулятора
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ