The problem:
Since long time, I am worried about the exceptions
mechanism, because I feel it does not really resolve what it should.
CLAIM: There are long debates outside about this topic, and most of them struggle at comparing exceptions
vs returning an error code. This is definitively not the topic here.
Trying to define an error, I would agree with CppCoreGuidelines, from Bjarne Stroustrup & Herb Sutter
An error means that the function cannot achieve its advertised purpose
CLAIM: The exception
mechanism is a language semantic for handling errors.
CLAIM: To me, there is "no excuse" to a function for not achieving a task: Either we wrongly defined pre/post conditions so the function cannot ensure results, or some specific exceptional case is not considered important enough for spending time in developing a solution. Considering that, IMO, the difference between normal code and error code handling is (before implementation) a very subjective line.
CLAIM: Using exceptions to indicate when a pre or post condition is not keep is another purpose of the exception
mechanism, mainly for debugging purpose. I do not target this usage of exceptions
here.
In many books, tutorials and other sources, they tend to show error handling as a quite objective science, that is solved with exceptions
and you just need to catch
them for having a robust software, able to recover from any situation. But my several years as a developer make me to see the problem from a different approach:
- Programmers tends to simplify their task by throwing exceptions when the specific case seem too rare to be implemented carefully. Typical cases of this are: out of memory issues, disk full issues, corrupted file issues, etc. This might be sufficient, but is not always decided from an architectural level.
- Programmers tends not reading carefully documentation about exceptions in libraries, and are usually not aware of which and when a function throws. Furthermore, even when they know, they don't really manage them.
- Programmers tends not catching exceptions early enough, and when they do, it is mostly to log and throw further. (refer to first point).
This has two consequences:
- Errors happening frequently are detected early in development and debugged (which is good).
- Rare exceptions are not managed and make the system to crash (with a nice log message) at the user home. Some times the error is reported, or not even.
Considering that, IMO the main purpose of an error mechanism should be:
- Make visible in code where some specific case is not managed.
- Communicate the issue runtime to related code (at least the caller) when this situation happens.
- Provides recovery mechanisms
The main flaw of the exception
semantic as an error handling mechanism is IMO: it is easy to see where a throw
is in the source code, but absolutely not evident to know if a specific function could throw by looking at the declaration. This bring all the problem that I introduced above.
The language do not enforce and check the error code as strictly as it make for other aspects of the language (e.g. strong types of variables)
A try for solution
In the intention of improving this, I developed a very simple error handling system, which tries to put the error handling at the same level of importance than the normal code.
The idea is:
- Each (relevant) function receive a reference to a
success
very light object, and may set it to an error status in case. The object is very light until a error with text is saved. - A function is encouraged to skip its task if the object provided contain already an error.
- An error must never be override.
The full design obviously consider thoroughly each aspect (about 10 pages), also how to apply it to OOP.
Example of the Success
class:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Usage:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
I used that in many of my (own) code and it force the programmer (me) to think further about possible exceptional cases and how to solve them (good). However, it has a learning curve and don't integrate well with code that do now use it.
The question
I would like to understand better the implications of using such a paradigm in a project:
- Is the premise to the problem correct? or Did I missed something relevant?
- Is the solution a good architectural idea? or the price is too high?
EDIT:
Comparison between methods:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
Best Answer
Error-handling is perhaps the hardest portion of a program.
In general, realizing that there is an error condition is easy; however signalling it in a way that cannot be circumvented and handling it appropriately (see Abrahams' Exception Safety levels) is really hard.
In C, signalling errors is done by a return code, which is isomorphic to your solution.
C++ introduced exceptions because of the short-coming of such an approach; namely, it only works if callers remember to check whether an error occurred or not and fails apart horribly otherwise. Whenever you find yourself saying "It's OK as long as every time..." you have a problem; humans are not that meticulous, even when they care.
The problem, however, is that exceptions have their own issues. Namely, invisible/hidden control flow. This was intended: hiding the error case so that the logic of the code is not obfuscated by the error handling boilerplate. It makes the "happy path" much clearer (and fast!), at the cost of making the error paths nigh inscrutable.
I find it interesting to look at how other languages approach the issue:
C++ used to have some form of checked exceptions, you may have noticed it has been deprecated and simplified toward a basic
noexcept(<bool>)
instead: either a function is declared to possibly throw, or it's declared never to. Checked exceptions are somewhat problematic in that they lack extensibility, which can cause awkward mappings/nesting. And convoluted exception hierarchies (one of the prime use cases of virtual inheritance is exceptions...).In contrast, Go and Rust take the approach that:
The latter is rather evident in that (1) they name their exceptions panics and (2) there is no type hierarchy/complicated clause here. The language does not offer facilities to inspect the content of a "panic": no type hierarchy, no user-defined content, just a "oops, things went so wrong there's no possible recovery".
This effectively encourages users to use proper error handling, whilst still leaving an easy way to bail out in exceptional situations (such as: "wait, I haven't implement that yet!").
Of course, the Go approach unfortunately is much like yours in that you can easily forget to check the error...
... the Rust approach however is mostly centered around two types:
Option
, which is similar tostd::optional
,Result
, which is a two possibilities variant: Ok and Err.this is much neater because there is no opportunity for accidentally using a result without having checked for success: if you do, the program panics.
FP languages form their error handling in constructs which can be split in three layers: - Functor - Applicative / Alternative - Monads / Alternative
Let's have a look at Haskell's
Functor
typeclass:First of all, typeclasses are somewhat similar but not equal to interfaces. Haskell's function signatures look a bit scary on a first look. But let's decipher them. The function
fmap
takes a function as first parameter which is somewhat similar tostd::function<a,b>
. The next thing is anm a
. You can imaginem
as something likestd::vector
andm a
as something likestd::vector<a>
. But the difference is, thatm a
doesn't say it has to be explicitlystd:vector
. So it could be astd::option
, too. By telling the language that we have an instance for the typeclassFunctor
for a specific type likestd::vector
orstd::option
, we can use the functionfmap
for that type. The same must be done for the typeclassesApplicative
,Alternative
andMonad
which allows you to do stateful, possible failing computations. TheAlternative
typeclass implements error recovery abstractions. By that you can say something likea <|> b
meaning it's either terma
or termb
. If neither of both computations succeed, it's still an error.Let's have a look at Haskell's
Maybe
type.This means, that where you expect a
Maybe a
, you get eitherNothing
orJust a
. When looking atfmap
from above, an implementation could look likeThe
case ... of
expression is called pattern matching and resembles what is known in the OOP world asvisitor pattern
. Imagine the linecase m of
asm.apply(...)
and the dots is the instantiation of a class implementing the dispatch functions. The lines below thecase ... of
expression are the respective dispatch functions bringing the fields of the class directly in scope by name. In theNothing
branch we createNothing
and in theJust a
branch we name our only valuea
and create anotherJust ...
with the transformation functionf
applied toa
. Read it as:new Just(f(a))
.This can now handle erroneous computations while abstracting the actual error checks away. There exist implementations for the other interfaces which makes this kind of computations very powerful. Actually,
Maybe
is the inspiration for Rust'sOption
-Type.I would there encourage you to rework your
Success
class toward aResult
instead. Alexandrescu actually proposed something really close, calledexpected<T>
, for which standard proposals were made.I will stick to the Rust naming and API simply because... it's documented and works. Of course, Rust has a nifty
?
suffix operator which would make the code much sweeter; in C++, we'll use theTRY
macro and GCC's statements expression to emulate it.Note: this
Result
is a placeholder. A proper implementation would use encapsulation and aunion
. It's sufficient to get the point across however.Which allows me to write (see it in action):
which I find really neat:
Success
class), forgetting to check for errors will result in a runtime error1 rather than some random behavior,concepts
in the standard. This would make this kind of programming far more pleasuring as we could leave the choice over the error kind. E.g. with an implementation ofstd::vector
as result, we could compute all possible solutions at once. Or we could choose to improve error handling, as you proposed.1 With a properly encapsulated
Result
implementation ;)Note: unlike exception, this lightweight
Result
does not have backtraces, which makes logging less efficient; you may find it useful to at least log the file/line number at which the error message is generated, and to generally write a rich error message. This can be compounded by capturing the file/line each time theTRY
macro is used, essentially creating the backtrace manually, or using platform-specific code and libraries such aslibbacktrace
to list the symbols in the callstack.There is one big caveat though: existing C++ libraries, and even
std
, are based on exceptions. It'll be an uphill battle to use this style, as any 3rd party library's API must be wrapped in an adapter...