Skip to Content
Course content

98: The Exception Hierarchy in the Standard Library

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you start writing larger C++ applications, you'll realize that not all errors are created equal. Some things go wrong because the programmer messed up the logic (like passing a negative value to a function that expects a size), while other things go wrong because the environment failed (like a network cable being unplugged). The C++ Standard Library provides a hierarchy of exception classes to help us distinguish between these two categories.

Defining a basic configuration loader

Let's build a small configuration loader. I want this tool to read a value from a simulated file. If the file is missing, that's a runtime issue. If the value read is logically impossible for our application, that's a logic error. I'll start by using the most common base classes: std::runtime_error and std::logic_error.

#include <iostream>
#include <std;string>
#include <stdexcept>

class ConfigLoader {
public:
    int loadSetting(const std::string& key) {
        if (key == "missing") {
            // This is an environmental failure
            throw std::runtime_error("Configuration file not found on disk");
        }
        if (key == "timeout" && 0 == 0) { // Simulating a value check
            // Let's say a timeout of -1 is logically invalid
            throw std::logic_error("Timeout value cannot be negative");
        }
        return 42; 
    }
};

The danger of "Island" exceptions

Here is where I usually trip up when I'm rushing a project. I often decide I want a more "specific" exception name to make the code more readable, so I create a custom class. But if I'm not careful, I create an "island"—an exception that doesn't belong to the standard hierarchy.

// My mistake: creating a standalone class
class ConfigParseError : public std::exception {
    const char* what() const noexcept override {
        return "Failed to parse the config format";
    }
};

At first glance, this looks fine. I inherited from std::exception. But wait—look at how I'm catching errors in my main loop:

int main() {
    ConfigLoader loader;
    try {
        loader.loadSetting("missing");
    } catch (const std::runtime_error& e) {
        std::cout << "Runtime issue: " << e.what() << "\n";
    } catch (const std::logic_error& e) {
        std::cout << "Logic issue: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cout << "General issue: " << e.what() << "\n";
    }
}

If I throw my ConfigParseError, it *will* be caught by std::exception. However, std::exception is a very thin interface. It doesn't have the convenient string constructor that std::runtime_error has. I'm finding myself overriding what() manually for every single single custom exception, which is a tedious waste of time.

Integrating into the runtime hierarchy

The professional way to handle this is to realize that std::runtime_error and std::logic_error are themselves derived from std::exception. Instead of inheriting from the root, I should inherit from the category that fits my error. Since a parsing error is something that happens at runtime due to bad input, I'll pivot my custom exception to inherit from std::runtime_error.

// The corrected approach
class ConfigParseError : public std::runtime_error {
public:
    // I can now just pass the message up to the runtime_error constructor
    ConfigParseError(const std::string& msg) : std::runtime_error(msg) {}
};

Now, my exception hierarchy looks like this: ConfigParseErrorstd::runtime_errorstd::exception. This means my catch (const std::runtime_error& e) block will now catch my custom ConfigParseError, allowing me to group similar types of failures together while still maintaining the ability to catch them specifically if I need to.

Ordering the catch blocks

One last thing you need to remember: the order of your catch blocks matters immensely. C++ checks them from top to bottom. If you put catch (const std::exception& e) at the top, it will swallow every single exception in the hierarchy, and your more specific std::runtime_error blocks will never execute. Always move from the most specific child to the most general parent.




📋 Practical Task

Implementing a Multi-Tiered Database Connection Validator

Create a program that simulates a database connection process. You need to implement the following exception hierarchy and logic:

  • Create a custom exception class ConnectionTimeoutError that inherits from std::runtime_error.
  • Create a custom exception class InvalidQueryError that inherits from std::logic_error.
  • Write a function connectAndQuery(int statusCode) that:
    • Throws ConnectionTimeoutError if statusCode is 408.
    • Throws InvalidQueryError if statusCode is 400.
    • Throws a generic std::runtime_error if statusCode is 500.
  • In main, wrap the function call in a try-block with three separate catch blocks:
    1. One specifically for ConnectionTimeoutError.
    2. One for the broader std::logic_error.
    3. One for the base std::exception to catch everything else.

Test your code by calling the function with different status codes to ensure the correct catch block is triggered.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.