Стратегии обработки ошибок: выбор правильного подхода
Сравните исключения, коды ошибок и std::expected — узнайте, когда какой подход уместен и как обрабатывать ошибки в конструкторах, деструкторах и многопоточном коде.
Нет универсального решения
C++ предлагает несколько механизмов обработки ошибок, каждый со своими компромиссами. Выбор правильного зависит от вашей области:
Исключения лучше всего подходят для ошибок, которые редки и не могут быть обработаны локально. Они имеют нулевую стоимость на пути успеха, но дороги при выбрасывании (тысячи наносекунд). Они автоматически распространяются через кадры вызова.
Коды ошибок (возвращаемые значения, errno, std::error_code) лучше всего подходят для ошибок, которые ожидаемы и часты. Они имеют небольшую постоянную стоимость при каждом вызове (вызывающий должен проверить возвращаемое значение). Это стандарт для C API и кода уровня ядра.
std::expected (C++23) сочетает лучшее от обоих: заставляет вызывающего подтвердить ошибку (как коды ошибок), но несёт богатый тип ошибки (как исключения) и поддерживает монадическую цепочку. Быстро становится предпочтительным подходом в новом коде C++.
Ключевое понимание: это не конкурирующие философии — это инструменты для различных ситуаций, и промышленный код часто использует все три.
Сравнение: исключения vs коды ошибок vs std::expected
| Критерий | Исключения | Коды ошибок | std::expected |
|---|---|---|---|
| Стоимость при успехе | Нулевая (табличная) | Ветвление + проверка | Минимальная (хранит значение) |
| Стоимость при ошибке | Высокая (раскрутка) | Та же, что при успехе | Та же, что при успехе |
| Можно игнорировать? | Нет (программа завершается) | Да (тихие ошибки) | Сложнее (нужно обращаться к .value()) |
| Конструкторы | Единственный вариант | Не могут вернуть значение | Не могут вернуть значение |
| Деструкторы | Не должны выбрасывать | Допустимо | Допустимо |
| Компонуемость | Ограниченная (try/catch) | Ручные цепочки if | Монадическая (and_then, transform) |
| Размер бинарника | Больше (таблицы раскрутки) | Минимальный | Минимальный |
| Безопасно для реального времени? | Нет (неограниченное время) | Да | Да |
Обработка ошибок в конструкторах
Конструкторы не могут возвращать коды ошибок — у них нет возвращаемого значения. Исключения — единственный чистый механизм сигнализации об ошибке конструирования. Выброшенное исключение означает, что объект никогда не был полностью сконструирован, поэтому его деструктор не вызывается (но деструкторы уже сконструированных подобъектов и баз вызываются).
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>
class ConfigFile {
public:
explicit ConfigFile(const std::string& path)
: path_(path), stream_(path) {
if (!stream_.is_open()) {
throw std::runtime_error(
"Cannot open config file: " + path);
}
// Parse the file — if this throws, stream_ is
// already constructed and its destructor will
// close the file automatically (RAII)
parse();
}
std::string get(const std::string& key) const {
auto it = data_.find(key);
if (it == data_.end()) {
throw std::out_of_range("Key not found: " + key);
}
return it->second;
}
private:
void parse() {
std::string line;
while (std::getline(stream_, line)) {
auto pos = line.find('=');
if (pos != std::string::npos) {
data_[line.substr(0, pos)] = line.substr(pos + 1);
}
}
}
std::string path_;
std::ifstream stream_; // RAII: closed by destructor
std::map<std::string, std::string> data_;
};
// Usage: the object is fully valid or does not exist
int main() {
try {
ConfigFile cfg("/etc/myapp.conf");
std::cout << cfg.get("port") << '\n';
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
}std::error_code для системных ошибок
std::error_code предоставляет легковесный невыбрасывающий механизм для системных ошибок. Библиотека — отличный пример: большинство функций имеют перегрузку, принимающую параметр std::error_code& вместо выбрасывания.
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
fs::copy_file(src, dst); // throws fs::filesystem_error on failure
}
// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
std::error_code ec;
fs::copy_file(src, dst, ec); // sets ec instead of throwing
if (ec) {
std::cerr << "Copy failed: " << ec.message()
<< " (" << ec.category().name()
<< ':' << ec.value() << ")\n";
return false;
}
return true;
}
int main() {
// The error_code version is preferable in loops where
// failure is expected (e.g., batch file processing)
for (const auto& entry : fs::directory_iterator(".")) {
std::error_code ec;
auto sz = fs::file_size(entry.path(), ec);
if (!ec) {
std::cout << entry.path().filename()
<< ": " << sz << " bytes\n";
}
}
}Деструкторы в C++11 и позднее неявно являются noexcept. Если деструктор выбросит, вызывается std::terminate(). Даже если вы явно пометите деструктор noexcept(false), выбрасывание во время раскрутки стека (когда уже активно другое исключение) вызывает std::terminate().
- Залогировать и подавить: если операция очистки завершилась ошибкой, запишите ошибку в лог и продолжайте
- Предоставьте отдельный метод
close(): позвольте вызывающему явно обработать ошибки очистки до уничтожения - Проектируйте ресурсы так, чтобы очистка не могла завершиться ошибкой: например, закрытие файлового дескриптора всегда успешно на уровне ОС
- Никогда не вызывайте выбрасывающую функцию из деструктора без обёртки в try/catch
Обработка ошибок в многопоточном коде
Исключения не пересекают границы потоков. Если поток выбросит исключение, и оно не перехвачено внутри этого потока, вызывается std::terminate(). Для распространения исключений между потоками:
1. Используйте std::future и std::async: исключения, выброшенные в асинхронной задаче, перехватываются и повторно выбрасываются при вызове .get() на future.
2. Используйте std::exception_ptr с std::current_exception() и std::rethrow_exception() для ручного перехвата и передачи исключений.
3. Используйте std::promise::set_exception() для отправки исключения из потока-производителя потоку-потребителю.
Для высокопроизводительного параллельного кода часто предпочтительнее коды ошибок или std::expected, так как они позволяют избежать затрат на инфраструктуру исключений в каждом потоке и проще для рассуждения при пересечении границ потоков.
Подбирайте механизм обработки ошибок к ситуации.
- Используйте исключения для ошибок, которые редки, не могут быть обработаны локально и должны распространяться вверх по стеку (например, файл не найден, база данных недоступна)
- Используйте коды ошибок для высокочастотных операций, где ошибка нормальна и ожидаема (например, промах кэша, неблокирующий ввод-вывод вернул бы блокировку)
- Используйте
std::expectedдля нового кода, где нужна явная обработка ошибок с богатыми типами ошибок и монадической композицией - Используйте исключения в конструкторах — это единственный способ сигнализировать об ошибке до существования объекта
- Никогда не выбрасывайте в деструкторах — залогируйте ошибку и подавите её
- Исключения, коды ошибок и
std::expected— дополняющие друг друга инструменты, а не конкурирующие философии - Конструкторы должны использовать исключения для сигнализации об ошибках; коды ошибок не вариант
- Деструкторы никогда не должны выбрасывать — они неявно
noexceptначиная с C++11 - Исключения не пересекают границы потоков — используйте
std::futureилиstd::exception_ptrдля их передачи std::error_codeидеален для системных ошибок, где нужен невыбрасывающий API
Тест — проверьте свои знания
(15 XP)1. Почему исключения являются предпочтительным механизмом обработки ошибок в конструкторах?
2. Что происходит, если исключение выбрасывается в деструкторе во время раскрутки стека от другого исключения?
3. Как можно передать исключения между потоками?