Skip to content
academia.sh

Lesson 12 / 14

Exceptions

The measurement of how many times a thrown object is copied while being caught, which parts a construction cut short by an exception destroy, and how a temporary object is copied fewer times than a named local.

Published Updated

In this lesson

  • Uses a counter to measure that catching by value produces one more copy than catching by reference
  • Observes that an exception that cuts a construction short destroys the already-constructed parts but not the object itself
  • Measures how throwing a temporary object instead of a named local reduces the copy count
Contents

The previous four lessons measured constructors, destructors, and virtual calls that completed in a normal flow. This lesson takes up the case where the flow is cut short: when a function throws an error (throw), the thrown object can be copied one or more times before it reaches the catch block; if the interruption happened in the middle of a constructor, the already-constructed parts are destroyed, but the object itself is never destroyed. The measurement question: how many times, and with which object’s copy, does an expression work?

Catching by Value Produces One More Copy Than Catching by Reference

ErrorObject increments a counter in its copy constructor. When we throw the same named local object and catch it once by value and once by reference, how many copies does the counter show?

// exception_copy_counter.cpp — catching by value produces one more copy than catching by reference
#include <iostream>

class ErrorObject {
public:
    ErrorObject() = default;
    ErrorObject(const ErrorObject&) { ++copy_count; }
    static int copy_count;
};
int ErrorObject::copy_count = 0;

void throw_it() {
    ErrorObject e;
    throw e;
}

int main() {
    try {
        throw_it();
    } catch (ErrorObject e) {
        (void)e;
    }
    std::cout << "copy count after catch by value " << ErrorObject::copy_count << "\n";

    ErrorObject::copy_count = 0;
    try {
        throw_it();
    } catch (const ErrorObject& e) {
        (void)e;
    }
    std::cout << "copy count after catch by reference " << ErrorObject::copy_count << "\n";

    return 0;
}
copy count after catch by value 2
copy count after catch by reference 1

PS13. throw e; throws a named local — it produces one copy into the exception storage (the first). catch (ErrorObject e) catches it by value, producing a second copy: total 2. catch (const ErrorObject& e) binds directly to the stored object, producing no extra copy: total 1. Class copy: catching by value adds one more copy to the work of catching by reference.

An Exception That Cuts a Construction Short: The Already-Constructed Parts Are Destroyed, the Object Itself Is Not

Composite’s constructor throws an exception after constructing two SubPart members. Are the already-constructed members destroyed? Does Composite’s own destructor ever run?

// exception_interrupted_construction.cpp — the already-constructed parts are destroyed, the object itself is never destroyed
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

class SubPart {
public:
    explicit SubPart(std::string name) : name_(std::move(name)) {
        std::cout << name_ << " constructed\n";
    }
    ~SubPart() {
        std::cout << name_ << " destroyed\n";
    }
private:
    std::string name_;
};

class Composite {
public:
    Composite() : first_("first"), second_("second") {
        std::cout << "Composite body running, throwing exception\n";
        throw std::runtime_error("construction error");
    }
    ~Composite() {
        std::cout << "Composite destructor ran\n";
    }
private:
    SubPart first_;
    SubPart second_;
};

int main() {
    try {
        Composite c;
    } catch (const std::exception& e) {
        std::cout << "caught error: " << e.what() << "\n";
    }
    return 0;
}
first constructed
second constructed
Composite body running, throwing exception
second destroyed
first destroyed
caught error: construction error

PS14. first_ and second_ are constructed in the member initializer list. The exception thrown in the body cuts the constructor short: Composite never completes. As the stack unwinds, the members constructed up to that point are destroyed in reverse order — first second_, then first_ — but “Composite destructor ran” is never printed: the destructor is only called if the constructor has completed. Class copy does not appear directly here, but catch (const std::exception& e) catches by reference and produces no extra copy.

A Temporary Object Is Copied Fewer Times Than a Named Local

In PS13, throwing a named local and catching it by value gave a copy count of 2. Does that number change if a temporary is thrown directly (throw ErrorObject();) and still caught by value?

// exception_temporary_copy.cpp — throwing a temporary object copies fewer times than a named local
#include <iostream>

class ErrorObject {
public:
    ErrorObject() = default;
    ErrorObject(const ErrorObject&) { ++copy_count; }
    static int copy_count;
};
int ErrorObject::copy_count = 0;

void throw_temporary() {
    throw ErrorObject();
}

int main() {
    try {
        throw_temporary();
    } catch (ErrorObject e) {
        (void)e;
    }
    std::cout << "copy count when throwing a temporary (catch by value) "
              << ErrorObject::copy_count << "\n";

    return 0;
}
copy count when throwing a temporary (catch by value) 1

PS15. throw_temporary does not construct a named local; it constructs and throws a temporary (prvalue) directly. In C++17, an exception object initialized from a temporary (prvalue) is constructed directly in the exception storage without calling the copy constructor, so the first copy drops out. The only remaining copy is the one from catching by value: total 1, one less than PS13’s 2. Class copy: whether the thrown expression is a named local or a temporary changes the copy count.

Stack Unwinding Spans Multiple Frames: Each Layer Destroys Its Own Locals

PS14 measured a construction interrupted inside a single constructor. If an exception is thrown at the innermost of several nested function calls, are the local objects in the intermediate frames also destroyed by the same stack-unwinding mechanism, and in what order?

// exception_stack_unwinding.cpp — in multi-frame stack unwinding, each frame destroys its own locals in reverse order
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

class Tracker {
public:
    explicit Tracker(std::string name) : name_(std::move(name)) {
        std::cout << name_ << " constructed\n";
    }
    ~Tracker() {
        std::cout << name_ << " destroyed (during stack unwinding)\n";
    }
private:
    std::string name_;
};

void third_level() {
    Tracker t3("third level");
    throw std::runtime_error("error deep inside");
}

void second_level() {
    Tracker t2("second level");
    third_level();
}

void first_level() {
    Tracker t1("first level");
    second_level();
}

int main() {
    try {
        first_level();
    } catch (const std::exception& e) {
        std::cout << "caught error: " << e.what() << "\n";
    }
    return 0;
}
first level constructed
second level constructed
third level constructed
third level destroyed (during stack unwinding)
second level destroyed (during stack unwinding)
first level destroyed (during stack unwinding)
caught error: error deep inside

PS35. The locals of the three frames are printed in construction order; destruction happens in reverse: first t3, then t2, then t1. Stack unwinding destroys the locals of every frame it passes through until it reaches the catch — the rule from PS14’s single constructor, generalized to the entire call stack. Class copy does not appear, but the mechanism is the same.

throw; Preserves the Original Object, throw e; Slims It to the Static Type

There are two ways to rethrow a caught exception: the parameterless throw; and throw e;, which throws the caught variable. Do they carry the same exception out?

// exception_throw_vs_throw_e.cpp — throw; preserves the original type, throw e; slims it to the parameter's static type
#include <iostream>
#include <stdexcept>

class BaseError : public std::runtime_error {
public:
    BaseError() : std::runtime_error("base error") {}
};

class DerivedError : public BaseError {
public:
    DerivedError() = default;
};

void rethrow_original() {
    try {
        throw DerivedError();
    } catch (BaseError&) {
        throw; // rethrows the caught object as is, with its dynamic type
    }
}

void rethrow_slimmed() {
    try {
        throw DerivedError();
    } catch (BaseError& e) {
        throw e; // constructs a new object from e's static type (BaseError)
    }
}

int main() {
    try {
        rethrow_original();
    } catch (DerivedError&) {
        std::cout << "after throw;, caught as DerivedError\n";
    } catch (BaseError&) {
        std::cout << "after throw;, caught as BaseError\n";
    }

    try {
        rethrow_slimmed();
    } catch (DerivedError&) {
        std::cout << "after throw e;, caught as DerivedError\n";
    } catch (BaseError&) {
        std::cout << "after throw e;, caught as BaseError\n";
    }

    return 0;
}
after throw;, caught as DerivedError
after throw e;, caught as BaseError

PS36. throw; rethrows the caught object as is; the outer catch still catches it as DerivedError — the dynamic type is preserved. throw e; instead constructs a new object from e’s static type (BaseError); catch (DerivedError&) no longer matches — the same root as the object slicing in PS21. Class copy: throw e; carries a copy constructed from e‘s static type, not the original’s.

Catching the Base Class by Value Slices the Derived Exception

DerivedError overrides the virtual function type_name(). When the same object is caught once by value and once by reference as BaseError, which version does type_name() reach?

// exception_catch_by_value_slicing.cpp — a derived exception caught by value into the base is sliced, caught by reference is not
#include <iostream>
#include <stdexcept>

class BaseError : public std::exception {
public:
    virtual const char* type_name() const { return "BaseError"; }
};

class DerivedError : public BaseError {
public:
    const char* type_name() const override { return "DerivedError"; }
};

void throw_derived() {
    throw DerivedError();
}

int main() {
    try {
        throw_derived();
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wcatch-value="
    } catch (BaseError e) { // the compiler deliberately warns about catching by value here, the measurement shows exactly this behavior
#pragma GCC diagnostic pop
        std::cout << "type_name() of the object caught by value: " << e.type_name() << "\n";
    }

    try {
        throw_derived();
    } catch (const BaseError& e) {
        std::cout << "type_name() of the object caught by reference: " << e.type_name() << "\n";
    }

    return 0;
}
type_name() of the object caught by value: BaseError
type_name() of the object caught by reference: DerivedError

PS37. catch (BaseError e) catches by value: the object is copied into a new object of type BaseError — slicing similar to PS21, type_name() reaches BaseError. catch (const BaseError& e) binds directly to the stored DerivedError, does not slice: DerivedError. This is why exception hierarchies are almost always caught by reference. Class copy: the object produced by catch (BaseError e) is a copy of only the original’s BaseError part.

Three Levels of Exception Guarantee: The Strong Level Preserves State, the Basic Level May Change It

After a function that throws an exception, an object’s state can be in one of three levels: the strong guarantee (returns to the state before the call), the basic guarantee (valid but may have changed), and the no-throw guarantee (the next measurement). Two addition functions that cross the same limit — one written with the strong guarantee, one with the basic guarantee — how do they differ after a failed call?

// exception_guarantee_levels.cpp — the strong guarantee does not change state after a failed call, the basic guarantee may
#include <iostream>
#include <stdexcept>

class Counter {
public:
    void strong_add(int amount) {
        int new_value = value_ + amount; // computed in a local variable, value_ has not changed yet
        if (new_value > 100) {
            throw std::runtime_error("limit exceeded");
        }
        value_ = new_value; // assignment happens only once success is certain
    }

    void basic_add(int amount) {
        value_ += amount / 2;             // the first half is applied directly first
        if (value_ + amount / 2 > 100) {
            throw std::runtime_error("limit exceeded");
        }
        value_ += amount / 2;             // then the remaining half
    }

    int value() const { return value_; }

private:
    int value_ = 90;
};

int main() {
    Counter c1;
    try {
        c1.strong_add(20);
    } catch (const std::exception&) {
        std::cout << std::boolalpha
                  << "strong_add failed, did the value change: "
                  << (c1.value() != 90) << "\n";
    }

    Counter c2;
    try {
        c2.basic_add(20);
    } catch (const std::exception&) {
        std::cout << std::boolalpha
                  << "basic_add failed, did the value change: "
                  << (c2.value() != 90) << "\n";
    }

    return 0;
}
strong_add failed, did the value change: false
basic_add failed, did the value change: true

PS38. strong_add performs the limit check in a local variable and only assigns to value_ once success is certain; value_ is never touched: false — the strong guarantee. basic_add applies the limit in two steps, wedging the check between them; at the moment it throws, value_ has already partly changed: true — the basic guarantee. The third level is covered in the next measurement via noexcept. Class itself: the difference is not in the object’s identity, but in the state the call leaves it in.

Copy, Swap, Swap-Exchange: Earning the Strong Guarantee From Structure

Instead of writing the assignment operator by hand, taking the parameter by value and swapping it with an exchange function is another well-known pattern that provides the strong guarantee. Does it produce the correct result in a normal assignment and in self-assignment (c = c;)?

// exception_copy_swap_exchange.cpp — the copy-swap-exchange pattern gives the correct result in normal assignment and self-assignment
#include <iostream>
#include <string>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::string data) : data_(std::move(data)) {}
    Buffer(const Buffer& other) : data_(other.data_) {}

    friend void exchange(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
    }

    Buffer& operator=(Buffer other) { // taken by value: the copy is made at the call itself
        exchange(*this, other);
        return *this;
    } // other is destroyed here, taking the old data_ with it

    const std::string& data() const { return data_; }

private:
    std::string data_;
};

int main() {
    Buffer a("first");
    Buffer b("second");
    a = b;
    std::cout << "after assignment a: " << a.data() << ", b: " << b.data() << "\n";

    Buffer c("third");
    c = c;
    std::cout << "after self-assignment c: " << c.data() << "\n";

    return 0;
}
after assignment a: second, b: second
after self-assignment c: third

PS39. operator=(Buffer other) takes the parameter by value; other is constructed as an independent copy at call time — the copy completes before the body runs, the only place that can throw. The body only calls the noexcept exchange: this ordering gives the strong guarantee automatically — if the copy fails, *this is never changed. In self-assignment (c = c;), no special check is needed; since other is an independent copy, the result is still third. Class copy: the guarantee rests on making a separate copy before the assignment.

noexcept: A No-Throw Contract and the Move Choice on Reallocation

When std::vector exceeds its capacity, it reallocates and either moves or copies the old elements. The standard requires copying if the move constructor is not noexcept — an exception thrown mid-move during reallocation could leave elements unrecoverable. Do two classes, one with a noexcept move constructor and one without, really behave differently when grown with emplace_back?

// exception_noexcept_vector_choice.cpp — a noexcept-marked move constructor takes precedence over copying during vector reallocation
#include <iostream>
#include <vector>

class SafeMove {
public:
    SafeMove() = default;
    SafeMove(const SafeMove&) { ++copy_count; }
    SafeMove(SafeMove&&) noexcept { ++move_count; }
    static int copy_count;
    static int move_count;
};
int SafeMove::copy_count = 0;
int SafeMove::move_count = 0;

class UnsafeMove {
public:
    UnsafeMove() = default;
    UnsafeMove(const UnsafeMove&) { ++copy_count; }
    UnsafeMove(UnsafeMove&&) { ++move_count; } // not noexcept
    static int copy_count;
    static int move_count;
};
int UnsafeMove::copy_count = 0;
int UnsafeMove::move_count = 0;

int main() {
    {
        std::vector<SafeMove> v;
        v.reserve(1);
        for (int i = 0; i < 5; ++i) {
            v.emplace_back();
        }
    }
    std::cout << std::boolalpha
              << "did reallocation with a noexcept move constructor use any copies: "
              << (SafeMove::copy_count > 0) << "\n";
    std::cout << "did reallocation with a noexcept move constructor use moves: "
              << (SafeMove::move_count > 0) << "\n";

    {
        std::vector<UnsafeMove> v;
        v.reserve(1);
        for (int i = 0; i < 5; ++i) {
            v.emplace_back();
        }
    }
    std::cout << "did reallocation with a non-noexcept move constructor use any copies: "
              << (UnsafeMove::copy_count > 0) << "\n";

    return 0;
}
did reallocation with a noexcept move constructor use any copies: false
did reallocation with a noexcept move constructor use moves: true
did reallocation with a non-noexcept move constructor use any copies: true

PS40. SafeMove’s move constructor is noexcept: vector never uses copies while reallocating — copy_count > 0 is false, move_count > 0 is true. UnsafeMove’s is not noexcept: vector chooses to copy — copy_count > 0 is true. noexcept is not just a documentation note; the standard library runs different code based on this contract; breaking the promise and throwing triggers std::terminate, and that path was not run here. Class copy: the presence of noexcept determines whether elements are moved with a copy or a move.

The Bounding Measurement

In this lesson, exceptions were always caught with a single or a sequence of catch blocks; catching every type with catch (...) and partial matching within the same hierarchy were out of scope. That a noexcept-marked function actually throwing triggers std::terminate was not measured — since the program could not end cleanly, it cannot be shown in the sandbox; only the case where the contract is kept was measured. The copy-swap-exchange pattern’s core guarantee — that if the copy constructor itself throws, *this remains untouched — was only addressed indirectly here; the case where the copy constructor actually throws was not run separately.

Summary

  • throw e; copies a named local variable into the exception storage once; catch (ErrorObject e) catches this stored object by value, producing a second copy.
  • catch (const ErrorObject&) binds directly to the stored object, producing no extra copy.
  • An exception thrown in a constructor’s body destroys the members constructed up to that point in reverse construction order, but never calls the object’s own destructor — because the constructor did not complete.
  • Throwing a temporary object directly (throw ErrorObject();) produces one fewer copy than throwing a named local.
  • The expression this lesson measures falls in the copy class: how an exception is thrown and caught directly determines how many copies end up being made.

Next Step

In this lesson, ErrorObject::copy_count shared the same counter across every call as a static member. The next lesson, Namespaces and Code Organization, measures how two symbols with the same name are separated by namespaces, and whether static/extern linkage confines a symbol to a single file or shares it across files — again the question of which symbol an expression works with, but now across file boundaries rather than class boundaries.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close