In Monday’s post, “Out-of-Band Error Reporting Without Exceptions”, I described a method for elegantly obtaining three out of five major advantages of C++ exceptions without actually using them. Now I’ll tell you how to go about grabbing a fourth advantage: exception type hierarchies.
C++ exceptions are hierarchical, and their hierarchy is a type hierarchy such that a more-derived exception can be treated, if desired, as one of its base exception types. For example, a FileNotFoundException
might be derived from FileIOException
. Some code may choose to handle FileNotFoundException
s distinctly from other FileIOException
s. Other code might delegate all FileIOException
s to a common handler. In C++ this is done with implicit typecasting in the catch
statements in a try
block.
Wouldn’t it be useful if we could do something similar with the ErrorReport
and CheckedValue
classes that were defined in Monday’s post, instead of just having an error/not-an-error indicator and a human-readable message? Well, we can. It’s easy to use but a little tricky to set up.
Our goal, to be clear, is to essentially turn something like this:
std::string contents; try { contents = readFile("foo"); std::cout << contents << std::endl; } catch (FileNotFoundException ex) { // Handle File-Not-Found } catch (FileIOException ex) { // Handle other File I/O problem }
into something like this:
CheckedValue chkContents = readFile("foo"); if (!chkContents.isError()) { std::cout << *chkContents << std::endl; } else if (chkContents.isError(Errors::FileNotFound)) { // Handle File-Not-Found } else if (chkContents.isError(Errors::FileIO)) { // Handle other File I/O problem }
Such that, with the only difference being a lack of stack-unwindning in the second example, the two constructs should behave identically.
The first thing to rule out is the idea of making the error identifiers an enumerated value. Implementing hierarchical organization on an enumeration would take a jumble of if-else logic and would make it very difficult to extend the pattern by defining new error types within the hierarchy. Instead, we’re going to let the C++ type system take care of this for us.
Now, the straightforward way to do that might seem to be to define the error types as a straight type hierarchy and use dynamic_cast
to determine if one error type is derived from another. But then remember what dynamic_cast
does when the casn’t doesn’t work: it throws a std::bad_cast
exception, and exceptions are exactly what we want to avoid here.
What we are going to do is give each error a unique name (a string), which will normally, although not necessarily, be the same as the name of its associated class. Then, each error type (e.g. Errors::FileNotFound
will be a pointer to an object of an associated class. The class will have an isDerivedFrom
method, which (assuming they it is implemented properly) will allow us to easily, and without using dynamic_cast
determine if one error is logically derived from another.
All of this, of course, must begin from a mother-of-all-errors class, which we will call Error
.
namespace ErrorClasses { class Error { public: bool isDerivedFrom(const Error* err) const { return isDerivedFrom(err->name); } protected: Error() : name("Error") {} virtual bool isDerivedFrom(const std::string& name) const { return (this->name == name); } const std::string& name; }; }
Note first of all that this class is in the ErrorClasses
namespace, rather than the Errors
namespace that I used in the initial example with the try
block. This is deliberate. The Errors
namespace will be reserved for the pointers to these objects that will be given to ErrorReport
and CheckedValue
objects. Next, note that the constructor is protected. This prevents construction of base errors, but you could change this if you like. Finally, note that the public method is non-virtual and calls a virtual protected method, which will be overriden in the same way by each derived class.
Each derived class will have the following form:
namespace ErrorClasses { class FooError : public Error { public: FooError() : name("FooError") {} protected: virtual bool isDerivedFrom(const std::string& name) const { return (this->name == name) || Error::isDerivedFrom(name); } const std::string& name; }; }
You can even set up a macro to help out with this:
#define DEF_ERROR(ErrorName, ParentError) class ErrorName : public ParentError { public: ErrorName() : name(#ErrorName) {} protected: virtual bool isDerivedFrom(const std::string& name) const { return (this->name == name) || ParentError::isDerivedFrom(name); } const std::string& name; }
When defined like this, you can see how the derivation check works. When a user calls isDerivedFrom
on one error, passing another, the non-virtual, public version of the method extracts the string name from the passed error, and calls the virtual, protected version of isDerivedFrom
. The way this is implemented, the most-derived class checks if the name to compare against is equal to its own name, and if not, recursively asks its parent class if it is derived from that name. This proceeds recursively until either we match a class name, or we reach the mother-of-all-errors superclass, which does not perform the recursive step.
Finally, to complete this system, we must simply declare and define a canonical error pointer in the Errors
namespace for each error, like so:
// In a header file namespace ErrorClasses { DEF_ERROR(FileIOError, Error); DEF_ERROR(FileNotFound, FileIOError); DEF_ERROR(FilePermissionDenied, FileIOError); } namespace Errors { using ErrorClasses::Error; const Error* FileIOError; const Error* FileNotFound; const Error* FilePermissionDenied; } // In a source file namespace Errors { extern const Error* FileIOError = new ErrorClasses::FileIOError(); extern const Error* FileNotFound = new ErrorClasses::FileNotFound(); extern const Error* FilePermissionDenied = new ErrorClasses::FilePermissionDenied(); }
Now we have a complete hierarchy of errors that we can extend and query without using exceptions or complicated branching logic. These can now be used in ErrorReport
objects (and by extension in CheckedValue
objects), by changing the ErrorReport
constructor that takes the description string to also take a const Error*
describing the error type, and by overloading the ErrorReport::isError
method to optionally accept a const Error*
, with the semantics being that this version of isError
returns true if and only if the contained error is equal to, or derived from, the supplied error.
One important note is that in order to completely mimic the behavior of the try
block, ErrorReport::isError
should not set its checked flag to true unless it actually returns true. We still want an ErrorReport
to “do something horrible” if we checked some series of error types that it wasn’t but never actually found out what it was.
For demonstration purposes, let’s take a look at how we might write the final example from Monday using this new feature:
CheckedValue<int> computeSomething(Foo* p_foo) { if (p_foo == NULL) { return ErrorReport(Errors::NullPointer, "p_foo cannot be NULL"); } int computed_value; // Compute Something return computed_value; } int main() { Foo* p_foo = new Foo(); CheckedValue<int> chkVal = computeValue(p_foo); if (chkVal.isError(Errors::NullPointer)) { // Handle Null Pointer error } else if (chkVal.isError()) { // Handle some other kind of error } else { std::cout << *chkVal << std::endl; } delete p_foo; return 0; }
Simple to use, and provides much greater flexibility.
Incidentally, this also provides a neat solution for the question of what to do if someone tries to construct a CheckedValue
with an ErrorReport
object that contains no error – simply create some sort of UninitializedCheckedValue
error, and have the CheckedValue
constructor set itself to containing that error if indeed it is passed a no-error error report.
Share this content on: