1. Проблема «магических значений»
Когда вы пишете первые программы, очень хочется сделать так: «если не получилось — верну -1». Это звучит логично ровно до момента, когда -1 становится валидным значением (например, пользователь реально ввёл -1, потому что «хочу отменить»), или когда вы возвращаете 0 и внезапно “ноль” тоже бывает нормальным ответом. В этот момент код превращается в угадайку: «это реальный результат или ошибка?».
Ещё хуже, когда “ошибка” выражается строкой "error", а потом вы забываете, где именно вы её проверяли, и начинаете делать вычисления с "error" (да, компилятор вас остановит, но настроение уже испорчено). Поэтому нам нужен тип, который прямо говорит: «внутри либо полезный результат, либо информация об ошибке».
С этой мыслью мы и приходим к std::expected<T, E>.
Мини-таблица: optional vs expected vs «вернуть -1»
В этом месте обычно полезно остановиться и честно сравнить модели, потому что новички часто пытаются «впихнуть optional везде», а потом удивляются, что им не хватает информации.
| Модель результата | Что означает “плохой исход” | Есть ли причина ошибки? | Как выглядит типичный вызов |
|---|---|---|---|
| “магическое значение” (-1, "") | “не получилось, догадайся” | нет | |
| std::optional<T> | “значения нет” | нет (или спрятана где-то ещё) | |
| std::expected<T, E> | “либо значение, либо ошибка” | да (в E) | |
Смысл 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]] у вас появляется шанс поймать это сразу, ещё до запуска.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ