12
\$\begingroup\$

I've been thinking about error handling in C++ lately. Exceptions are a great way of handling errors in most applications. However, I work in the games industry where it is commonly accepted that exceptions should be avoided, so I'm looking for a clean and simple alternative. (The validity of avoiding exceptions in games is definitely up for debate, but let's assume they shouldn't be used to remain on topic).

Errors can be considered to fall into 2 categories: expected and unexpected. An expected error is an error that is acceptable during the running of an application: no internet connection, file doesn't exist, etc. Unexpected errors are those which should never happen and are usually bugs: NaN values, null pointer issues, etc. The difference between the two is nicely explained in this blog.

In an app which doesn't make use of exception, it is typical for the app to abort when an unexpected error occurs - either through use of assert() or some abort method/macro that allows an error message to be supplied: FATAL_ERROR("Null pointer!"). This seems perfectly acceptable, so I'm not concerned with this case.

I can see problems with the way expected errors are often handled however. There are two issues. First of all, it is common to see methods like this:

bool readFile(std::string& out_contents); //returns false if the file couldn't be read.

This is quite messy. Out-parameters make for a more untidy API (Subjective, but in my opinion this is the case). A common solution is something like boost::Optional or a Maybe monad.

Optional<std::string> readFile();

This seems decent, but leads us to my second issue: we've lost all context of the error. If the optional value is null, we have no idea why. A possible solution would be to return a struct, containing the error code and the optional value:

enum class Error 
{ 
    k_success, 
    k_fileDoesNotExist, 
    k_badPermissions 
};
struct Result 
{ 
    Error m_error;
    Optional<std::string> m_contents;
};
Result readFile();

This is pretty good. Calling it is cleanish and no error context is lost. However, it's very verbose. I can't imagine many people bothering to write all of that for every method, so I've tried to come up with a more convenient, generic version:

#ifndef _IC_IRESULT_H_
#define _IC_IRESULT_H_

#include <assert.h>
#include <memory>
#include <string>

namespace IC
{
    // Base class for type erasure.
    class IResult
    {
    public:
        explicit operator bool() const noexcept { return wasSuccessful(); };
        virtual bool wasSuccessful() const noexcept = 0;
        virtual const std::string& getErrorMessage() const noexcept = 0;
        virtual std::string getFullErrorMessage() const noexcept = 0;
        virtual const IResult* getCausedBy() const noexcept = 0;
        virtual std::unique_ptr<const IResult> cloneError() const noexcept = 0;
        virtual ~IResult() noexcept {}
    };

    //templated Result class. Typedefs for Result<T, bool>, Result<void, T> and Result<void, bool> are available
    template <typename TValue, typename TError, TError TErrorSuccess = TError()> class Result final : public IResult
    {
    public:
        Result(const TValue& in_value) noexcept
            : m_value(in_value), m_error(TErrorSuccess)
        {
        }

        Result(TError in_error, const std::string& in_errorMessage) noexcept
            : m_error(in_error), m_errorMessage(in_errorMessage)
        {
            assert(!wasSuccessful());
        }

        Result(TError in_error, const std::string& in_errorMessage, const IResult& in_causedBy) noexcept
            : m_error(in_error), m_errorMessage(in_errorMessage), m_causedBy(in_causedBy.cloneError())
        {
            assert(!wasSuccessful());
            assert(!m_causedBy->wasSuccessful());
        }

        Result(const Result<TValue, TError, TErrorSuccess>& in_toCopy) noexcept
            : m_value(in_toCopy.m_value), m_error(in_toCopy.m_error), m_errorMessage(in_toCopy.m_errorMessage), m_causedBy(in_toCopy.m_causedBy->cloneError())
        {
        }

        Result(Result<TValue, TError, TErrorSuccess>&& in_toMove) noexcept
            : m_value(in_toMove.m_value), m_error(in_toMove.m_error), m_errorMessage(in_toMove.m_errorMessage), m_causedBy(std::move(in_toMove.m_causedBy))
        {
            in_toMove.m_errorMessage = "";
        }


        Result<TValue, TError, TErrorSuccess>& operator=(const Result<TValue, TError, TErrorSuccess>& in_toCopy) noexcept
        {
            m_value = in_toCopy.m_value;
            m_error = in_toCopy.m_error;
            m_errorMessage = in_toCopy.m_errorMessage;
            m_causedBy = in_toCopy.m_causedBy->clone();

            return *this;
        }

        Result<TValue, TError, TErrorSuccess>& operator=(Result<TValue, TError, TErrorSuccess>&& in_toMove) noexcept
        {
            m_value = in_toMove.m_value;
            m_error = in_toMove.m_error;
            m_errorMessage = in_toMove.m_errorMessage;
            m_causedBy = std::move(in_toMove.m_causedBy);

            in_toMove.m_errorMessage = "";

            return *this;
        }

        bool wasSuccessful() const noexcept override
        {
            return m_error == TErrorSuccess;
        }

        const TValue& getValue() const noexcept
        {
            assert(wasSuccessful());

            return m_value;
        }

        TError getError() const noexcept
        {
            return m_error;
        }

        const std::string& getErrorMessage() const noexcept override
        {
            assert(!wasSuccessful());

            return m_errorMessage;
        }

        std::string getFullErrorMessage() const noexcept override
        {
            assert(!wasSuccessful());

            std::string errorMessage = m_errorMessage;
            if (m_causedBy)
            {
                errorMessage += "\nCaused by:\n" + m_causedBy->getFullErrorMessage();
            }
            return errorMessage;
        }

        const IResult* getCausedBy() const noexcept override
        {
            assert(!wasSuccessful());

            return m_causedBy.get();
        }

        std::unique_ptr<const IResult> cloneError() const noexcept override
        {
            assert(!wasSuccessful());

            if (m_causedBy)
            {
                return std::unique_ptr<const IResult>(new Result<void, TError, TErrorSuccess>(m_error, m_errorMessage, *m_causedBy));
            }
            else
            {
                return std::unique_ptr<const IResult>(new Result<void, TError, TErrorSuccess>(m_error, m_errorMessage));
            }
        }

    private:
        TValue m_value;
        TError m_error;
        std::string m_errorMessage;
        std::unique_ptr<const IResult> m_causedBy;
    };

    // partial specialisation for void values
    template <typename TError, TError TErrorSuccess> class Result<void, TError, TErrorSuccess> final : public IResult
    {
    public:
        Result() noexcept
            : m_error(TErrorSuccess)
        {
        }

        Result(TError in_error, const std::string& in_errorMessage) noexcept
            : m_error(in_error), m_errorMessage(in_errorMessage)
        {
            assert(!wasSuccessful());
        }

        Result(TError in_error, const std::string& in_errorMessage, const IResult& in_causedBy) noexcept
            : m_errorMessage(in_errorMessage), m_causedBy(in_causedBy.cloneError())
        {
            assert(!wasSuccessful());
            assert(!m_causedBy);
        }

        Result(const Result<void, TError, TErrorSuccess>& in_toCopy) noexcept
            : m_error(in_toCopy.m_error), m_errorMessage(in_toCopy.m_errorMessage), m_causedBy(in_toCopy.m_causedBy->cloneError())
        {
        }

        Result(Result<void, TError, TErrorSuccess>&& in_toMove) noexcept
            : m_error(in_toMove.m_error), m_errorMessage(in_toMove.m_errorMessage), m_causedBy(std::move(in_toMove.m_causedBy))
        {
            in_toMove.m_errorMessage = "";
        }

        Result<void, TError, TErrorSuccess>& operator=(const Result<void, TError, TErrorSuccess>& in_toCopy) noexcept
        {
            m_error = in_toCopy.m_error;
            m_errorMessage = in_toCopy.m_errorMessage;
            m_causedBy = in_toCopy.m_causedBy->clone();

            return *this;
        }

        Result<void, TError, TErrorSuccess>& operator=(Result<void, TError, TErrorSuccess>&& in_toMove) noexcept
        {
            m_error = in_toMove.m_error;
            m_errorMessage = in_toMove.m_errorMessage;
            m_causedBy = std::move(in_toMove.m_causedBy);

            in_toMove.m_errorMessage = "";

            return *this;
        }

        bool wasSuccessful() const noexcept override
        {
            return m_error == TErrorSuccess;
        }

        void getValue() const noexcept
        {
            assert(false)
        }

        TError getError() const noexcept
        {
            return m_error;
        }

        const std::string& getErrorMessage() const noexcept override
        {
            assert(!wasSuccessful());

            return m_errorMessage;
        }

        std::string getFullErrorMessage() const noexcept override
        {
            std::string errorMessage = m_errorMessage;
            if (m_causedBy)
            {
                errorMessage += "\nCaused by:\n" + m_causedBy->getFullErrorMessage();
            }
            return errorMessage;
        }

        const IResult* getCausedBy() const noexcept override
        {
            assert(!wasSuccessful());

            return m_causedBy.get();
        }

        std::unique_ptr<const IResult> cloneError() const noexcept override
        {
            assert(!wasSuccessful());

            if (m_causedBy)
            {
                return std::unique_ptr<const IResult>(new Result<void, TError, TErrorSuccess>(m_error, m_errorMessage, *m_causedBy));
            }
            else
            {
                return std::unique_ptr<const IResult>(new Result<void, TError, TErrorSuccess>(m_error, m_errorMessage));
            }
        }

    private:
        TError m_error;
        std::string m_errorMessage;
        std::unique_ptr<const IResult> m_causedBy;
    };

    //typedefs for common cases
    template <typename TValue> using BoolResult = Result<TValue, bool, true>;
    template <typename TError, TError TErrorSuccess = TError()> using Error = Result<void, TError, TErrorSuccess>;
    using BoolError = Result<void, bool, true>;
}

#endif

(It might be easier to read this on GitHub.)

This allows the same as above, but in a tider format. It also has two additions. First of all error messages are nestible. If one error causes another then the latter can contain the former (similar to using the java exception cause-constructor). An example of this could be a texture loader. If it internally uses a loadFile() method which fails with an error, the texture loader can return a texture loading specific error, containing the original error. The user is then able to get information on both errors. Secondly an error string can be supplied. This respects the notion of nestible errors and allows the full error "stack trace" to be printed. Again this is similar to printing an exception stack trace in Java.

The following is an example use case:

#include <iostream>
#include <time.h>
#include "ICResult/Result.h"

enum class OperationResult
{
    k_success,
    k_failedOperation,
    k_operationNotStarted
};

IC::BoolResult<float> tryCalcValue()
{
    if (rand() % 2 == 0)
    {
        return IC::BoolResult<float>(5.5f);
    }

    return IC::BoolResult<float>(false, "Could not calculate value.");
}

IC::Result<float, OperationResult> tryPerformOperation()
{
    if (rand() % 2 == 0)
    {
        auto result = tryCalcValue();
        if (result)
        {
            return IC::Result<float, OperationResult>(result.getValue());
        }

        return IC::Result<float, OperationResult>(OperationResult::k_failedOperation, "The operation could not be performed.", result);
    }

    return IC::Result<float, OperationResult>(OperationResult::k_operationNotStarted, "Failed to start operation.");
}

int main()
{
    auto result = tryPerformOperation();
    if (result)
    {
        std::cout << "The result of the operation is: " << result.getValue();
    }
    else
    {
        std::cout << result.getFullErrorMessage();
    }
}

As far as I can see it, these are the pros and cons:

Pros

  • Pretty clean and handles most cases, while retaining error context and avoiding out-parameters.
  • There shouldn't be much overhead when no error occurs as there is no heap allocation (assuming the short string optimisation occurs on the empty string, at least)

Cons

  • Messes with RVO. The temporary will need to be copied when passed to the result constructor.
  • Doesn't handle the case where the contained value not default constructible.
  • Is very similar in concept to both boost Optional and Maybe Monad implementations. It doesn't allow for binding of methods like a maybe monad.

While reviewing the code it would be useful to consider how useful the class actually is in a real world environment. Would it actually be used over the alternatives?

My biggest concern is the final con. It could allow for binding of methods like a Maybe monad. However, while cool, the syntax for this is really convoluted in C++. I can't see someone using the following in a real world project:

auto result = tryCalcValue();
int output = result.bind([](int& in_value){return tryAnotherOperation(in_value);}).getOrDefault(0);

Rather than:

int output = 0;
auto result = tryCalcValue();
if (result)
{
    result = tryAnotherOperation(result.getValue());
    if (result)
    {
        output = result.getValue();
    }
}

The former is obviously much more concise, but it's also much more difficult to read and debug.

\$\endgroup\$
  • \$\begingroup\$ I like this question, lots of context. Welcome to CodeReview! :) \$\endgroup\$ – Caridorc Aug 11 '15 at 21:34
  • 1
    \$\begingroup\$ Alternate or alternative? Is that a typo? \$\endgroup\$ – Caridorc Aug 11 '15 at 21:43
  • \$\begingroup\$ Also I disagree with your article on error handling. There are two types of errors. Errors that can be fixed locally (use error codes (as long as the error code does not cross a public API)). Errors that can not be fixed locally and need a higher context to understand what needs to be done (use exceptions. If somebody knows how to handle it they catch fix and retry or the applications exits). \$\endgroup\$ – Martin York Aug 11 '15 at 22:14
  • 1
    \$\begingroup\$ The can not be fixed locally can be broken into three groups. Runtime: std::runtime_error: an error can happen and sometime will. Logical std::logical_errors: The code is being used in an incorrect mannor that has been detected. Domain std::domain_errors: these are errors that should never happen but an ultra conservative programmer has put in the checks just in case they do happen (because an assumption has been broken by another developer). \$\endgroup\$ – Martin York Aug 11 '15 at 22:14
  • \$\begingroup\$ @Caridorc Thank you :) I was under the impression either could work in this context, but I could be wrong, so I'll change to "alternative". \$\endgroup\$ – AzCopey Aug 12 '15 at 8:51
3
\$\begingroup\$

I'm not familiar enough with C++ to know their standards, or what the elders consider to be the best way of dealing with errors are (and C++ is definitely a language of elders), but I can give you some insight on what another equally old object-oriented language does to handle errors (and it's not just throwing exceptions).

Enter Objective-C.

In Objective-C, errors and exceptions are considered entirely different.

  • Exceptions are thrown and can be caught, just as you'd expect.
  • Errors are passed by reference into a method that expects to have an error and can be checked afterward to determine the nature of the problem.

And there's a pretty simple principle guiding when you should use an exception and when you should use an error.

  • Exceptions are used for programmer mistakes that are easily prevented by writing safer code (index out of bounds, calling a method on an object that doesn't implement it, etc).
  • Errors are for run-time problems that can't be entirely prevented (network problems, file I/O, etc).

So, keeping in mind that I'm no C++ expert, so pardon any syntax, a method to read an entire file at a particular path and return a string containing all its contents might look something like this:

string stringWithContentsOfFile(string filePath, Error *error);

Objective-C methods taking this approach will always be documented clearly to indicate that the return value indicates success or failure (if NULL then failure, otherwise success), and that the contents of error are only worth checking on success (because of the way many Objective-C libraries work, the error might have some sort of placeholder and be non-NULL even in the case of success).

Now, the advantage of using this approach over exceptions is that if we want, much like with error codes, we can simply ignore the error. Or perhaps, more importantly, we might simply not care about the detail of the error. With exceptions, you can ignore the detail of the exception, but you still have to catch the exception. With this approach, you just pass NULL for the second argument. Whether or not the method returned NULL is your indication of whether or not it worked.

The advantage of using this approach over error codes is that it's far more verbose. Error codes, as a start, require knowing to find the library's definition of all their error code constants, so you can at least get the code's name. Then, if you wanted to do something as output a simple description of each error, you have to write a function to convert the code into a human-readable string. And to even get that human-readable string, you either have to make up your own based on the error code's name, or hope the library has great documentation. And even then, it's a static description that is applied to all cases of that code.

The error object simply contains these sort of details.

The Objective-C version of the error object is called NSError.

It has three important properties.

  1. An NSInteger (it's just a type def for 32-bit/64-bit platform dependent integer) called code. This is your "error code".
  2. A NSString (just the string class) called domain. This is used to help prevent overlap of the error codes.
  3. An NSDictionary (C++ equivalent is map) that contains more detailed information about the error.

That dictionary can contain anything it wants, but it usually will contain values for a few predefined keys:

  • localizedDescription
  • localizedRecoveryOptions
  • localizedRecoverySuggestion
  • localizedFailureReason

  • recoveryAttempter

  • helpAnchor

(For more information on these, I recommend looking at the Apple docs regarding NSError.)

\$\endgroup\$

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.