Custom exception types

A standard exception says what went wrong in the library's words. A custom one says it in the program's: a missing key, a malformed line, a refused payment. The type is what a caller catches, so the type is the interesting part.

Deriving from std::runtime_error gives the message and the base class that every standard handler already catches. This lesson builds a small hierarchy, adds a field, translates between layers, and ends with the catch that cuts the derived part off.

The message is built once, in the constructor, and what() returns a pointer to it. A message assembled from members afterwards would need storage the exception does not have, which is why the fields are copied into the message at the throw site.

A library that exports exception types makes them part of its interface: a caller can catch the type by name, so renaming it is a breaking change, and a new derived type can appear without breaking existing handlers.

A named failure

The class below adds nothing to std::runtime_error except its name. That name is enough for a caller to catch this failure and not the others, and the constructor passes the message up.

The program throws the custom type and catches it as std::exception, which works because runtime_error derives from it. A catch of config_error would be more specific and is the next section.

Keep the constructor explicit and take the message by const reference or string. The base class copies it, and what() returns a pointer to that copy.

#include <iostream> #include <stdexcept> #include <string> class config_error : public std::runtime_error { public: explicit config_error(const std::string& message) : std::runtime_error(message) {} }; int main() { try { throw config_error{"missing key: port"}; } catch (const std::exception& error) { std::cout << error.what() << '\n'; } }

g++ -std=c++23 -Wall -Wextra derive.cpp -o derive
./derive

missing key: port

A small hierarchy

A base class for the application's errors and two derived classes for the kinds give a caller three levels of catch: the specific kind, the application, or any standard exception.

The program throws a parse_error and catches it with the specific handler. The other handler is there to show the order: the specific one must come first, or the base swallows everything.

The hierarchy should stay shallow. One base and a handful of kinds is enough for most programs; a class per function call site is a sign that the errors should be values instead, as in lesson 081.

#include <iostream> #include <stdexcept> #include <string> class app_error : public std::runtime_error { public: explicit app_error(const std::string& message) : std::runtime_error(message) {} }; class io_error : public app_error { public: explicit io_error(const std::string& message) : app_error(message) {} }; class parse_error : public app_error { public: explicit parse_error(const std::string& message) : app_error(message) {} }; int main() { try { throw parse_error{"unexpected token"}; } catch (const parse_error& error) { std::cout << "parse: " << error.what() << '\n'; } catch (const app_error& error) { std::cout << "app: " << error.what() << '\n'; } }

g++ -std=c++23 -Wall -Wextra hierarchy.cpp -o hierarchy
./hierarchy

parse: unexpected token

An exception with data

An exception may carry more than a message: a line number, a key, a status code. The what() string is built from the fields, and the fields are readable through accessors after the catch.

The program throws a parse_error with line 3, catches it by const reference and prints both the message and the line. A catch by value would copy the object and lose nothing here, but it would slice a further derived type.

Store only what the handler needs. The exception is copied when it is thrown, so a large payload costs a copy at the throw site.

#include <iostream> #include <stdexcept> #include <string> class parse_error : public std::runtime_error { public: parse_error(const std::string& message, int line) : std::runtime_error(message), line_(line) {} int line() const { return line_; } private: int line_; }; int main() { try { throw parse_error{"unexpected token", 3}; } catch (const parse_error& error) { std::cout << error.what() << " at line " << error.line() << '\n'; } }

g++ -std=c++23 -Wall -Wextra data.cpp -o data
./data

unexpected token at line 3

Translating at a layer

A layer that uses a library should not leak the library's exception types to its callers. It catches them, adds context and throws its own type, which the next layer up understands.

The program's loader catches std::runtime_error from the storage layer and throws config_error with a message that says which file failed. The outer handler sees only the application's type.

The original exception is lost unless the new one stores it. A std::exception_ptr from std::current_exception keeps it for a rethrow later, which is the tool when the cause has to be reported.

#include <iostream> #include <stdexcept> #include <string> class config_error : public std::runtime_error { public: explicit config_error(const std::string& message) : std::runtime_error(message) {} }; std::string load(const std::string& name) { throw std::runtime_error{"file not found: " + name}; } int main() { std::string path = "settings.ini"; try { try { load(path); } catch (const std::runtime_error& cause) { throw config_error{"cannot read " + path + ": " + cause.what()}; } } catch (const config_error& error) { std::cout << error.what() << '\n'; } }

g++ -std=c++23 -Wall -Wextra translate.cpp -o translate
./translate

cannot read settings.ini: file not found: settings.ini

The catch that slices

Catching by value copies the exception into a variable of the handler's type. When the handler names a base class, the copy is a base object and the derived fields are gone.

The program catches a parse_error as std::runtime_error by value and calls line(). The copy has no line(), and the compiler reports that the member does not exist on the base type.

Catching by const reference keeps the original object and its fields. The standard library's own handlers all take const std::exception&, which is the shape to copy.

#include <iostream> #include <stdexcept> class parse_error : public std::runtime_error { public: parse_error(const std::string& message, int line) : std::runtime_error(message), line_(line) {} int line() const { return line_; } private: int line_; }; int main() { try { throw parse_error{"unexpected token", 3}; } catch (std::runtime_error error) { std::cout << error.what() << ' ' << error.line() << '\n'; } }

g++ -std=c++23 -Wall -Wextra slicing.cpp -o slicing

slicing.cpp: In function 'int main()':
slicing.cpp:23:33: warning: catching polymorphic type 'class std::runtime_error' by value [-Wcatch-value=]
23 | } catch (std::runtime_error error) {
| ^~~~~
slicing.cpp:24:51: error: 'class std::runtime_error' has no member named 'line'
24 | std::cout << error.what() << ' ' << error.line() << '\n';
| ^~~~

Exercises

  1. Add a timeout_error to the hierarchy and a handler for it before the app_error one.
  2. Give config_error a key() accessor and include the key in what().
  3. Catch the slice in slicing.cpp by const reference and print the line number.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why derive a custom exception from std::runtime_error?

    Answer

    It gains what() and is caught as a standard exception

    The standard base supplies the message interface and the common catch.

  2. What is the value of a custom exception type?

    Answer

    The caller can catch exactly that failure

    The type is the contract, and the message is the detail.

  3. What should be caught by reference?

    Answer

    Every exception

    A value catch slices the derived part and loses what()'s override.

  4. What does translating an exception at a layer do?

    Answer

    It turns a low-level error into the layer's own type

    The upper layer sees a failure in its own terms, with the original message kept.

  5. What does this program print? #include <iostream> #include <stdexcept> #include <string> class missing_key : public std::runtime_error { public: explicit missing_key(const std::string& key) : std::runtime_error{"missing key: " + key} {} }; int main() { try { throw missing_key{"port"}; } catch (const std::exception& error) { std::cout << error.what(); } }

    Answer

    missing key: port

    The constructor builds the message, and the base catch reads it through what().

  6. What does this program print? #include <iostream> #include <stdexcept> class parse_error : public std::runtime_error { public: explicit parse_error(const std::string& m) : std::runtime_error{m} {} }; class unexpected_token : public parse_error { public: explicit unexpected_token(const std::string& t) : parse_error{"unexpected token " + t} {} }; int main() { try { throw unexpected_token{"}"}; } catch (const parse_error& error) { std::cout << "parse: " << error.what(); } }

    Answer

    parse: unexpected token }

    The derived type is caught by its base class, and the message names the token.

  7. What does this program print? #include <iostream> #include <stdexcept> #include <string> class parse_error : public std::runtime_error { public: parse_error(std::string message, int line) : std::runtime_error{std::move(message)}, line_(line) {} int line() const { return line_; } private: int line_; }; int main() { try { throw parse_error{"unexpected token", 3}; } catch (const parse_error& error) { std::cout << error.what() << " at line " << error.line(); } }

    Answer

    unexpected token at line 3

    The message and the line are separate members, and the catch reads both.

  8. What does this program print? #include <iostream> #include <stdexcept> #include <string> class io_error : public std::runtime_error { public: explicit io_error(const std::string& m) : std::runtime_error{m} {} }; int open_settings() { throw std::runtime_error{"file not found: settings.ini"}; } int main() { try { open_settings(); } catch (const std::exception& error) { std::cout << "cannot read settings.ini: " << error.what(); } }

    Answer

    cannot read settings.ini: file not found: settings.ini

    The layer adds its own prefix and keeps the original message from the deeper layer.

  9. What does the compiler warn and report for this catch? #include <iostream> #include <stdexcept> class parse_error : public std::runtime_error { public: parse_error(std::string m, int line) : std::runtime_error{std::move(m)}, line_(line) {} int line() const { return line_; } private: int line_; }; int main() { try { throw parse_error{"bad", 3}; } catch (std::runtime_error error) { std::cout << error.line(); } }

    Answer

    the catch has no member named 'line' because the derived part was sliced

    Catching by value copies only the runtime_error part, so the derived member and its function are gone.

  10. What should the custom exception's constructor do?

    Answer

    Build the message for the base and store its own fields

    The type carries the data, and the base carries the text.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.

Article author: Arthur Isaev

Banner image