Recommended resources
The freely available resources this lesson will direct you to.
Welcome to the final lesson of our "Modern C++ for Graphics Programming" module. In our previous lesson, we mastered one of the most powerful idioms in C++: RAII, implemented through smart pointers. You learned how std::unique_ptr and std::shared_ptr automate memory management, preventing leaks and making code significantly safer. Today, we'll build directly on that foundation of safety to tackle the broader topic of error handling.
An application that crashes unexpectedly or continues running with corrupted data is of little use. In complex, performance-critical software like a rendering engine, things can and will go wrong—files may not be found, graphics API calls can fail, or user input might be invalid. This lesson will equip you with a suite of tools and strategies to implement robust error handling. We will explore and compare different mechanisms, from traditional approaches to modern C++ features, so you can make informed decisions about which tool is right for which problem.
Categorizing Errors: Bugs vs. Runtime Failures
Before diving into mechanisms, it's crucial to distinguish between two fundamental types of errors. The strategy for handling them is completely different.
(almost) All the ways to handle errors in C++
This video from the channel "Code for yourself" provides an excellent high-level overview of error handling. To start, let's watch the section that introduces this core distinction.
Watch from the beginning of the discussion on errors. The presenter categorizes errors into two groups: unrecoverable and recoverable.
-
Unrecoverable Errors (Programmer Bugs): These are logic errors, violations of preconditions, or broken invariants in your code. For example, passing a null pointer to a function that requires a valid object. The program is in a state it was never designed to be in. The only sane response is to terminate immediately and loudly, so the bug can be found and fixed.
-
Recoverable Errors (Runtime Situations): These are anticipated problems that can occur at runtime even when the code is perfectly correct. Examples include a user providing an invalid file path, a network connection being lost, or running out of graphics memory. The program should be able to handle these gracefully, perhaps by notifying the user and continuing, or by aborting the specific operation cleanly.
Let's look at the tools for each category.
Handling Unrecoverable Errors: Fail Fast
For programmer bugs, you want to stop execution as early as possible. Continuing with a corrupted state can lead to even more confusing errors later on, making debugging a nightmare.
The classic C tool for this is assert(). It checks a condition and, if it's false, terminates the program, typically printing the file and line number. However, assert() has a major drawback for professional development.
(almost) All the ways to handle errors in C++
Let's return to the "Code for yourself" video to see a demonstration of this critical flaw.
Watch the segment from "You might wonder if using check is the only way". The key takeaway is that assert statements are typically compiled out of "release" or "production" builds to improve performance. This means your checks disappear in the version of the software you ship, leaving it vulnerable to bugs that might only appear under optimization.
Because of this, many professional codebases, including those at companies like Google, use custom CHECK macros that are similar to assert but are not compiled out in release builds. The philosophy is that the tiny performance cost of a check is worth paying to prevent the chaos of undefined behavior from a corrupted state.
// A hypothetical CHECK macro
#define CHECK(condition) \
if (!(condition)) { \
std::cerr << "Check failed: " #condition << " at " << __FILE__ << ":" << __LINE__ << std::endl; \
std::abort(); \
}
void process_data(int* pointer) {
// This check ensures we don't proceed with a null pointer, even in a release build.
CHECK(pointer != nullptr);
// ... proceed to use the pointer safely
}
For our work in graphics programming, this principle is vital. The graphics drivers and APIs we'll use (like Vulkan and CUDA) have their own error-checking mechanisms, often in the form of "validation layers," which follow this same "fail fast and loud" philosophy. Later in the course, you'll even build your own C++ wrappers that turn API error codes into unrecoverable failures to enforce correctness.
Handling Recoverable Errors with Exceptions
For recoverable, runtime errors, C++'s primary tool is the exception handling mechanism: try, catch, and throw. Instead of returning an error code that a caller might ignore, throwing an exception forces the error to be dealt with.
Why and How to Use Exceptions
The core idea is to separate the "happy path" (normal execution) from the "error path". This often leads to cleaner code, especially when an error detected deep within a call stack needs to be handled by a function much higher up.
Exceptions and Error Handling, C++ FAQ
The C++ Standard Committee's own FAQ page (isocpp.org) has an excellent, in-depth guide on exceptions. It's a definitive resource.
First, read the section "Why use exceptions?" to understand the fundamental problems with error codes that exceptions solve, particularly in constructors. Then, read the next section for a basic example. Notice the critical link to RAII: if an exception is thrown, the File_handle object's destructor is still called, ensuring the file is closed. This is where our last lesson on smart pointers and RAII becomes essential. Without RAII, exceptions would leave a trail of leaked resources.
The basic flow is:
- A function encounters an error it cannot handle locally and
throws an object containing error information. - The C++ runtime halts normal execution and unwinds the stack, destroying local objects (this is where RAII saves the day!).
- It looks for a
catchblock in a calling function that can handle the type of exception thrown. - If a matching
catchblock is found, execution resumes there. - If no handler is found, the program terminates via
std::terminate.
What to Throw and How to Catch
You can technically throw anything in C++, but best practice is to throw objects that derive from the standard base class std::exception. This creates a consistent hierarchy for all errors.

When you catch an exception, you should catch by reference:
#include <iostream>
#include <stdexcept>
// A custom exception for a graphics-related error
class ShaderCompilationError : public std::runtime_error {
public:
ShaderCompilationError(const std::string& message)
: std::runtime_error("Shader Compilation Failed: " + message) {}
};
void compile_shader(const std::string& source) {
if (source.find("ERROR") != std::string::npos) {
// Throw an object of our custom exception type
throw ShaderCompilationError("Syntax error in shader source.");
}
// ... compilation logic ...
}
int main() {
try {
compile_shader("shader_source_with_ERROR");
}
// Catch by const reference. This is efficient and prevents "slicing".
catch (const std::exception& e) {
std::cerr << "Caught an exception: " << e.what() << std::endl;
// Clean up and exit gracefully
return 1;
}
return 0;
}
The Performance Question
In performance-critical fields like graphics, the cost of exceptions is a major point of debate.
Modern C++ best practices for exceptions and error handling
This article from Microsoft's C++ documentation provides a concise summary of the performance implications.
Read the section titled Exceptions and performance. The key points are: The overhead is minimal if no exception is thrown (the "zero-cost" model). The cost is significant when an exception is thrown, comparable to a function call plus stack unwinding logic. This unpredictability is why exceptions are often forbidden in performance-critical hot-loops in game engines or in safety-critical systems.
Modern Alternatives: std::optional and std::expected
Because of the performance concerns and the "invisible" control flow of exceptions, modern C++ has seen a rise in error handling patterns that are explicit and have deterministic performance. These are often preferred in systems programming.
std::optional: A Value That Might Not Be There
Introduced in C++17, std::optional<T> is a simple wrapper that can either contain a value of type T or contain nothing. It's perfect for functions that might fail to produce a result, where the reason for failure isn't important.
#include <optional>
#include <iostream>
// Returns the index if found, otherwise returns an empty optional
std::optional<int> find_item(const std::vector<int>& items, int value_to_find) {
for (int i = 0; i < items.size(); ++i) {
if (items[i] == value_to_find) {
return i; // Implicitly creates an optional containing i
}
}
return std::nullopt; // Explicitly return an empty optional
}
int main() {
std::vector<int> my_items = {10, 20, 30};
if (auto index = find_item(my_items, 20)) {
// The optional contained a value. Access it with * or .value()
std::cout << "Item found at index: " << *index << std::endl;
} else {
std::cout << "Item not found." << std::endl;
}
}
This is cleaner and safer than returning a magic number like -1 to indicate failure.
std::expected: The Best of Both Worlds
The problem with std::optional is that it can't tell you why something failed. Enter std::expected<T, E>, introduced in C++23. It's a revolutionary tool for error handling. An expected object holds either an expected value of type T (on success) or an error value of type E (on failure).
This combines the explicitness of error codes with the ability to return a full value, all with minimal overhead.
C++ FINALLY Improved Error Handling with std::expected!
The Cherno has an excellent video that introduces std::expected and explains why it's such a significant improvement.
Please watch these two clips: The initial example demonstrates a simple divide function that can return either an integer result or a string error message. The explanation of its internal workings is key. std::expected is essentially a union, meaning it only stores the success value or the error value, plus a flag. There is no dynamic memory allocation, making it extremely efficient.
Let's adapt our shader compilation example to use std::expected.
#include <expected>
#include <string>
#include <iostream>
// Our error type can be a simple enum
enum class ShaderError {
SYNTAX_ERROR,
UNSUPPORTED_FEATURE,
OUT_OF_MEMORY
};
// The function returns an expected object.
// On success, it holds a compiled shader ID (an int).
// On failure, it holds a ShaderError enum.
std::expected<int, ShaderError> compile_shader_expected(const std::string& source) {
if (source.empty()) {
// Return an unexpected value
return std::unexpected(ShaderError::SYNTAX_ERROR);
}
// ... pretend to compile ...
std::cout << "Shader compiled successfully.\n";
int shader_id = 123;
return shader_id; // Implicitly returns a successful expected object
}
int main() {
auto result = compile_shader_expected("my shader source");
if (result.has_value()) { // or just `if (result)`
std::cout << "Success! Shader ID: " << *result << std::endl;
} else {
ShaderError err = result.error();
std::cerr << "Failure! Error code: " << static_cast<int>(err) << std::endl;
}
}
This approach makes error handling explicit, type-safe, and highly performant, which is why it's gaining rapid adoption in modern C++ for systems programming, game development, and other performance-sensitive domains.
Summary
Choosing the right error handling mechanism is a matter of understanding the context. This table provides a great at-a-glance summary of the techniques we've discussed.

Key Takeaways:
- Unrecoverable Errors (Bugs): Use
assert()for debugging but preferCHECK-style macros that don't get compiled out in release builds to fail fast and prevent state corruption. - Recoverable Errors (Runtime):
- Exceptions are the standard C++ mechanism for propagating errors up the call stack. They excel at separating error logic from normal code flow and work powerfully with the RAII pattern to ensure resource safety. Their non-deterministic performance makes them unsuitable for some real-time contexts.
std::optionalis a lightweight, modern way to handle functions that might not return a value, when the reason for failure is unimportant.std::expected(C++23) is a powerful, high-performance tool for functions that can fail. It explicitly returns either a success value or a rich error object, combining the best of error codes and exceptions. It is increasingly the preferred method in performance-critical code.
This lesson concludes our module on Modern C++. You've built a solid foundation, from object-oriented principles to memory management and now robust error handling. With these skills, you are well-prepared to tackle the specific challenges of graphics programming.
In our next module, we will dive into the heart of 3D graphics: the mathematics. We'll begin with vectors, the fundamental building blocks for representing positions, directions, and movement in a 3D world.