5
\$\begingroup\$

I'm designing an modular exception class hierarchy to use in various projects. I intend to inherit from std::exception in order to be maximally compatible with any exception-handling code. A design goal is that each exception's what() method returns a string which contains a base message, which is dependent on the object's most specific class (i.e. equal for all objects of the class), and an optional instance-specific details message which specifies the origin of the exception.

The two main goals are ease of use (as in when throwing the exceptions), as well as ensuring that writing another exception subclass is as simple and repetition-free as possible.


Base class

The base exception class I wrote is the following. It is conceptually an abstract class, but not syntactically, since I don't have any virtual method to make pure virtual. So, as an alternative, I made all constructors protected.

/**
 * Base class for all custom exceptions. Stores a message as a string.
 * Instances can only be constructed from within a child class,
 * since the constructors are protected.
 */
class BaseException : public std::exception {
protected:
    std::string message;  ///< message to be returned by what()

    BaseException() = default;

    /**
     * Construct a new exception from a base message and optional additional details.
     * The base message is intended to be class-specific, while the additional
     * details string is intended to be instance-specific.
     *
     * @param baseMessage  generic message for the kind of exception being created
     * @param details  additional information as to why the exception was thrown
     */
    BaseException(const std::string &baseMessage, const std::string &details = "") {
        std::ostringstream oss(baseMessage, std::ios::ate);
        if (not details.empty()) oss << " (" << details << ')';
        message = oss.str();
    }

public:
    /// `std::exception`-compliant message getter
    const char *what() const noexcept override {
        return message.c_str();
    }
};

The intention of the above design is that any subclass of BaseException defines a constructor that passes a class-specific base message (as the baseMessage paramter) and an optional detail-specifier (as the details parameter) as arguments to BaseException's constructor.

Errors & warnings

Since I want to be able to differentiate between general exception "types", e.g. errors vs. warnings, I've made the following two virtually-inherited bases:

class Error: public virtual BaseException {};

class Warning : public virtual BaseException {};




Examples

Here are some (project-specific) examples of implementing concrete exception subclasses with this design:

/// Indicates that a command whose keyword is unknown was issued
class UnknownCommand : public Error {
public:
    static constexpr auto base = "unrecognized command";
    UnknownCommand(const std::string &specific = "") : BaseException(base, specific) {}
};


/// Indicates an error in reading or writing to a file
class IOError : public Error {
public:
    static constexpr auto base = "unable to access file";
    IOError(const std::string &specific = "") : BaseException(base, specific) {}
};


/// Indicates that an algorithm encountered a situation in which it is not well-defined;
/// i.e., a value that doesn't meet a function's prerequisites was passed.
class ValueError : public Error {
public:
    static constexpr auto base = "invalid value";
    ValueError(const std::string &specific = "") : BaseException(base, specific) {}
};

# [...]

As you can see, the common pattern is

class SomeException : public Error /* or Warning */ {
public:
    static constexpr auto base = "some exception's generic description";
    SomeException(const std::string &details) : BaseException(base, details) {}
}

Usage example

Taking the previous IOError class as an example:

#include <iostream>
#include <fstream>
#include "exceptions.h"  // all of the previous stuff

void cat(const std::string &filename) {
    std::ifstream file(filename);
    if (not file.is_open()) throw IOError(filename);
    std::cout << file.rdbuf();
}

int main(int argc, char **argv) {
    for (int i = 1; i < argc; ++i) {
        try { cat(argv[i]); }
        catch (std::exception &e) { std::cerr << e.what() << '\n'; }
    }
}

In case of calling the program with an inaccessible file path, e.g. the path "foo", it should output

unable to access file (foo)
\$\endgroup\$
2
\$\begingroup\$
  1. Using a std::ostringstream to concatenate strings is like using a DeathStar to kill a sparrow.

  2. Don't use std::string for the stored message. Copying it is not guaranteed to never throw, and you only need a very small sliver of its capabilities anyway.
    A std::shared_ptr<const char[]> fits the bill much better, though even that is overkill.

  3. Avoid using std::string at all, so you don't risk short-lived but costly dynamic allocations. Prefer std::string_view for the interface.

  4. BaseException seems to be purely an implementation-help, adding storing of an arbitrary exception-message on top of std::exception. That's fine, only a pitty it wasn't already in the base.

  5. Still, marking the additions as protected doesn't make any sense, message should really be private, and why shouldn't the ctors be public?
    If the aim of that exercise is forbidding objects of most derived class BaseException, just make the ctor pure virtual:

    // Declaration in the class:
    virtual ~BaseException() = 0;
    // Definition in the header-file:
    inline BaseException::~BaseException() = default;
    

Applying that:

template <class... Ts>
auto shared_message(Ts... ts)
-> std::enable_if_t<(std::is_same_v<Ts, std::string_view> ... &&),
        std::shared_ptr<const char[]>> {
    auto r = std::make_shared_default_init<char[]>(1 + ... + ts.size());
    auto p = &r[0];
    ((std::copy_n(&ts[0], ts.size(), p), p += ts.size()), ...);
    *p = 0;
    return r;
}

template <class... Ts>
auto shared_message(Ts&&... ts)
-> std::enable_if_t<!(std::is_same_v<std::decay_t<Ts>, std::string_view> ... &&),
        decltype(shared_message(std::string_view{ts}...))>
{ return shared_message(std::string_view{ts}...); }

class BaseException : std::exception {
    decltype(shared_message()) message;
public:
    const char* what() const noexcept final override
    { return &message[0]; }
    virtual ~BaseException() = 0;
    template <class... Ts, class = decltype(shared_message(std::declval<Ts&>()...))>
    BaseException(Ts&&... ts)
    : message(shared_message(ts...))
    {}
};

inline BaseException::~BaseException() = default;
\$\endgroup\$
  • \$\begingroup\$ Thanks for your feedback! Conceptually speaking, BaseException is an abstract class -- although not syntactically, since there is no method to make pure virtual; so, as an alternative, I made the constructors protected. Regarding the use of shared_pointer, a copy must still be made, to avoid dangling pointer issues, right? In this case, would the "manual" copy potentially throw? And would switching to shared_pointer or a more "basic" string type be worth it if the thrower actually passes a std::string as the "details" (so the std::string would already have been constructed)? \$\endgroup\$ – Anakhand May 5 at 9:39
  • 1
    \$\begingroup\$ If BaseException is conceptually abstract, simply make it mechanically abstract too, look at the edit. Copying a shared_ptr cannot throw, as it just involves incrementing a reference-count. And dealing with std::strings at all won't save an allocation: std::make_shared generally allocates the shared object, and the reference-counts, in one piece. Of Course it doesn't matter whether the used string-type is "basic", as long as it does copy-on-write, which is forbidden for std::string. \$\endgroup\$ – Deduplicator May 5 at 10:55
  • \$\begingroup\$ I meant copying the characters, as in std::copy_n(&ts[0], ts.size(), p). Regarding the std::string: suppose there are throw clauses that construct a std::string as the details message: throw IOError(filename + "!") (filename is a std::string, as in the example above). If such uses were frequent, wouldn't taking the string as a const std::string & be better than having to force the thrower to give a char * (throw IOError(filename.c_str())) and then copy all of the characters in a newly allocated char[] (through make_shared)? Maybe I missed something about what you said. \$\endgroup\$ – Anakhand May 5 at 19:09
  • 1
    \$\begingroup\$ @Anakhand Prefer std::string_view over std::string const& as a function-argument, wherever you don't need the 0-terminator. You can cheaply construct the view even in many cases where you would have to allocate for a temporary std::string. And the only part in my replacement that can throw is the call to std::make_shared, because some dynamic memory is needed. \$\endgroup\$ – Deduplicator May 5 at 19:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.