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]] у вас зʼявляється шанс упіймати це одразу, ще до запуску.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ