Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Class-Based Exception Hierarchies
Create exception class hierarchies for organized error handling.
Exceptions, classes, and inheritance
Exceptions in member functions
So far, you've seen exceptions used in standalone functions. However, exceptions work equally well in member functions and are particularly valuable in overloaded operators. Consider this subscript operator for a dynamic buffer class:
char& Buffer::operator[](std::size_t index)
{
return m_data[index];
}
This function works perfectly when the index is valid, but lacks any error checking. We could add an assertion to validate the index:
char& Buffer::operator[](std::size_t index)
{
assert(index < m_capacity);
return m_data[index];
}
Now if an invalid index is passed, the program triggers an assertion error. Unfortunately, overloaded operators have strict requirements for their parameter types and return values, leaving no room for returning error codes or Boolean values. Since exceptions don't alter a function's signature, they're ideal for this situation:
char& Buffer::operator[](std::size_t index)
{
if (index >= m_capacity)
{
throw index;
}
return m_data[index];
}
Now when an invalid index is provided, operator[] throws an exception with the invalid index value.
When constructors fail
Constructors are another area where exceptions prove extremely useful. When a constructor must fail (perhaps due to invalid input), throwing an exception signals that object creation failed. In this case, the object's construction is aborted, and all class members that were already constructed are properly destructed.
However, the class's destructor never runs because the object never finished constructing. Since the destructor doesn't execute, you cannot rely on it to clean up resources allocated during construction.
This raises an important question: if we've allocated resources in our constructor and then an exception occurs before the constructor completes, how do we ensure proper cleanup? One approach is wrapping potentially failing code in a try block, catching exceptions, performing cleanup, and then rethrowing (a topic we'll cover in the lesson on rethrowing exceptions). However, this approach adds significant complexity and is error-prone, especially when a class manages multiple resources.
Fortunately, there's a better solution. Since class members are destructed even when constructors fail, placing resource management inside member objects (rather than in the constructor itself) allows those members to clean up automatically when they're destructed.
Here's an example:
#include <iostream>
#include <string>
class Resource
{
public:
Resource()
{
std::cerr << "Resource acquired\n";
}
~Resource()
{
std::cerr << "Resource released\n";
}
};
class Configuration
{
private:
std::string m_filename{};
Resource m_resource;
public:
Configuration(std::string_view filename) : m_filename{filename}
{
if (m_filename.empty())
{
throw std::string{"Filename cannot be empty"};
}
}
~Configuration()
{
std::cerr << "~Configuration()\n"; // should not be called
}
};
int main()
{
try
{
Configuration config{""};
}
catch (const std::string& error)
{
std::cerr << "Error: " << error << '\n';
}
return 0;
}
This prints:
Resource acquired Resource released Error: Filename cannot be empty
In this program, when class Configuration throws an exception, all of Configuration's members get destructed. The m_resource member's destructor runs, providing an opportunity to clean up any resources it allocated.
This is a key reason RAII is advocated so strongly—even in exceptional circumstances, classes implementing RAII clean up after themselves automatically.
However, creating custom classes to manage every resource type is inefficient. Fortunately, the C++ standard library provides RAII-compliant classes for common resource types, such as files (std::fstream) and dynamic memory (std::unique_ptr and other smart pointers).
For example, instead of this:
class DataProcessor
{
private:
int* buffer; // DataProcessor handles allocation/deallocation
};
Do this:
class DataProcessor
{
private:
std::unique_ptr<int[]> buffer; // std::unique_ptr handles allocation/deallocation
};
In the first case, if DataProcessor's constructor fails after allocating buffer, DataProcessor must handle cleanup, which is challenging. In the second case, if the constructor fails after buffer allocates its memory, buffer's destructor executes automatically and returns the memory to the system. DataProcessor doesn't need explicit cleanup when resource handling is delegated to RAII-compliant members.
Exception classes
A significant problem with using primitive types (like int) as exceptions is their inherent ambiguity. An even bigger issue is disambiguating what an exception means when multiple operations occur within a try block.
// Using the Buffer operator[] defined above
try
{
char* data{ new char{buffer[index1] + buffer[index2]} };
}
catch (std::size_t index)
{
// What exactly happened?
}
In this example, catching a std::size_t exception doesn't tell us much. Was one of the buffer indices out of bounds? Did operator+ cause an overflow? Did operator new fail due to insufficient memory? Unfortunately, there's no straightforward way to distinguish between these cases. While throwing const char* exceptions helps identify what went wrong, this approach still doesn't allow us to handle exceptions from different sources differently.
One solution is using exception classes. An exception class is simply a normal class designed specifically to be thrown as an exception. Let's design a simple exception class for our Buffer class:
#include <string>
#include <string_view>
class BufferException
{
private:
std::string m_error;
public:
BufferException(std::string_view error)
: m_error{error}
{
}
const std::string& getError() const { return m_error; }
};
Here's a complete program using this class:
#include <iostream>
#include <string>
#include <string_view>
#include <cstddef>
class BufferException
{
private:
std::string m_error;
public:
BufferException(std::string_view error)
: m_error{error}
{
}
const std::string& getError() const { return m_error; }
};
class Buffer
{
private:
char m_data[256]{}; // fixed size buffer for simplicity
public:
Buffer() {}
std::size_t getCapacity() const { return 256; }
char& operator[](std::size_t index)
{
if (index >= getCapacity())
{
throw BufferException{"Index out of range"};
}
return m_data[index];
}
};
int main()
{
Buffer buffer;
try
{
char ch{ buffer[300] }; // out of range access
}
catch (const BufferException& exception)
{
std::cerr << "Buffer error: " << exception.getError() << '\n';
}
return 0;
}
Using exception classes, we can have exceptions return detailed descriptions of problems, providing context about what went wrong. Since BufferException is its own unique type, we can specifically catch exceptions thrown by the buffer class and handle them distinctly from other exceptions.
Note that exception handlers should catch class exceptions by reference rather than by value. This prevents the compiler from copying the exception at the catch point, which can be expensive for class objects, and prevents object slicing when dealing with derived exception classes (which we'll discuss next). Catching exceptions by pointer should generally be avoided unless you have a specific reason.
Catch exceptions of fundamental types by value since they're cheap to copy. Catch exceptions of class types by const reference to prevent expensive copying and slicing.
Since classes can be thrown as exceptions, and classes can be derived from other classes, we need to consider inheritance relationships in exception handling. Exception handlers match not only specific types but also classes derived from those types. Consider this example:
#include <iostream>
class NetworkError
{
public:
NetworkError() {}
};
class TimeoutError : public NetworkError
{
public:
TimeoutError() {}
};
int main()
{
try
{
throw TimeoutError{};
}
catch (const NetworkError& error)
{
std::cerr << "Caught NetworkError\n";
}
catch (const TimeoutError& error)
{
std::cerr << "Caught TimeoutError\n";
}
return 0;
}
In this example, we throw a TimeoutError exception. However, the output is:
Caught NetworkError
What happened?
First, as mentioned, derived classes are caught by handlers for base types. Since TimeoutError derives from NetworkError, TimeoutError is-a NetworkError (they have an is-a relationship). Second, when C++ searches for an exception handler, it does so sequentially. The first thing C++ checks is whether the NetworkError handler matches the TimeoutError exception. Since TimeoutError is-a NetworkError, the answer is yes, and the NetworkError catch block executes! The TimeoutError catch block is never even considered.
To make this work as expected, we must reverse the order of catch blocks:
#include <iostream>
class NetworkError
{
public:
NetworkError() {}
};
class TimeoutError : public NetworkError
{
public:
TimeoutError() {}
};
int main()
{
try
{
throw TimeoutError{};
}
catch (const TimeoutError& error)
{
std::cerr << "Caught TimeoutError\n";
}
catch (const NetworkError& error)
{
std::cerr << "Caught NetworkError\n";
}
return 0;
}
This way, the TimeoutError handler gets first chance at catching TimeoutError objects before the NetworkError handler can. Objects of type NetworkError won't match the TimeoutError handler (TimeoutError is-a NetworkError, but NetworkError is not a TimeoutError), so they'll fall through to the NetworkError handler.
List handlers for derived exception classes before handlers for base classes.
The ability to catch derived exceptions using a base class handler turns out to be extremely useful.
Many classes and operators in the standard library throw exception classes on failure. For example, operator new can throw std::bad_alloc if it cannot allocate enough memory. A failed dynamic_cast throws std::bad_cast. As of C++20, there are 28 different exception classes that can be thrown, with more added in subsequent language standards.
The good news is all these exception classes derive from a single class called std::exception (defined in the <exception> header). std::exception is a small interface class designed to serve as a base class for any exception thrown by the C++ standard library.
Much of the time, when the standard library throws an exception, we don't care whether it's a bad allocation, a bad cast, or something else. We just care that something catastrophic happened and our program is failing. Thanks to std::exception, we can set up one exception handler to catch std::exception, and we'll catch all derived exceptions together in one place:
#include <cstddef>
#include <exception>
#include <iostream>
#include <limits>
#include <string>
int main()
{
try
{
// Code using standard library goes here
// We'll trigger an exception intentionally for demonstration
std::string text;
text.resize(std::numeric_limits<std::size_t>::max()); // triggers length_error or allocation exception
}
catch (const std::exception& exception)
{
std::cerr << "Standard library exception: " << exception.what() << '\n';
}
return 0;
}
When tested, this program prints:
Standard library exception: basic_string::_M_replace_aux
The example is straightforward. The noteworthy aspect is that std::exception has a virtual member function named what() that returns a C-style string description of the exception. Most derived classes override what() to customize the message. Note that this string is meant for descriptive purposes only—don't use it for comparisons, as it's not guaranteed to be consistent across compilers.
Sometimes we want to handle specific exception types differently. In this case, we can add a handler for that specific type and let others fall through to the base handler:
try
{
// code using standard library goes here
}
catch (const std::length_error& exception)
{
std::cerr << "String operation exceeded maximum length\n";
}
catch (const std::exception& exception)
{
std::cerr << "Standard library exception: " << exception.what() << '\n';
}
In this example, exceptions of type std::length_error are caught by the first handler. Exceptions of type std::exception and all other derived classes are caught by the second handler.
Such inheritance hierarchies allow fine-grained control—we can use specific handlers to target specific derived exception classes, or use base class handlers to catch entire hierarchies. This provides flexibility in exception handling while minimizing the amount of code we need to write.
Using standard exceptions directly
Nothing throws std::exception directly, and neither should you. However, you should feel free to throw other standard exception classes if they adequately represent your needs. You can find a complete list of standard exceptions on cppreference.
std::runtime_error (included in the <stdexcept> header) is a popular choice because it has a generic name and its constructor accepts a customizable message:
#include <exception>
#include <iostream>
#include <stdexcept>
int main()
{
try
{
throw std::runtime_error{"Connection refused by server"};
}
catch (const std::exception& exception)
{
std::cerr << "Standard library exception: " << exception.what() << '\n';
}
return 0;
}
This prints:
Standard library exception: Connection refused by server
Deriving your own classes from std::exception or std::runtime_error
You can derive your own classes from std::exception and override the virtual what() member function. Here's the same program as above, with BufferException derived from std::exception:
#include <exception>
#include <iostream>
#include <string>
#include <string_view>
#include <cstddef>
class BufferException : public std::exception
{
private:
std::string m_error{};
public:
BufferException(std::string_view error)
: m_error{error}
{
}
const char* what() const noexcept override { return m_error.c_str(); }
};
class Buffer
{
private:
char m_data[256]{};
public:
Buffer() {}
std::size_t getCapacity() const { return 256; }
char& operator[](std::size_t index)
{
if (index >= getCapacity())
{
throw BufferException{"Index out of range"};
}
return m_data[index];
}
};
int main()
{
Buffer buffer;
try
{
char ch{ buffer[300] };
}
catch (const BufferException& exception) // derived catch blocks go first
{
std::cerr << "Buffer error: " << exception.what() << '\n';
}
catch (const std::exception& exception)
{
std::cerr << "Other standard exception: " << exception.what() << '\n';
}
return 0;
}
Note that virtual function what() has the noexcept specifier (meaning the function promises not to throw exceptions itself). Therefore, our override should also have noexcept.
Since std::runtime_error already has string handling capabilities, it's also a popular base class for derived exception classes. std::runtime_error can take a C-style string parameter or a const std::string& parameter.
Here's the same example derived from std::runtime_error instead:
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
#include <cstddef>
class BufferException : public std::runtime_error
{
public:
BufferException(const std::string& error)
: std::runtime_error{error}
{
}
// no need to override what() since std::runtime_error::what() handles it
};
class Buffer
{
private:
char m_data[256]{};
public:
Buffer() {}
std::size_t getCapacity() const { return 256; }
char& operator[](std::size_t index)
{
if (index >= getCapacity())
{
throw BufferException{"Index out of range"};
}
return m_data[index];
}
};
int main()
{
Buffer buffer;
try
{
char ch{ buffer[300] };
}
catch (const BufferException& exception) // derived catch blocks go first
{
std::cerr << "Buffer error: " << exception.what() << '\n';
}
catch (const std::exception& exception)
{
std::cerr << "Other standard exception: " << exception.what() << '\n';
}
return 0;
}
Whether you create standalone exception classes, use standard exception classes, or derive from std::exception or std::runtime_error is up to you. All are valid approaches depending on your requirements.
The lifetime of exceptions
When an exception is thrown, the object being thrown is typically a temporary or local variable allocated on the stack. However, exception handling may unwind the function, destroying all local variables. So how does the thrown exception object survive stack unwinding?
When an exception is thrown, the compiler makes a copy of the exception object to some unspecified memory region (outside the call stack) reserved for handling exceptions. This ensures the exception object persists regardless of whether or how many times the stack is unwound. The exception is guaranteed to exist until it has been handled.
This means thrown objects generally need to be copyable (even if the stack isn't actually unwound). Smart compilers may be able to perform a move instead or elide the copy altogether in specific circumstances.
Exception objects need to be copyable.
Here's an example showing what happens when we try to throw a non-copyable derived object:
class NetworkError { public: NetworkError() {} };
class TimeoutError : public NetworkError { public: TimeoutError() {}
TimeoutError(const TimeoutError&) = delete; // not copyable
};
int main() { TimeoutError error{};
try
{
throw error; // compile error: TimeoutError copy constructor was deleted
}
catch (const TimeoutError& error)
{
std::cerr << "Caught TimeoutError\n";
}
catch (const NetworkError& error)
{
std::cerr << "Caught NetworkError\n";
}
return 0;
}
When compiled, the compiler complains that the TimeoutError copy constructor is unavailable and halts compilation.
Exception objects should not hold pointers or references to stack-allocated objects. If a thrown exception causes stack unwinding (destroying stack-allocated objects), these pointers or references may become dangling.
## Summary
**Exceptions in member functions**: Exceptions work equally well in member functions and are particularly valuable in overloaded operators where return values cannot indicate errors due to strict type requirements.
**Constructor exceptions**: When a constructor throws an exception, object construction is aborted and all class members that were already constructed are properly destructed. However, the class's destructor never runs because the object never finished constructing.
**RAII and exception safety**: Place resource management inside member objects (using RAII) rather than in the constructor itself, allowing those members to clean up automatically when they're destructed, even if construction fails.
**Exception classes**: An exception class is a normal class designed specifically to be thrown as an exception. Exception classes solve the ambiguity problem of primitive types and allow detailed error information to be passed to handlers.
**Catching exceptions by reference**: Catch exceptions of fundamental types by value since they're cheap to copy. Catch exceptions of class types by const reference to prevent expensive copying and slicing.
**Inheritance and exception handlers**: Exception handlers match not only specific types but also classes derived from those types. List handlers for derived exception classes before handlers for base classes to ensure proper matching.
**std::exception hierarchy**: All standard library exception classes derive from std::exception, allowing a single handler to catch all standard library exceptions together. The what() virtual member function returns a C-style string description of the exception.
**Exception lifetime**: When an exception is thrown, the compiler makes a copy of the exception object to some unspecified memory region outside the call stack, ensuring the exception persists regardless of stack unwinding. Exception objects must be copyable.
Understanding exception classes and inheritance enables writing robust, maintainable error handling code that can distinguish between different error conditions while minimizing code duplication.
Class-Based Exception Hierarchies - Quiz
Test your understanding of the lesson.
Practice Exercises
Exception Class Hierarchy
Create a custom exception class hierarchy with a base NetworkError class and derived TimeoutError class. Demonstrate proper catch order with derived classes before base classes.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!