Error Handling
Category | C++ |
---|
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 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.
- They prevent an invalid program to compile, and force the programmer to fix the error to be able to compile.
- They allow errors to be detected and fixed as early as possible.
Usage
- Ensure that a type has a specific size or is aligned on a specific boundary.
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");
- Validate template parameters using type traits.
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:
- Is enabled in development builds (covers debug builds and non final release builds).
- Provides a custom reporting mechanism.
#if defined(DEVELOPMENT)
#define MyAssert(expression, message) \
if (expression) {}
else \
{ \
ReportAssert(#expression, __FILE__, __LINE__); \
__debugbreak(); \
}
#else
#define MyAssert(expression, message)
#endif
- In non-development builds, the assertion is resolved to nothing, which strips out the assert statement from machine code.
- If the expression returns true, nothing happens.
- If the expression returns false:
- A custom ReportAssert function is invoked with 3 parameters:
- The string representation of the expression using the
#
character.
- The file name of the source code in which the assert function is invoked.
- The line number in the source code where the assert function is invoked.
- The string representation of the expression using the
- The
__debugbreak
intrinsic function is platform-dependent and causes a breakpoint in the code.
- A custom ReportAssert function is invoked with 3 parameters:
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.
- Exceptions add overhead to an application by generating additional machine code to support stack unwinding operations. It increases the code size and degrade the performance of the instruction cache.
- Exception handling can be expensive.
- Exception handling is hard to follow in a large program, and can be worse than goto statement.
- The implementation is platform-dependent, which makes the actual cost of using exception variable across platforms.
- In real-time applications, handling immediate errors is more critical than bubbling up every errors in a generic handler.
- In practice, in a game, recovering from exceptions is only possible by the immediate callers, which makes the the bubble up through the callstack useless.
- If an exception were to bubble up through the callstack, the frame in the callstack that ends up handling the exception would only recover from the exception by crashing the game.
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.