What Should a Class Throw?

Everything so far has thrown whatever was convenient: an int, a const char*, a double. That works in a lesson where one thing can go wrong. It stops working the moment a try block contains more than one operation, because the handler receives a value with no indication of where it came from.

This lesson answers one question and lets everything else follow from it: what type should a failing operation throw? The answer climbs a ladder, and each rung fixes a specific defect in the rung below.

What you throw What the handler can tell What it still cannot do
A fundamental type such as std::size_t the value, and nothing more tell two unrelated failures apart
A class of your own exactly which operation failed, plus any detail you stored group related failures under one handler
A small hierarchy of your own classes the specific failure, or the whole family, your choice be caught by code that knows nothing about your types
A class derived from std::exception all of the above, plus what() that any caller already understands nothing you are likely to miss

Along the way, two mechanics decide how far you can push this: what happens when the failing function is a constructor, and where the thrown object actually lives while the stack unwinds.

Signalling Failure Where a Return Value Is Not Available

Exceptions are not restricted to free functions. They work identically in member functions, and there is one family of member functions where they are the only option available.

Consider a subscript operator on a row of theatre seats:

char& SeatRow::operator[](std::size_t slot)
{
    return m_seats[slot];
}

That is correct for every valid slot and catastrophic for every invalid one. The obvious fixes both fail. An assertion reports the problem but takes the whole program down with it and disappears entirely once NDEBUG is defined. A status code has nowhere to go: operator[] must return a reference to the element so that row[2] = 'X' can work, and that return type is fixed by what callers need to do with the result, not by what the implementation would like to report.

An exception is the way out, because throwing changes nothing about the function's signature:

char& SeatRow::operator[](std::size_t slot)
{
    if (slot >= seatCount())
        throw slot;

    return m_seats[slot];
}
Key insight
Overloaded operators have their parameter and return types dictated by the syntax they implement, which leaves no channel for an error code. An exception travels outside the signature entirely, so it is the one error-reporting mechanism an operator can use without distorting the interface.

Why a Thrown Number Tells You Almost Nothing

Throwing slot compiles, and for one line of code inside a try block it is even readable. Put two failing operations in the same block and the handler is left guessing:

#include <array>
#include <cstddef>
#include <iostream>

class SeatRow
{
private:
    std::array<char, 8> m_seats{};

public:
    std::size_t seatCount() const { return m_seats.size(); }

    char& operator[](std::size_t slot)
    {
        if (slot >= seatCount())
            throw slot;

        return m_seats[slot];
    }
};

char rowLetter(std::size_t rowNumber)
{
    if (rowNumber >= 26)
        throw rowNumber;

    return static_cast<char>('A' + rowNumber);
}

int main()
{
    SeatRow stalls{};

    try
    {
        char letter{rowLetter(31)};
        char seat{stalls[3]};

        std::cout << "assigned " << letter << seat << '\n';
    }
    catch (std::size_t offender)
    {
        std::cout << "a seat number was rejected: " << offender << '\n';
    }

    return 0;
}

This prints:

a seat number was rejected: 31

The handler now knows a number. It does not know whether that number was a row, a seat, or something a third function threw next month when someone adds one. Switching to const char* improves the message and fixes nothing structural, because every failure in the program still arrives as the same type and lands in the same handler.

The defect is not the message. It is that the type carries no identity.

A Type That Carries the Story

Give the failure a type of its own and both problems disappear at once. An exception class is nothing more exotic than a normal class you intend to throw. It has no special base, no keyword, and no compiler support behind it.

#include <array>
#include <cstddef>
#include <iostream>
#include <string>

class SeatingFault
{
private:
    std::string m_detail{};

public:
    explicit SeatingFault(const std::string& detail)
        : m_detail{detail}
    {
    }

    const std::string& detail() const { return m_detail; }
};

class SeatRow
{
private:
    std::array<char, 8> m_seats{};

public:
    std::size_t seatCount() const { return m_seats.size(); }

    char& operator[](std::size_t slot)
    {
        if (slot >= seatCount())
            throw SeatingFault{"seat number is past the last seat in this row"};

        return m_seats[slot];
    }
};

char rowLetter(std::size_t rowNumber)
{
    if (rowNumber >= 26)
        throw SeatingFault{"row number is past the last lettered row"};

    return static_cast<char>('A' + rowNumber);
}

int main()
{
    SeatRow stalls{};

    try
    {
        char letter{rowLetter(31)};
        char seat{stalls[3]};

        std::cout << "assigned " << letter << seat << '\n';
    }
    catch (const SeatingFault& fault)
    {
        std::cout << "seating fault: " << fault.detail() << '\n';
    }

    return 0;
}
seating fault: row number is past the last lettered row

Two things changed. The message now describes the failure in words the caller can act on, and catch (const SeatingFault&) selects seating failures specifically, so an unrelated failure of some other type passes straight through this handler to whichever one is meant to deal with it. A class can carry as much context as the handler needs: the offending number, the row letter, a timestamp, a suggested recovery.

By Reference, Because Catching Copies

That handler takes the exception by const reference, and the choice is not cosmetic. A catch parameter is initialised from the thrown object, so a by-value parameter copies it. For a class with a std::string member that is a wasted allocation on the failure path, which is exactly where you would rather not be allocating.

The bigger problem shows up as soon as one exception class derives from another. Catching a derived object into a base-class parameter by value slices it, discarding everything the derived class added. This program demonstrates the mistake in its first try block:

#include <iostream>
#include <string>

class BookingFault
{
public:
    virtual ~BookingFault() = default;

    virtual std::string label() const { return "BookingFault"; }
};

class SeatTaken : public BookingFault
{
public:
    std::string label() const override { return "SeatTaken"; }
};

int main()
{
    try
    {
        throw SeatTaken{};
    }
    catch (BookingFault sliced) // by value, so only the base part survives
    {
        std::cout << "handled as " << sliced.label() << '\n';
    }

    try
    {
        throw SeatTaken{};
    }
    catch (const BookingFault& intact) // by reference, so the object arrives whole
    {
        std::cout << "handled as " << intact.label() << '\n';
    }

    return 0;
}

The platform compiler flags the first handler before you ever run it:

s.cpp: In function 'int main()':
s.cpp:24:25: warning: catching polymorphic type 'class BookingFault' by value [-Wcatch-value=]
   24 |     catch (BookingFault sliced) // by value, so only the base part survives
      |                         ^~~~~~
handled as BookingFault
handled as SeatTaken

Same thrown object, two different answers. The by-value handler copied the BookingFault sub-object out of a SeatTaken and threw the rest away, so the virtual call resolved to the base version. The reference handler bound to the whole object and the override ran.

Best Practice
Catch class-type exceptions as const T&. That avoids copying an object on the failure path and, more importantly, keeps a derived exception intact instead of slicing it down to its base. Fundamental types are cheap and cannot be sliced, so catching those by value is fine. Catching by pointer needs a specific reason, since it drags object lifetime back into a situation you were trying to simplify.

Handlers Match Along the Inheritance Chain

A catch handler for a base class also matches every class derived from it. That is what makes exception hierarchies worth building, and it is also the source of the single most common ordering bug in exception handling.

C++ tests handlers in the order they are written and takes the first one that matches. It does not search for the closest match. So a base-class handler placed above a derived-class handler swallows everything:

#include <iostream>
#include <string>

class BookingFault
{
public:
    virtual ~BookingFault() = default;

    virtual std::string label() const { return "BookingFault"; }
};

class SeatTaken : public BookingFault
{
public:
    std::string label() const override { return "SeatTaken"; }
};

int main()
{
    try
    {
        throw SeatTaken{};
    }
    catch (const BookingFault& fault) // reached first, and SeatTaken is-a BookingFault
    {
        std::cout << "the BookingFault handler took a " << fault.label() << '\n';
    }
    catch (const SeatTaken& fault) // never reached
    {
        std::cout << "the SeatTaken handler took a " << fault.label() << '\n';
    }

    return 0;
}

The second handler is dead code, and the compiler says so:

s.cpp: In function 'int main()':
s.cpp:28:5: warning: exception of type 'SeatTaken' will be caught by earlier handler [-Wexceptions]
   28 |     catch (const SeatTaken& fault) // never reached
      |     ^~~~~
s.cpp:24:5: note: for type 'BookingFault'
   24 |     catch (const BookingFault& fault) // reached first, and SeatTaken is-a BookingFault
      |     ^~~~~
the BookingFault handler took a SeatTaken

SeatTaken is-a BookingFault, so the first handler matched and the second was never considered. Note that fault.label() still reported SeatTaken: the object was not sliced, because that handler binds a reference rather than copying. Only handler selection went wrong.

Reverse the two handlers and each exception reaches the most specific one that applies:

#include <iostream>
#include <string>

class BookingFault
{
public:
    virtual ~BookingFault() = default;

    virtual std::string label() const { return "BookingFault"; }
};

class SeatTaken : public BookingFault
{
public:
    std::string label() const override { return "SeatTaken"; }
};

void sellSeat(bool alreadySold)
{
    if (alreadySold)
        throw SeatTaken{};

    throw BookingFault{};
}

int main()
{
    for (bool alreadySold : {true, false})
    {
        try
        {
            sellSeat(alreadySold);
        }
        catch (const SeatTaken& fault) // most derived handler first
        {
            std::cout << "the SeatTaken handler took a " << fault.label() << '\n';
        }
        catch (const BookingFault& fault)
        {
            std::cout << "the BookingFault handler took a " << fault.label() << '\n';
        }
    }

    return 0;
}
the SeatTaken handler took a SeatTaken
the BookingFault handler took a BookingFault

The relationship only runs one way. A SeatTaken matches the BookingFault handler, but a plain BookingFault is not a SeatTaken, so it falls past the first handler and into the second.

Rule
Order the handlers on a try from most specific to most general: any type whose base class is also handled has to sit above that base. Get the order backwards and the lower handler becomes unreachable, and while the platform compiler warns about it, a build that ignores warnings just runs the wrong recovery path.

Joining the Standard Hierarchy

Your own hierarchy solves your own failures. It does nothing for the failures the standard library raises, and there are plenty: operator new throws std::bad_alloc when memory runs out, std::vector::at throws std::out_of_range, a dynamic_cast to a reference type throws std::bad_cast when the conversion is impossible. Each language standard adds more.

They share one ancestor. Every exception type the standard library throws derives from std::exception, declared in <exception>, a deliberately minimal base whose entire job is to be that common ancestor. One handler therefore covers all of them:

#include <exception>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> ticketPrices{45, 60, 85};

    try
    {
        int chosenTier{ticketPrices.at(9)}; // there is no tier 9
        std::cout << "that tier costs " << chosenTier << '\n';
    }
    catch (const std::exception& fault)
    {
        std::cout << "library fault: " << fault.what() << '\n';
    }

    return 0;
}

On the platform compiler:

library fault: vector::_M_range_check: __n (which is 9) >= this->size() (which is 3)

std::exception supplies one virtual member function, what(), returning a C-style string that describes the failure. Derived types override it to say something more specific.

Warning
Read the output above and notice how much of it is implementation detail. The text what() returns is a diagnostic aid, not data. Another standard library will word it differently, so never branch on it, parse it, or compare it against a string you have written down. Branch on the exception's type instead, which is exactly what the hierarchy is for.

Catching the base type is a blunt instrument on purpose. When one failure deserves its own treatment, handle that type first and let the rest fall through:

#include <exception>
#include <iostream>
#include <stdexcept>
#include <vector>

int main()
{
    std::vector<int> ticketPrices{45, 60, 85};

    try
    {
        int chosenTier{ticketPrices.at(9)};
        std::cout << "that tier costs " << chosenTier << '\n';
    }
    catch (const std::out_of_range&) // the specific case, handled specifically
    {
        std::cout << "no such price tier exists\n";
    }
    catch (const std::exception& fault) // anything else in the hierarchy lands here
    {
        std::cout << "library fault: " << fault.what() << '\n';
    }

    return 0;
}
no such price tier exists

That is the same ordering rule as before, applied to a hierarchy somebody else designed: specific handlers above general ones, with std::exception as the widest net you can cast while still catching something meaningful.

You can also throw the standard types yourself. std::runtime_error, from <stdexcept>, is the usual pick when you have a message and no reason to invent a type, because its constructor takes one:

#include <exception>
#include <iostream>
#include <stdexcept>

int main()
{
    try
    {
        throw std::runtime_error{"the seating plan file is missing a row header"};
    }
    catch (const std::exception& fault)
    {
        std::cout << "library fault: " << fault.what() << '\n';
    }

    return 0;
}
library fault: the seating plan file is missing a row header
Warning
Throw the derived standard types, never std::exception itself. It exists to be caught, not thrown: it carries no message, so a handler receives an object that confirms something failed and says nothing about what. The standard library never throws it directly either.

Two Ways to Derive Your Own

SeatingFault from earlier is a fine class that no unfamiliar code can catch, because nothing outside your project knows the name. Deriving it from the standard hierarchy fixes that: a generic catch (const std::exception&) anywhere up the call stack will now catch it too. There are two routes, and they differ only in how much you write yourself.

Derive from std::exception Derive from std::runtime_error
Stores the message you do, in your own member the base does
Implements what() you do, and it must be noexcept inherited, nothing to write
Constructor takes whatever you decide a const std::string& or a const char*
Suits a class carrying structured data rather than a sentence a class whose entire payload is a message

Deriving from std::exception means owning the string and the override:

#include <array>
#include <cstddef>
#include <exception>
#include <iostream>
#include <string>

class SeatingFault : public std::exception
{
private:
    std::string m_detail{};

public:
    explicit SeatingFault(const std::string& detail)
        : m_detail{detail}
    {
    }

    const char* what() const noexcept override { return m_detail.c_str(); }
};

class SeatRow
{
private:
    std::array<char, 8> m_seats{};

public:
    std::size_t seatCount() const { return m_seats.size(); }

    char& operator[](std::size_t slot)
    {
        if (slot >= seatCount())
            throw SeatingFault{"seat number is past the last seat in this row"};

        return m_seats[slot];
    }
};

int main()
{
    SeatRow stalls{};

    try
    {
        char seat{stalls[19]};
        std::cout << "assigned seat " << seat << '\n';
    }
    catch (const SeatingFault& fault) // our own type first
    {
        std::cout << "seating fault: " << fault.what() << '\n';
    }
    catch (const std::exception& fault) // any other standard exception
    {
        std::cout << "library fault: " << fault.what() << '\n';
    }

    return 0;
}
seating fault: seat number is past the last seat in this row

Two details in that override matter. It returns const char* because that is the signature it is overriding, hence the c_str(). And it is marked noexcept because std::exception::what() is: a handler running during unwinding is a terrible place to raise a second exception, and the base type promises never to.

Deriving from std::runtime_error deletes almost all of it:

#include <array>
#include <cstddef>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

class SeatingFault : public std::runtime_error
{
public:
    explicit SeatingFault(const std::string& detail)
        : std::runtime_error{detail}
    {
    }
};

class SeatRow
{
private:
    std::array<char, 8> m_seats{};

public:
    std::size_t seatCount() const { return m_seats.size(); }

    char& operator[](std::size_t slot)
    {
        if (slot >= seatCount())
            throw SeatingFault{"seat number is past the last seat in this row"};

        return m_seats[slot];
    }
};

int main()
{
    SeatRow stalls{};

    try
    {
        char seat{stalls[19]};
        std::cout << "assigned seat " << seat << '\n';
    }
    catch (const SeatingFault& fault)
    {
        std::cout << "seating fault: " << fault.what() << '\n';
    }
    catch (const std::exception& fault)
    {
        std::cout << "library fault: " << fault.what() << '\n';
    }

    return 0;
}
seating fault: seat number is past the last seat in this row

Identical behaviour, one forwarding constructor, no what() at all. All three shapes in this lesson are legitimate: a standalone class when your project is the only thing that will ever catch it, a standard type when a message is the whole story, and a derived type when you want both your own identity and the reach of the standard hierarchy.

Failure During Construction

A constructor has no return value, which puts it in the same position as an overloaded operator: throwing is the only way it can refuse. Doing so is well defined, and the exact sequence is what makes the next best practice necessary.

When a constructor throws, construction is abandoned. Every member that had already been initialised is destructed, in reverse order, exactly as usual. What does not happen is the class's own destructor: the object never finished becoming one, so it never runs.

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

class PrinterSpool
{
public:
    PrinterSpool()
    {
        std::cout << "spool opened\n";
    }

    ~PrinterSpool()
    {
        std::cout << "spool closed\n";
    }
};

class BoxOffice
{
private:
    std::string m_venue{};
    PrinterSpool m_spool{};

public:
    explicit BoxOffice(const std::string& venue)
        : m_venue{venue}
    {
        if (m_venue.empty())
            throw std::runtime_error{"a box office needs a venue name"};

        std::cout << "box office open for " << m_venue << '\n';
    }

    ~BoxOffice()
    {
        std::cout << "box office closed\n"; // never runs for a failed construction
    }
};

int main()
{
    try
    {
        BoxOffice kiosk{""};
    }
    catch (const std::exception& fault)
    {
        std::cout << "could not open: " << fault.what() << '\n';
    }

    return 0;
}
spool opened
spool closed
could not open: a box office needs a venue name

Read those three lines as a sequence. m_spool was constructed before the constructor body began. The body threw. m_spool was destructed on the way out and printed its second line. ~BoxOffice never appeared anywhere.

That is the whole argument for putting resources in members. Anything the constructor body acquires directly is stranded when the body throws, because the destructor that would have released it does not run. Anything a member acquired is released automatically, because members are destructed regardless.

So a class that manages memory by hand:

class BoxOffice
{
private:
    int* m_seatMap{}; // BoxOffice is now responsible for freeing this
};

becomes a class that manages nothing:

#include <memory>

class BoxOffice
{
private:
    std::unique_ptr<int[]> m_seatMap{}; // the member releases it, failure or not
};
Best Practice
Let members own resources, not constructor bodies. A member that follows RAII releases what it holds whether construction succeeds or fails, which turns exception safety in a constructor into something you get for free rather than something you write. The standard library already supplies these members for the common cases: std::unique_ptr for dynamic memory, std::fstream for files, std::vector and std::string for buffers.
Warning
C++ also offers a function try block, which wraps a constructor's member initialiser list as well as its body, and lesson 8 of this chapter covers it. It is a way to observe or translate a failure, not a way to clean up after one. By the time its handler runs the object is already dead, so referring to its members there is undefined behaviour. Cleanup still belongs to the members.

Where the Thrown Object Actually Lives

One question is left hanging by everything above. throw SeatTaken{}; creates a temporary, and propagating the exception unwinds the stack that temporary was sitting on. How does it survive its own storage being destroyed?

It does not. What survives is a copy. On a throw, the implementation copies the exception object into storage it reserves for the purpose, outside the call stack entirely, and that copy is what handlers bind to. It lives until the exception has been handled, no matter how many frames unwind on the way, and it is unaffected by anything the unwinding destroys.

The practical consequence is a requirement on the thrown type: it has to be copyable. Compilers are permitted to move instead, or to elide the copy altogether, but the type must permit the copy for the throw to be valid at all. Delete the copy constructor and the throw stops compiling. This program will not compile:

#include <iostream>
#include <string>

class BookingFault
{
public:
    virtual ~BookingFault() = default;

    virtual std::string label() const { return "BookingFault"; }
};

class SeatTaken : public BookingFault
{
public:
    SeatTaken() = default;
    SeatTaken(const SeatTaken&) = delete; // deliberately not copyable

    std::string label() const override { return "SeatTaken"; }
};

int main()
{
    SeatTaken clash{};

    try
    {
        throw clash; // will not compile
    }
    catch (const SeatTaken& fault)
    {
        std::cout << "handled as " << fault.label() << '\n';
    }

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:27:15: error: use of deleted function 'SeatTaken::SeatTaken(const SeatTaken&)'
   27 |         throw clash; // will not compile
      |               ^~~~~
s.cpp:16:5: note: declared here
   16 |     SeatTaken(const SeatTaken&) = delete; // deliberately not copyable
      |     ^~~~~~~~~

The diagnostic points at the throw, not at the catch, which tells you where the copy is required.

The same reasoning constrains what an exception object may hold. Its lifetime deliberately outlasts the frames between the throw and the handler, so a pointer or reference to anything living in those frames is dangling by the time the handler reads it.

Warning
An exception object must own its contents. Store a std::string, never a std::string_view or a pointer into a local buffer: the object that data referred to is destroyed by the unwinding that carries the exception outward, and the handler ends up reading freed memory.

Looking Forward

The next lesson covers rethrowing, which is how a handler passes a failure onward after acting on it, and where the difference between throw; and throw fault; turns out to matter for exactly the slicing reasons this lesson demonstrated. Two lessons on, function try blocks give constructors a handler that also covers the member initialiser list. Later in the chapter, noexcept moves in the opposite direction, letting a function promise that nothing will come out of it at all.

Key Terminology

  • Exception class: an ordinary class written to be thrown, carrying whatever context a handler needs
  • std::exception: the base class of every exception the standard library throws, declared in <exception>
  • what(): the virtual member of std::exception returning a C-style description, overridden by derived types and marked noexcept
  • Object slicing: the loss of a derived object's own members when it is copied into a base-class object, which a by-value catch parameter causes
  • Handler ordering: the first catch whose type matches wins, so derived types must be listed before their bases
  • RAII: tying a resource's lifetime to an object's, so the resource is released by a destructor rather than by explicit code

Summary

  • An overloaded operator or a constructor has no spare return value for an error code, and an exception reports failure without altering the signature
  • Throwing a fundamental type gives a handler a value with no identity, so two unrelated failures in one try block become indistinguishable
  • An exception class is just a normal class; its type identifies the failure and its members carry the detail
  • Catch class types as const T&: a by-value handler copies the object and slices a derived exception down to its base, and the platform compiler warns when you do it
  • A handler for a base class matches every derived class, and handlers are tried in written order, so derived handlers must come first or the later ones are unreachable
  • Every standard library exception derives from std::exception, so one handler catches all of them; what() is for reading, never for comparing
  • Throw derived standard types such as std::runtime_error rather than std::exception itself, which carries no message
  • Deriving from std::exception means writing your own storage and a noexcept what() override; deriving from std::runtime_error inherits both
  • A throwing constructor aborts construction and destructs the members already built, but the class destructor never runs, so resources belong in RAII members rather than in the constructor body
  • The thrown object is copied into storage outside the call stack, so the type must be copyable and must own everything it holds