⚠️

Error Handling

CategoryC++

Overview

There are several methods to handle errors in a program.

Assertions

An assertion indicates a programming error.

It means that the error should only occur during the development of the application and never occur in final builds.

Assertions are conditional statements that are ignored when they return true, but raise an error when they return false.

In C++, there are two types of asserts: static asserts and run-time asserts.

Static Assertions

🗓
Static assertions were introduced in C++11.

Static assertions are checked by the compiler, and if they fail, the compilation is stopped.

Static assertions should always be preferred to run-time assertions whenever possible.

Usage

struct VertexConstants
{
    Matrix WorldViewProjectionMatrix;
    Matrix WorldViewMatrix;
    Matrix ViewMatrix;
    Matrix ProjectionMatrix;

    Vector WorldLightDirection;
    Vector ModelLightDirection;
    Vector AmbientColor;
    Vector DiffuseColor;
};

static_assert((sizeof(VertexConstants) % 16) == 0, "Context buffer must be 16 byte aligned");
template <typename T, int Size>
class Vector
{ 
    static_assert(Size > 0, "Vector size must be positive");
    static_assert(Size <= 4, "Vector size must be in the [1 4] range");
  
    T _values[Size]; 
}; 

Vector<float, 3> vector1; // OK
Vector<float, -3> vector2; // Fail
Vector<double, 6> vector3; // Fail

Run-time Assertions

Run-time assertions are used when static asserts are not applicable.

An assertion that returns true is ignored, but an assertion that returns false causes a debug break. A debug break outputs an error, and invoke a debugger when it is possible.

Assertions are programming assumptions.

They should only be enabled in development builds.

The assert function is defined in the cassert header file.

It is actually a macro that is stripped out of non-debug builds.

For example, in this example a static assert cannot be used because the index parameter is dynamic.

T GetAt(int32_t index)
{
    assert(index >= 0 && "Index must be positive");
    assert(index < Size && "Index must be in the [1 4] range");

    return _values[index];
}

Try to use more specific types to catch programming assumptions at compile time when possible.

In this example, a more specific type for the index parameter (uint32_t) allows the first assert to be removed from the function.

T GetAt(uint32_t index)
{
    assert(index < Size && "Index must be in the [1 4] range");

    return _values[index];
}

The assert function from the standard library does not allow a message to be associated with the assertion (which explains the && operator between the condition and the message in the previous example).

You can define a custom macro function that:

#if defined(DEVELOPMENT)
#define MyAssert(expression, message) \
    if (expression) {}
    else \
    { \
				ReportAssert(#expression, __FILE__, __LINE__); \
				__debugbreak(); \
    }
#else
#define MyAssert(expression, message)
#endif

Error Codes

To handle application errors, we can use error codes.

A common approach is to return an error code in functions than can fail and always check for this error code when calling the functions.

For example, the COM library use this approach. The HRESULT type is a common error code returned by many functions.

You can define a global list of possible errors for the application.

enum class Errors
{
    OK,
    OutOfMemory,
    IndexOutOfRange,
    InvalidOperation,
    ...
};

Or you can define separate error types for distinct domains.

enum class SystemErrors
{
    OK,
    OutOfMemory,
    IndexOutOfRange,
    InvalidOperation,
    ...
};
enum class IOErrors
{
    OK,
    FileNotFound,
    EndOfStream,
    ...
};

Functions that are expected to return a result, return it through an out parameter.

Errors GetAt(uint32_t index, T* item)
{
    if (index >= Size)
    {
		    *item = nullptr;
		    return Errors::IndexOutOfRange;
		}

    item = _values[index];
    return Errors::OK;
}

An alternative way to implement errors codes, is to define a Result type that act as a pair of an expected value and an error code.

template <typename T>
struct Result
{
    T Value;
    Errors Error = Errors::OK;
    
    Result(const T& value) : Value(value) {}
    Result(Errors error) : Error(error) {}
};

Functions that return a value, return the value wrapped inside the Result type.

The implicit constructors make the construction of the Result type more readable.

Result<T> GetAt(uint32_t index)
{
    if (index >= Size)
    {
		    return Errors::IndexOutOfRange;
		}

    return _values[index];
}

Exceptions

In C++, exceptions are a mechanism in which an error is thrown in a method with an exception object, and the application jumps to an exception handler at any previous frame of the callstack (stack unwinding).

To handle an exception, a try-catch block wraps the call the functions than can throw exceptions. When an exception is throw, the catch block that matches the exception object that was thrown is invoked.

This is known as exception handling.

#include <stdexcept>

T GetAt(uint32_t index)
{
    if (index >= Size)
    {
		    throw std::exception("Index out of range.");
		}

    return _values[index];
}

void main()
{
    Vector<float, 3> float3;

    try
    {
		    float z = float3.GetAt(2); // OK
		    float w = float3.GetAt(4); // Fail
		}
		catch (const std::exception& e)
		{
				// Handle the error
				std::cout << e.what() << std::endl;
		}
}

You can declare more specific exception types, to provide more specific information or force the callers to catch that specific exception.

class IndexOutOfRangeException : public std::exception
{
public:
	  virtual const char* what() const throw()
		{
		    return "Index out of range.";
	  }
};

The IndexOutOfRangeException exception is handled in the catch block, but other exceptions bubble up the callstack.

T GetAt(uint32_t index)
{
    if (index >= Size)
    {
		    throw IndexOutOfRangeException();
		}

    return _values[index];
}

void main()
{
    Vector<float, 3> float3;

    try
    {
		    float z = float3.GetAt(2); // OK
		    float w = float3.GetAt(4); // Fail
		}
		catch (const IndexOutOfRangeException& e)
		{
				// Handle the error
				std::cout << e.what() << std::endl;
		}
}

Advantages

Exception handling ensure that the caller that is most able to handle the error, is able to do so.

Using error codes, many callers would have to pass the error to their own respective callers.

Exceptions are designed to leverage the constructors and destructors of the types in an application to ensure that objects are correctly destroyed when an exception is thrown.

Issues

There are several issues with exception handling.

Conclusion

In a game engine, exceptions are generally disabled on every platforms.

A more fine grained error handling mechanism using error codes is generally the preferred method to handle errors.