Object-Oriented Programming (C++)

Chapter 9
Error Handling, Exceptions and Robustness

Bachelors Program in Computer Science Engineering
Object-Oriented Programming (C++)   •   Post Graduate

Error Handling, Exceptions and Robustness

Introduction

Software rarely executes only under ideal conditions. Files may be absent, networks may fail, input may be malformed, memory may be exhausted, and external services may return unexpected data. In object-oriented C++, error handling is therefore not an accessory to programming; it is a design discipline concerned with preserving program invariants, communicating failure, releasing resources, and preventing undefined behavior.

An error is a condition that prevents an operation from producing its intended result. A failure is the observable inability of a component or system to meet a requirement. An exception is a C++ mechanism for transferring control from a point where an unusual condition is detected to a handler capable of addressing it. Robustness is the broader property of continuing to behave predictably in the presence of invalid input, environmental faults, programming mistakes, and partial failure.

C++ provides several complementary techniques: validation, return values, status codes, assertions, exceptions, RAII, contracts expressed through types, and carefully designed interfaces. Advanced C++ programming requires selecting among these techniques rather than assuming that every problem should be solved with exceptions.

Learning Objectives

After completing this chapter, the reader should be able to:

  • distinguish errors, failures, exceptions, undefined behavior, and recoverable conditions;
  • explain the mechanics of throwing, catching, and propagating exceptions;
  • design exception-safe classes using RAII and value semantics;
  • apply the basic, strong, and no-throw exception-safety guarantees;
  • use standard exception types and define domain-specific exceptions;
  • evaluate exception specifications, including noexcept;
  • identify anti-patterns such as catch-all handlers, exception swallowing, and throwing from destructors;
  • combine validation, error values, assertions, logging, and exceptions in robust systems;
  • analyze the reliability and governance implications of error-handling decisions.

1. The Error Model in C++

1.1 Categories of abnormal conditions

A useful design begins by classifying the condition.

CategoryExampleUsual response
Expected alternativeUser enters an invalid search termReturn a status or result type
Recoverable environmental failureA file is temporarily unavailableReturn an error or throw, depending on abstraction
Programming errorArray index violates a class invariantAssertion, exception, or immediate termination
Undefined behaviorDereferencing an invalid pointerPrevent through design; no reliable recovery exists
Fatal process conditionCorrupted runtime state or unrecoverable startup failureTerminate safely and report

The distinction between expected alternatives and exceptional failures is contextual. In a parser, malformed input may be routine and represented by a result object. In a low-level library, the same condition may be reported by an exception if it violates the caller's precondition.

1.2 Error handling and invariants

An invariant is a property that must remain true at defined points in a program. For a bank account, a possible invariant is that the balance is represented consistently with the transaction ledger. A robust operation either completes while preserving the invariant or fails without leaving an invalid intermediate state.

Exception handling is consequently not merely control flow. It is a mechanism for preserving invariants across object boundaries and stack unwinding.

1.3 Compile-time and run-time prevention

The strongest error is often the one made unrepresentable. std::vector expresses a sequence with managed storage; std::unique_ptr expresses exclusive ownership; enum class restricts enumerated values; and const communicates immutability. Static analysis, compiler warnings, sanitizers, and tests complement run-time mechanisms.

Assertions are appropriate for conditions that indicate a programming defect and should hold if the program is correct. They are not a substitute for validating external input, because production builds may disable assertions and users cannot be blamed for violating undocumented internal assumptions.

#include <cassert>

void set_percentage(int value) {
    assert(value >= 0 && value <= 100); // internal invariant
    // ...
}

External input should instead be checked explicitly and handled according to the interface contract.

2. C++ Exception Mechanics

2.1 Throwing and catching

An exception is created by a throw expression. Control transfers to the nearest dynamically enclosing handler whose type can accept the thrown object. During this transfer, automatic objects constructed after entering the relevant try block are destroyed. This process is called stack unwinding.

#include <stdexcept>
#include <string>

int parse_positive(const std::string& text) {
    int value = std::stoi(text);
    if (value <= 0) {
        throw std::invalid_argument("value must be positive");
    }
    return value;
}

void process() {
    try {
        int n = parse_positive("-4");
        (void)n;
    } catch (const std::invalid_argument& ex) {
        // Handle a known input error.
    }
}

A handler should normally catch by const reference. Catching by value can copy and may slice a derived exception; catching by non-const reference prevents handling temporaries and is rarely justified.

try {
    operation();
} catch (const std::exception& ex) {
    report(ex.what());
}

2.2 Propagation and translation

If a function has no suitable local handler, the exception propagates to its caller. A lower layer should generally add context without exposing irrelevant implementation details. The original exception can be preserved using nested exceptions or by rethrowing.

try {
    repository.load(id);
} catch (const std::exception& ex) {
    throw std::runtime_error("loading customer record failed");
}

The example loses the original cause. A better modern approach is to use std::throw_with_nested and inspect nested causes, or to define an error type containing both context and a cause. A bare throw; inside a handler rethrows the currently handled exception; throw ex; creates a new throw operation and can alter its dynamic type through copying.

2.3 Exception hierarchy

The standard library defines std::exception as a common polymorphic base. Frequently used derived types include:

  • std::invalid_argument: an argument has an invalid value;
  • std::out_of_range: an index or value is outside an allowed range;
  • std::runtime_error: a run-time failure not represented more specifically;
  • std::logic_error: a condition associated with a logical precondition or program logic;
  • std::system_error: an error represented by an error code and category;
  • std::bad_alloc: allocation failed.

Domain exceptions should communicate a meaningful abstraction rather than merely repeat an implementation detail.

class InsufficientFunds : public std::runtime_error {
public:
    explicit InsufficientFunds(const std::string& message)
        : std::runtime_error(message) {}
};

A public library should document which exceptions may escape each operation. This is part of its behavioral interface even when the language does not encode the complete list.

3. RAII and Stack Unwinding

Resource Acquisition Is Initialization (RAII) binds resource ownership to object lifetime. A constructor acquires or establishes ownership; the destructor releases it. Since destructors for automatic objects run during normal scope exit and stack unwinding, RAII is the principal mechanism for exception-safe resource management.

#include <fstream>
#include <string>

void copy_first_line(const std::string& source,
                    const std::string& destination) {
    std::ifstream input(source);
    if (!input) {
        throw std::runtime_error("cannot open source file");
    }

    std::ofstream output(destination);
    if (!output) {
        throw std::runtime_error("cannot open destination file");
    }

    std::string line;
    std::getline(input, line);
    output << line << '\n';
}

The streams close automatically even if a later operation throws. Manual new/delete, raw file handles, mutex locks, and temporary changes to global state are dangerous when ownership is not encoded in an object. Standard RAII wrappers include std::unique_ptr, std::shared_ptr, std::lock_guard, std::scoped_lock, and stream classes.

A destructor should normally not throw. If a destructor throws while another exception is already unwinding, the implementation calls std::terminate, potentially hiding the original failure. Cleanup should therefore be non-throwing, with failures recorded, reported through an explicit close() operation, or handled according to a documented policy.

4. Exception-Safety Guarantees

Exception safety describes what remains true if an operation fails.

GuaranteeMeaningTypical design technique
No-throwThe operation does not thrownoexcept, non-throwing operations
StrongFailure leaves observable state unchangedValidate first, copy-and-swap, transactional update
BasicNo leaks; invariants remain valid, but state may changeRAII and carefully ordered mutation
No guaranteeState may be invalid or resources may leakAvoid in production interfaces

The strong guarantee is often expressed conceptually as: either the operation commits completely, or the object's observable state is equivalent to its state before the call. It is not always possible or efficient. For example, a large in-place algorithm may provide only the basic guarantee because partial progress is retained.

4.1 Copy-and-swap

Suppose a class owns a dynamically allocated representation. A robust assignment operator can first construct a complete copy, then exchange representations. If copying throws, the original object remains unchanged; if swapping is non-throwing, the commit step is safe.

class Text {
    std::string value;
public:
    Text& operator=(Text other) noexcept {
        value.swap(other.value);
        return *this;
    }
};

The parameter is copied before the function body. The function then commits by swapping. This pattern depends on a correct copy constructor and a non-throwing swap operation.

4.2 Transactional updates

A resource manager might validate all requested changes in a temporary object, write them to durable storage, and only then update its in-memory state. This resembles a transaction: prepare, validate, commit, and recover or roll back. Database systems formalize related ideas through atomicity and durability, but an ordinary C++ class must define its own consistency boundary.

5. noexcept and the Cost Model

noexcept states that a function is not expected to allow exceptions to escape.

void close_connection() noexcept;

If an exception escapes a noexcept function, the runtime calls std::terminate. Thus noexcept is a behavioral promise, not a request to suppress errors. It is particularly important for destructors, move constructors, move assignment, and swap functions. Standard containers can often move elements efficiently during reallocation when the move constructor is known not to throw.

Conditional specifications express properties of component operations:

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

The performance cost of an exception is usually concentrated in the failure path, while normal execution can be efficient because no error object is returned through every call. Nevertheless, exception support may increase binary size and complicate real-time systems. A real-time control loop commonly prefers prevalidated inputs, bounded operations, status values, and allocation-free behavior.

Performance should not be the only criterion. Exception safety, diagnosability, interface clarity, and failure frequency are equally important.

6. Error Values, Exceptions, and Hybrid Interfaces

Return-based designs make failure explicit at each call site. They are often suitable when failure is expected, local, or part of ordinary control flow.

#include <optional>
#include <string>

std::optional<int> find_code(const std::string& key);

std::optional<T> represents success with a value or absence of a value. It is insufficient when callers need structured failure information. In modern C++, an application may use std::expected<T, E> where available, or a project-defined equivalent, to represent either a value of type T or an error of type E.

Exceptions are often appropriate when:

  • the operation cannot continue locally;
  • the failure is unusual relative to successful execution;
  • multiple layers must be crossed before recovery is possible;
  • preserving normal code readability is valuable.

Error values are often preferable when:

  • failure is expected and frequent;
  • the caller must explicitly inspect the result;
  • the environment requires deterministic control flow;
  • an API is used across a language or ABI boundary where exception propagation is unsafe.

A coherent project should establish conventions. Mixing incompatible styles without documentation causes ignored errors, duplicated handling, and unclear ownership of recovery decisions.

7. Robust Class and API Design

7.1 Validate at boundaries

Validate data when it enters the system, but do not assume boundary validation makes internal code safe. Each class should maintain its own invariants. Constructors should reject impossible states rather than create partially valid objects.

class Port {
    int number_;
public:
    explicit Port(int number) : number_(number) {
        if (number < 1 || number > 65535) {
            throw std::out_of_range("port number");
        }
    }
    int value() const noexcept { return number_; }
};

7.2 Strong ownership and lifetime

Prefer automatic objects and standard containers. Use std::unique_ptr for exclusive ownership and std::shared_ptr only when shared lifetime is a genuine requirement. A raw pointer should generally express observation, not ownership. Clear ownership reduces leaks, double deletion, use-after-free, and exception-related cleanup defects.

7.3 Preserve abstraction boundaries

A file subsystem should not expose platform-specific error internals to every business object. Translate errors at meaningful boundaries, add context, and preserve the underlying cause for diagnostics. Do not catch an exception merely to log and rethrow at every layer; this creates duplicate records and noisy logs.

7.4 Avoid broad catches

catch (...) is appropriate at a small number of top-level boundaries, such as a thread entry point or application main function, where the program must log, clean up, and terminate or select a fallback. It is dangerous in ordinary code because it may hide cancellation, allocation failure, or programming defects.

Never silently swallow an exception:

try {
    perform_operation();
} catch (const std::exception& ex) {
    log_error(ex.what());
    throw; // preserve the failure unless recovery is intentional
}

7.5 Threading and asynchronous work

An exception escaping a thread function causes std::terminate. A task executed through std::future generally stores the exception and rethrows it when get() is called. Thread boundaries therefore require an explicit propagation policy. Exception objects should not contain references to temporary data, and shared state must remain synchronized during failure paths.

8. A Detailed Applied Case: Reliable Payment Processing

The following is an illustrative case, not a description of a particular company.

Background

An online payment service receives a payment request, validates it, reserves funds through an external gateway, and records the resulting transaction. The service must not charge a customer twice because a retry occurred, and it must not report success when durable recording failed.

Problem

An initial implementation performed three operations directly: call the gateway, update an in-memory order, and write a receipt. If the gateway call succeeded but receipt writing failed, a generic catch block returned “payment failed.” The customer could retry, causing a duplicate charge. In another path, a raw file handle was not closed when an exception occurred.

Analysis

The system had two distinct concerns:

  1. business failure, such as declined payment, which is an expected outcome;
  2. infrastructure failure, such as an unavailable database, which requires recovery or reconciliation.

Treating both as a Boolean result lost important information. The system also lacked an idempotency key, a durable state transition, and an ownership model for resources.

Approach and solution

The redesigned interface used a result type for expected gateway outcomes and exceptions for infrastructure failures. Each request carried an idempotency key. The workflow was:

  1. validate the request and reject malformed amounts before contacting the gateway;
  2. consult durable storage for an existing idempotency key;
  3. create a pending transaction using an RAII-backed database transaction;
  4. call the gateway with the same key;
  5. record approved, declined, or unknown status;
  6. commit the local transaction only after the state is consistent;
  7. on a communication failure after submission, reconcile using the key rather than blindly retrying.

A simplified domain model is:

enum class GatewayOutcome { approved, declined };

struct PaymentResult {
    GatewayOutcome outcome;
    std::string transaction_id;
};

PaymentResult charge(const PaymentRequest& request); // expected business result

A database or gateway exception is allowed to propagate to a service boundary, where it is logged with a correlation identifier and converted into a retryable response. The boundary does not claim that the payment failed if its actual status is unknown.

Outcome

The design prevents duplicate retries under the stated idempotency assumptions, releases resources during failures, and distinguishes a decline from an infrastructure outage. It does not eliminate uncertainty: external systems can remain unavailable, and reconciliation is still required. Robustness means managing uncertainty explicitly, not pretending that every operation has a binary outcome.

Learning points

  • Error taxonomy should reflect business meaning.
  • Idempotency is a system-level robustness property, not merely a C++ exception technique.
  • RAII protects local resources, while transactions and reconciliation protect distributed state.
  • An unknown result must not be incorrectly mapped to failure or success.
  • Logs should support diagnosis without exposing payment credentials or personal data.

9. Testing, Diagnostics, and Governance

Error handling requires deliberate testing. Unit tests should exercise invalid arguments, allocation or I/O failures where feasible, rollback paths, exception propagation, and invariant preservation. Property-based testing can generate malformed inputs. Fault injection can simulate unavailable files, timeouts, corrupted responses, and partial writes. Sanitizers help identify memory and undefined-behavior defects that ordinary exception tests cannot repair.

A useful test oracle asks not only whether an exception was thrown, but also:

  • Is the exception type meaningful?
  • Is the object still valid afterward?
  • Were all resources released?
  • Was the failure logged once at the correct boundary?
  • Can a caller safely retry?
  • Was sensitive information excluded from diagnostics?

Logging must balance observability and privacy. Exception messages should contain actionable context such as operation, resource class, and correlation identifier, but not passwords, access tokens, payment-card data, or unnecessary personal information. Production systems should distinguish user-facing messages from privileged diagnostic records.

Safety-critical, financial, medical, and regulated systems may impose requirements for deterministic behavior, audit trails, incident reporting, retention, and separation of duties. A C++ exception policy must therefore be compatible with organizational governance, platform ABI rules, and regulatory obligations.

10. Common Anti-Patterns

Using exceptions for ordinary branching. Repeatedly throwing to search for a normal alternative obscures intent and may impose avoidable cost.

Throwing primitive values. Throwing int, char*, or unrelated objects weakens polymorphic handling. Throw meaningful exception types derived from std::exception or use structured error values.

Catching by value. This can slice derived exceptions and makes copying unnecessary.

Throwing from destructors. This risks termination during unwinding and makes cleanup unreliable.

Using noexcept to hide failure. noexcept changes the consequence of an escaping exception; it does not make the operation safe.

Returning success after partial mutation. A function must document whether partial progress is possible and provide at least the basic guarantee.

Logging and rethrowing at every level. This duplicates messages and makes incident analysis difficult.

Using assertions for user input. Assertions express developer assumptions, not runtime validation obligations.

Contemporary Developments and Design Direction

Modern C++ increasingly emphasizes compile-time expression of ownership and valid states, value-oriented design, non-owning views with explicit lifetimes, and structured error values such as std::expected in C++23 environments. Static analyzers and sanitizers detect classes of defects before deployment. Coroutines introduce additional propagation questions because failures may be captured in a coroutine frame and observed later. Modules and foreign-function interfaces also require explicit exception boundaries; exceptions should not cross an ABI boundary unless the contract guarantees compatible support.

AI-assisted coding tools can generate handlers and tests, but generated code must be reviewed for swallowed exceptions, incorrect retry behavior, sensitive logging, and invalid assumptions about ownership. Reliability remains a human architectural responsibility.

Best-Practice Checklist

  1. Classify a condition before selecting an error mechanism.
  2. Make invalid states difficult or impossible to construct.
  3. Use RAII for every owned resource.
  4. Catch by const reference and preserve dynamic type.
  5. Document the failure behavior of public interfaces.
  6. Provide at least the basic exception-safety guarantee for mutating classes.
  7. Use the strong guarantee where transactional behavior is important and affordable.
  8. Mark genuinely non-throwing operations noexcept.
  9. Do not throw from destructors.
  10. Establish a deliberate policy for logging, retry, cancellation, and process termination.
  11. Test failure paths, not only successful paths.
  12. Protect confidential information in exception messages and logs.

Chapter Summary

Robust C++ programs treat failure as part of the design contract. Exceptions transfer control across stack frames and trigger destruction of automatic objects, making RAII essential for leak-free cleanup. Standard exception hierarchies provide common vocabulary, while domain-specific types communicate business meaning. The basic, strong, and no-throw guarantees describe progressively stronger post-failure properties. noexcept is a contractual promise whose violation terminates the program, not a mechanism for silently ignoring errors.

Exceptions are appropriate for failures that cannot be handled locally or are unusual relative to normal execution; return-based results are often clearer for expected alternatives and deterministic environments. Reliable classes validate boundaries, preserve invariants, encode ownership, and define coherent recovery behavior. In distributed applications, local exception safety must be combined with idempotency, transactions, reconciliation, observability, and privacy controls. Testing must examine cleanup, state validity, propagation, retry safety, and diagnostic quality.

Key Terms

Error: A condition that prevents an operation from achieving its intended result.

Failure: Observable inability to meet a requirement.

Exception: A C++ mechanism for transferring control to a matching handler.

Stack unwinding: Destruction of automatic objects while an exception propagates.

Handler: A catch block that processes a thrown exception.

RAII: Binding resource ownership and release to object lifetime.

Invariant: A property that must remain true at defined program points.

Precondition: A condition required before an operation begins.

Postcondition: A condition expected after an operation completes.

Basic guarantee: Failure preserves invariants and prevents leaks, although state may change.

Strong guarantee: Failure leaves observable state unchanged.

No-throw guarantee: An operation does not allow exceptions to escape.

noexcept: A C++ specification promising that exceptions will not escape a function.

Idempotency: Repeating an operation produces the same intended effect as performing it once.

Exception translation: Converting a lower-level failure into a higher-level abstraction.

Nested exception: An exception retaining another exception as its cause.

Assertion: A check expressing a programmer assumption or invariant.

Undefined behavior: Execution for which the C++ standard imposes no requirements.

Exception safety: Properties describing program state and resources after failure.

Fault injection: Deliberately introducing failures to evaluate resilience.

Review Questions

Short Answer Questions

  1. What is the difference between an exception and undefined behavior?
  2. Why should exceptions usually be caught by const reference?
  3. Explain how RAII supports exception safety.
  4. What does the strong exception-safety guarantee mean?
  5. Why is throwing from a destructor dangerous?

Long Answer Questions

  1. Compare exceptions, return values, assertions, and compile-time prevention as error-handling techniques.
  2. Explain stack unwinding and show how it affects local objects in a nested function call.
  3. Discuss the basic, strong, and no-throw guarantees with suitable C++ design examples.
  4. Analyze the role of noexcept in move operations, containers, and system reliability.
  5. Design an error-handling policy for a multithreaded C++ service, including logging and thread boundaries.

Analytical / Application Questions

  1. A method debits an account, writes an audit record, and then throws because the network acknowledgement is unavailable. Identify possible consistency problems and propose a robust design.
  2. A parser processes millions of malformed records in a batch. Should it use exceptions for every malformed record? Compare alternatives and justify a design.
  3. A library catches std::exception, logs what(), and returns an empty result for every failure. Critically evaluate this interface and redesign it for diagnosability and correctness.

Multiple Choice Questions with Answers

Question 1

What happens during stack unwinding after an exception is thrown?

A. Only the throwing function's local variables are destroyed

B. Automatic objects whose scopes are exited are destroyed

C. All dynamically allocated objects are automatically deleted

D. The program immediately resumes at the next statement

Correct Answer: B. Automatic objects whose scopes are exited are destroyed

Question 2

Which exception-safety guarantee ensures that a failed operation leaves observable state unchanged?

A. No guarantee

B. Basic guarantee

C. Strong guarantee

D. Compilation guarantee

Correct Answer: C. Strong guarantee

Question 3

What is the usual reason for catching an exception by const reference?

A. It prevents stack unwinding

B. It preserves polymorphic type and avoids unnecessary copying

C. It converts the exception into an error code

D. It makes every handler noexcept

Correct Answer: B. It preserves polymorphic type and avoids unnecessary copying

Question 4

Which mechanism is most directly associated with automatic release of owned resources during exception propagation?

A. RAII

B. Macros

C. Function overloading

D. Namespace qualification

Correct Answer: A. RAII

Question 5

What is the consequence if an exception escapes a function declared noexcept?

A. It is silently ignored

B. It is converted to std::optional

C. The function retries automatically

D. std::terminate is called

Correct Answer: D. std::terminate is called

Question 6

Which type is most suitable for representing either a successful value or a structured error in a result-oriented interface?

A. std::mutex

B. std::expected<T, E>

C. std::unique_lock

D. std::tuple<> only

Correct Answer: B. std::expected<T, E>

Question 7

Why are assertions generally unsuitable for validating user input?

A. Assertions cannot contain Boolean expressions

B. Assertions may be disabled and express internal assumptions

C. Assertions always allocate memory

D. Assertions can only be used in destructors

Correct Answer: B. Assertions may be disabled and express internal assumptions

Question 8

Which design best reduces duplicate effects when a payment request is retried?

A. Retry without recording request identity

B. Use an idempotency key and reconcile uncertain outcomes

C. Catch all exceptions and report success

D. Delete the transaction record before retrying

Correct Answer: B. Use an idempotency key and reconcile uncertain outcomes

Question 9

What is the principal danger of throwing from a destructor during stack unwinding?

A. The compiler changes the destructor into a constructor

B. The original exception is automatically duplicated

C. The program may call std::terminate

D. The object becomes immutable

Correct Answer: C. The program may call std::terminate

Question 10

Which response is most appropriate for a top-level application boundary that must prevent an exception from escaping main?

A. Use a narrowly scoped final handler to log, clean up, and terminate or recover deliberately

B. Silently discard every exception

C. Throw the same exception repeatedly

D. Disable all destructors

Correct Answer: A. Use a narrowly scoped final handler to log, clean up, and terminate or recover deliberately

Object-Oriented Programming (C++)
Chapter 9 of 9