C++ Programming

Exceptions, Stack Unwinding and Failure Guarantees

PGCP-AC

1. Reporting Exceptional Failure

An exception reports that an operation cannot produce its promised result through its normal return path. It separates failure detection from the place that has enough context to decide how to respond.

Typical exceptional conditions include unavailable files, failed allocation, invalid input for an operation and violation of a runtime precondition. Ordinary expected alternatives, such as reaching the end of a sequence, may be clearer as return values. Exception design begins by deciding which failures are exceptional for the interface.

C++ uses three language elements:

  • throw creates and raises an exception object;
  • try marks a region whose exceptions may be handled;
  • catch defines a handler for a matching exception type.

2. Throwing an Exception

double divide(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::invalid_argument("denominator is zero");
    }
    return numerator / denominator;
}

The throw expression initializes an exception object whose lifetime is managed by the exception mechanism. Execution stops along the current normal path and searches outward for a matching handler.

Throw objects with meaningful types. A type communicates the failure category more reliably than an integer or string alone. Standard exception classes cover many general conditions, while application-specific classes can carry domain details.

3. Handling an Exception

try {
    std::cout << divide(10.0, 0.0);
} catch (const std::invalid_argument& error) {
    std::cerr << error.what() << '\n';
}

Handlers are examined in source order. A handler matches according to exception conversion rules, which include matching a derived exception with a base-class handler. Put handlers for derived types before handlers for their bases; otherwise, the base handler captures them first.

Catch class exceptions by const reference. This avoids copying, preserves the dynamic exception object rather than slicing it and permits reading it without modification.

4. Propagation and Handler Search

If the current function has no matching handler, the exception propagates to its caller. Search continues through active function calls until a matching catch is found. If no handler is found, std::terminate is called.

A try block covers only operations dynamically executed within it. It does not handle exceptions that occurred before control entered the block. Keep try regions aligned with the operations whose failures can be meaningfully handled.

A catch-all handler uses:

catch (...) {
    // any exception type
}

It can perform boundary cleanup or logging, but it has no direct typed access to the exception. It should not silently hide failures that the program cannot actually recover from.

5. Rethrowing

A handler can perform local work and preserve the original exception:

catch (const std::exception& error) {
    log(error.what());
    throw;
}

A bare throw inside an active handler rethrows the current exception object with its original dynamic type. Writing throw error creates a new exception from the handler parameter and can slice a derived exception when the parameter has a base type.

Rethrow when the current level can add context or restore local state but cannot complete recovery. Nested exception facilities can preserve one exception while attaching higher-level context.

6. Stack Unwinding

As an exception propagates, C++ exits active scopes between the throw point and the selected handler. Each fully constructed automatic object in those scopes is destroyed in reverse construction order. This process is stack unwinding.

void process() {
    std::ifstream input("data.txt");
    std::vector<int> values;
    perform(values);       // may throw
}

If perform throws, values is destroyed and input closes its file before propagation leaves process. The cleanup occurs because their destructors are tied to scope, not because every throwing line has a manual cleanup branch.

Dynamic allocations and other raw resources do not clean themselves merely because their pointer variable leaves scope. Put ownership in RAII types such as vector, string, unique_ptr, locks and stream objects.

7. Constructors That Throw

An object's lifetime begins only after its initialization completes. If a constructor throws, the complete object's destructor is not called because that complete object never existed.

Every base and data member that finished construction is nevertheless destroyed in reverse order. This is why members should own resources directly:

class Session {
public:
    Session(std::string path)
        : file_(path), buffer_(4096) {
        if (!file_) {
            throw std::runtime_error("cannot open session file");
        }
    }
private:
    std::ifstream file_;
    std::vector<char> buffer_;
};

If the body throws, buffer_ and file_ are destroyed automatically. A raw resource acquired in the body and not yet placed under an owner could leak.

8. Destructors and Exceptions

Destructors normally must not let exceptions escape. If a destructor throws while another exception is already causing stack unwinding, the runtime calls std::terminate because two active propagations cannot be resolved safely.

Cleanup code should absorb failures it can safely ignore, record them without throwing or provide a separate explicit close or commit operation that callers invoke before destruction. The destructor then performs best-effort cleanup.

Destructors are implicitly noexcept in common cases. Declaring a throwing destructor creates difficult container and unwinding behavior and should be reserved for exceptional designs with a fully specified contract.

9. Standard Exception Hierarchy

Many library exceptions derive from std::exception, whose what member returns a diagnostic C string. Common categories include:

  • std::logic_error for conditions related to program logic;
  • std::invalid_argument for an unsuitable argument value;
  • std::out_of_range for invalid indexed or keyed access;
  • std::runtime_error for runtime conditions not predictable solely from arguments;
  • std::overflow_error and std::underflow_error for arithmetic range conditions;
  • std::bad_alloc for allocation failure;
  • std::bad_cast for a failed reference dynamic_cast.

The category should help the handler decide what action is possible. Human-readable text supplements the type rather than replacing it.

10. Custom Exception Types

A domain can define a specific failure type:

class ParseError : public std::runtime_error {
public:
    ParseError(std::size_t line, const std::string& message)
        : std::runtime_error(message), line_(line) {}

    std::size_t line() const noexcept { return line_; }

private:
    std::size_t line_;
};

Deriving publicly from a suitable standard base lets general handlers catch std::exception while specialized handlers inspect the line number. Store owned data rather than pointers to temporary message buffers.

Exception objects should be reasonably small, copyable or movable as required and safe to destroy. Their constructors should avoid failure wherever practical.

11. The Basic Guarantee

An operation provides the basic exception guarantee when failure leaves all involved objects valid, preserves their invariants and leaks no resources. Values may have changed, but they remain usable according to their documented state.

For example, an operation might process several records before a later record fails. If the already processed records remain valid and every resource is owned, the operation may meet the basic guarantee without restoring the original collection.

The basic guarantee is the minimum useful target for most mutating library code.

12. The Strong Guarantee

The strong exception guarantee says that failure produces no observable state change. The operation either succeeds completely or behaves as if it was never attempted.

A prepare-and-commit structure supports this guarantee:

void Configuration::replace(std::vector<Entry> proposed) {
    validate(proposed);        // may throw; object unchanged
    entries_.swap(proposed);   // nonthrowing commit
}

Potentially failing work occurs on temporary state. After it succeeds, a nonthrowing commit installs the result. The commit point is the moment prepared work becomes the object's observable state.

The strong guarantee has costs. Copying large state may be too expensive and external effects such as network messages cannot always be rolled back. State the guarantee the implementation can honestly preserve.

13. The No-Throw Guarantee

An operation with the no-throw guarantee does not allow an exception to propagate. Destructors, deallocation functions, swaps used for commit and move operations used by containers often benefit from this guarantee.

The noexcept specifier states this contract:

void swap(Buffer& other) noexcept;

If an exception escapes a noexcept function, std::terminate is invoked. Noexcept does not prevent called code from throwing; it defines the consequence if the exception crosses the function boundary.

Conditional noexcept can reflect dependent operations:

template<class T>
void exchange(T& a, T& b)
    noexcept(noexcept(a.swap(b))) {
    a.swap(b);
}

14. Why noexcept Affects Performance

Standard containers relocate elements when storage grows. If a type's move constructor is known not to throw, vector can move elements while maintaining its failure guarantee. If moving may throw and copying is available, it may copy instead so that the old elements remain intact if relocation fails.

Mark a function noexcept only when its implementation and dependencies satisfy the promise. A false declaration trades a recoverable exception for termination.

15. Resource Acquisition Is Initialization

RAII is the foundation of exception-safe C++. Acquire a resource in an object's initialization and release it in the destructor:

std::lock_guard<std::mutex> guard(mutex);
updateSharedState();       // lock releases even if this throws

The same pattern governs files, memory, sockets, database handles and transactions. unique_ptr can adopt exclusive dynamic ownership, while a custom deleter can adapt non-memory resources.

Avoid a sequence that acquires several raw resources and then performs throwing operations before cleanup guards exist. Establish ownership immediately after each acquisition.

16. Exceptions and Return-Based Errors

Exceptions are effective when failure prevents a function from producing its ordinary result and distant callers may decide recovery. Return-based representations such as optional, variant, an error code or a result type can be better when failure is expected and should be handled locally.

Do not use exceptions for routine loop control. Do not discard a returned error merely to avoid exceptions. Choose one clear contract and make success and failure states explicit to callers.

Exceptions do not replace input validation. Validate at a boundary, construct strong internal values and throw a meaningful type when a required invariant cannot be established.

17. Function Try Blocks

A function try block can cover a constructor's initializer list:

Widget::Widget(int size)
try : resource_(size) {
    initialize();
} catch (...) {
    logConstructionFailure();
    throw;
}

An ordinary try inside the body begins too late to catch an exception thrown while initializing bases or members. Function try blocks are mainly useful for translating or logging such construction failures. The partially constructed subobjects have already been cleaned before the handler runs.

18. C++17 Exception Specifications

Older dynamic exception specifications attempted to list types a function could throw. They were removed from normal C++17 use. Modern code uses noexcept to state whether exceptions may escape, while documentation describes possible exception types and failure guarantees.

Noexcept participates in the function type system in modern C++ and the noexcept operator can test an expression without executing it. This supports generic code that selects safe operations according to their exception properties.

19. Designing an Exception-Safe Operation

Begin with invariants: determine what must remain true after every public call. Put each resource under an owner. Identify which steps can throw and perform them before mutating shared state where possible. Choose a commit operation that cannot fail. Decide whether the interface promises basic, strong or no-throw behavior.

Catch an exception only where the code can recover, translate it into a more meaningful abstraction, add useful context or enforce a boundary policy. Otherwise, allow propagation. Empty catch blocks turn visible failure into corrupted assumptions.

Exception safety is not produced by surrounding every line with try and catch. It comes from ownership, invariants, limited mutation and explicit commit points. When objects manage themselves correctly, stack unwinding becomes a reliable cleanup path rather than a special case.

Continue learning

Related notes

Put this topic into timed practice

Open mock tests when you want full-exam pacing, or keep drilling in practice mode.