Error Handling Patterns

Modern approaches to error handling in C++

Exceptions vs Expected

Exceptions

  • • Use for exceptional circumstances
  • • Stack unwinding is expensive
  • • Non-local control flow
  • • Hard to track all possible throws

std::expected (C++23)

  • • Use for expected failures
  • • Zero overhead
  • • Explicit in type signature
  • • Monadic error handling

Expected Pattern

#include <expected>

enum class ParseError {
    InvalidFormat,
    OutOfRange
};

std::expected<int, ParseError> parse_int(const std::string& s) {
    try {
        size_t pos;
        int value = std::stoi(s, &pos);
        if (pos != s.size()) {
            return std::unexpected(ParseError::InvalidFormat);
        }
        return value;
    } catch (const std::out_of_range&) {
        return std::unexpected(ParseError::OutOfRange);
    } catch (...) {
        return std::unexpected(ParseError::InvalidFormat);
    }
}

// Usage
auto result = parse_int("42");
if (result) {
    std::cout << "Value: " << *result << std::endl;
} else {
    switch (result.error()) {
        case ParseError::InvalidFormat:
            std::cerr << "Invalid format" << std::endl;
            break;
        case ParseError::OutOfRange:
            std::cerr << "Number too large" << std::endl;
            break;
    }
}

Monadic Error Handling

// Chain operations with error handling
auto result = parse_int("100")
    .and_then([](int n) -> std::expected<int, ParseError> {
        if (n < 0) return std::unexpected(ParseError::OutOfRange);
        return n * 2;
    })
    .transform([](int n) {
        return std::to_string(n);
    })
    .or_else([](ParseError) {
        return std::expected<std::string, ParseError>{"default"};
    });

// No explicit error checking in the chain!
// Errors short-circuit automatically

RAII for Cleanup

// Use RAII for guaranteed cleanup
class FileHandle {
    FILE* file_;
public:
    explicit FileHandle(const char* path, const char* mode) 
        : file_(fopen(path, mode)) {
        if (!file_) throw std::runtime_error("Failed to open file");
    }
    
    ~FileHandle() { if (file_) fclose(file_); }
    
    // Disable copy
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
    
    // Enable move
    FileHandle(FileHandle&& other) : file_(other.file_) {
        other.file_ = nullptr;
    }
    
    FILE* get() const { return file_; }
};

// Usage - cleanup guaranteed even if exception thrown
void process_file() {
    FileHandle file("data.txt", "r");
    // Use file.get()...
    // File automatically closed when function exits
}

Best Practices

  • Use exceptions for truly exceptional errors (programming bugs, resource exhaustion)
  • Use std::expected for expected domain failures (parsing, validation)
  • Use std::optional for nullable values
  • Never throw from destructors
  • Don't use exceptions for control flow
  • Don't throw from noexcept functions