Программирование C++Современный C++Разработчик системного программного обеспечения на C++

Практическая ситуация: API должен штатно возвращать либо значение, либо диагностическую ошибку без исключен...

Практическая ситуация: API должен штатно возвращать либо значение, либо диагностическую ошибку без исключений. Какой механизм C++23 наиболее идиоматичен?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Используйте std::expected<T, E>: он содержит либо успешное значение типа T, либо ошибку типа E, но не оба состояния одновременно. Такой тип делает возможность ошибки частью интерфейса и позволяет обрабатывать штатные сбои без исключений.

Исторический контекст

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

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

Постановка проблемы

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

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

Подробное решение

std::expected<T, E> имеет два взаимоисключающих состояния: value с объектом типа T и error с объектом типа E. Проверить состояние можно через has_value() или неявно в условии; получить успешное значение — через value() или operator*, а ошибку — через error().

Минимальный пример:

#include <expected> #include <string> std::expected<int, std::string> parse_port(std::string text) { if (text == "80") return 80; return std::unexpected("неподдерживаемый порт"); } int main() { auto result = parse_port("80"); if (result) return *result; return 1; }

std::unexpected явно создаёт ошибочное состояние. Важно, что E — это не обязательно строка: им может быть перечисление, структурированный объект с кодом и контекстом или лёгкий тип ошибки. Выбор типа ошибки влияет на размер объекта, стоимость копирования и объём диагностической информации.

Вызов value() или operator* допустим только для успешного состояния. value() при ошибочном состоянии бросает std::bad_expected_access<E>, поэтому для полностью без исключений нужно сначала проверить состояние или использовать безопасную ветвизацию.

std::expected не означает автоматическое распространение ошибок через весь стек. Каждая функция должна явно решить, как передать ошибку дальше, преобразовать её или обработать. В C++23 для последовательного объединения операций предусмотрены методы and_then, transform и or_else, но их применение должно сохранять понятную модель владения и преобразования ошибок.

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

Ситуация из практики

Сервис конфигурации читает порт из файла, затем проверяет его диапазон и создаёт сетевой сокет. Возврат специального числа вроде -1 неудобен: порт имеет собственные ограничения, а причина отказа теряется. std::variant способен хранить значение или ошибку, но сам по себе не выражает, какая альтернатива считается успешной и каков интерфейс доступа.

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

Результат — компилятор заставляет явно учитывать возможный отказ, диагностические данные не теряются, а обычные ошибки не смешиваются с действительно исключительными сбоями. При этом аварийные условия, которые невозможно корректно восстановить, по-прежнему могут обрабатываться утверждениями или исключениями в зависимости от требований системы.

Что кандидаты часто упускают

1. Чем std::expected<T, E> отличается от std::optional<T>?

std::optional<T> выражает только наличие или отсутствие значения. Он подходит, когда причина отсутствия не нужна или заранее известна, но не позволяет штатно передать диагностическую информацию. std::expected<T, E> дополнительно хранит объект ошибки типа E, поэтому его следует выбирать, когда отказ имеет содержательную причину.

2. Что произойдёт, если тип ошибки не удовлетворяет требованиям к E?

E должен быть объектным типом, пригодным для хранения внутри std::expected; в частности, он не может быть ссылочным типом или void. Доступность операций копирования и перемещения влияет на доступность соответствующих операций самого std::expected: например, некопируемая ошибка может сделать результат некопируемым. Это нужно учитывать при передаче результатов между слоями API.

3. Как представить успешную операцию, которая не возвращает значения?

Используют std::expected<void, E>. В успешном состоянии такой объект не содержит значения, но хранит информацию об ошибке при неуспехе; успешный результат создаётся обычным возвратом, а ошибка — через std::unexpected. Это удобнее, чем использовать bool, поскольку ошибка сохраняет структурированную причину отказа.