C++ for C Developers | Lesson 10: Exception Handling: A Robust Approach to Errors
Goal: Introduce C++ exception handling (try
, catch
, throw
) as the standard mechanism for dealing with runtime errors, contrasting it with C’s error code/errno
approach. Emphasize the critical interaction between exceptions and RAII via stack unwinding. Introduce standard exception types and noexcept
.
1. Recap: Limitations of C-Style Error Handling
As we saw briefly before, C relies on return codes and global errno
. This forces you to mix error-checking logic (if (result == -1) { ... }
) directly into your normal program flow, making code harder to read and maintain. Crucially, it puts the entire burden of resource cleanup (like free
ing memory or fclose
ing files) on the programmer within every single error path, which is extremely error-prone.
flowchart TB subgraph C [C: Manual Error Handling] C1["Function returns error code"] C2["Caller must check return value"] C3["No automatic cleanup"] C4["Easy to forget checks"] end subgraph CPP [C++: Exception Handling] CPP1["throw exception on error"] CPP2["Caller can use try/catch"] CPP3["Automatic stack unwinding"] CPP4["Resource cleanup with RAII"] end
2. C++ Exceptions: Separating Error Handling
C++ exceptions provide a way to transfer control and information from a point where an error occurs to a designated error handler, separating the error path from the normal execution path.
throw
: When a function encounters an error it cannot resolve locally, it throws an exception object. This immediately stops the function’s normal execution. You can throw objects of almost any type, but typically standard exception classes or custom classes derived fromstd::exception
are used.try
: Code that might potentially throw an exception (either directly or by calling functions that might throw) is placed within atry { ... }
block.catch
: Following atry
block, one or morecatch (ExceptionType& e) { ... }
blocks act as handlers. If an exception is thrown within thetry
block, C++ searches for acatch
block whoseExceptionType
matches the type of the thrown object.
flowchart TD TryBlock["try { code }"] ExceptionThrown["Exception thrown (e.g., throw std::runtime_error)"] StackUnwind["Stack unwinding begins"] CatchBlock["catch (const std::exception& e)"] HandleError["Error handled gracefully"] TryBlock --> ExceptionThrown --> StackUnwind --> CatchBlock --> HandleError
3. Basic Syntax and Flow
#include <iostream>
#include <string>
#include <vector>
#include <stdexcept> // Required for standard exception classes like std::out_of_range
// Function that might throw an exception
double get_element_at(const std::vector<double>& data, size_t index) {
// Check for a condition that prevents normal execution
if (index >= data.size()) {
// Throw an exception object indicating the error
throw std::out_of_range(
"Index " + std::to_string(index) +
" is out of range for vector of size " + std::to_string(data.size())
);
// Execution of this function stops HERE if exception is thrown
}
// This part only runs if the condition above was false (no throw)
return data[index]; // Use [] because we already checked bounds
}
int main() {
std::vector<double> values = {10.5, 20.0, 30.2};
size_t valid_index = 1;
size_t invalid_index = 5;
std::cout << "Attempting to access elements..." << std::endl;
try {
// --- Code that might throw ---
std::cout << "Trying valid index " << valid_index << std::endl;
double val1 = get_element_at(values, valid_index);
std::cout << " Value at index " << valid_index << ": " << val1 << std::endl;
std::cout << "Trying invalid index " << invalid_index << std::endl;
double val2 = get_element_at(values, invalid_index); // This call will throw!
// This line below WILL NOT be reached if the line above throws
std::cout << " Value at index " << invalid_index << ": " << val2 << std::endl;
} catch (const std::out_of_range& oor) { // Catch specific exception type by const reference
// --- Error Handling Code ---
// This block executes because get_element_at threw std::out_of_range
std::cerr << "!! Caught an out_of_range exception !!" << std::endl;
std::cerr << " Error details: " << oor.what() << std::endl; // what() gives the error message
// Handle the error (e.g., log, return error code from main, use default value)
} catch (const std::exception& e) {
// --- Catching other standard exceptions ---
// This catches any other exception derived from std::exception
// that wasn't caught by the more specific handler above.
std::cerr << "!! Caught some other standard exception: " << e.what() << std::endl;
} catch (...) {
// --- Catch-all (Use Sparingly) ---
// Catches ANY exception type, including non-standard ones.
// Usually used for last-resort cleanup or logging.
std::cerr << "!! Caught an unknown exception type !!" << std::endl;
}
std::cout << "Program continues after try-catch block." << std::endl;
// Note: Execution jumps from the 'throw' to the matching 'catch' block.
// Code after the throw in the try block is skipped.
// Code after the try-catch block executes normally.
return 0; // Or return non-zero if an error was caught
}
flowchart TD Main["main()"] FuncA["functionA()"] FuncB["functionB()"] FuncC["functionC() throws std::runtime_error"] CatchInMain["catch block in main()"] Main --> FuncA --> FuncB --> FuncC --> CatchInMain
4. Stack Unwinding and RAII: The Critical Link
What happens to local variables and resources when an exception is thrown and control jumps potentially way back up the call stack? C++ performs stack unwinding.
As C++ searches up the call stack for a suitable catch
block, it exits the scope of each function it leaves. Crucially, as it exits each scope, it automatically calls the destructors for all fully constructed local objects within that scope.
#include <iostream>
#include <string>
#include <stdexcept>
// Simple RAII class (e.g., manages a resource like a log entry)
class Logger {
private:
std::string func_name;
public:
Logger(std::string name) : func_name(std::move(name)) {
std::cout << "[Entering " << func_name << "]" << std::endl;
}
~Logger() { // Destructor is the key!
std::cout << "[Exiting " << func_name << "] <--- Destructor Called!" << std::endl;
}
// Prevent copying
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
};
void functionB() {
Logger log("functionB"); // RAII object created on stack
std::cout << " In functionB, about to throw..." << std::endl;
throw std::runtime_error("Problem occurred in functionB");
// Destructor ~Logger() for 'log' WILL be called during stack unwinding.
}
void functionA() {
Logger log("functionA"); // RAII object created on stack
std::cout << " In functionA, calling functionB..." << std::endl;
functionB(); // This call leads to an exception
std::cout << " This line in functionA is never reached." << std::endl;
// Destructor ~Logger() for 'log' WILL be called during stack unwinding.
}
int main() {
Logger log("main");
try {
functionA();
} catch (const std::runtime_error& e) {
std::cerr << "** Caught Exception in main: " << e.what() << " **" << std::endl;
}
// Destructor ~Logger() for 'log' called when main exits.
return 0;
}
Output of the above:
[Entering main]
[Entering functionA]
In functionA, calling functionB...
[Entering functionB]
In functionB, about to throw...
[Exiting functionB] <--- Destructor Called!
[Exiting functionA] <--- Destructor Called!
** Caught Exception in main: Problem occurred in functionB **
[Exiting main] <--- Destructor Called!
Notice how the destructors for log
in functionB
and functionA
were called automatically as the stack unwound due to the exception, even though their normal exit points weren’t reached. This automatic cleanup via RAII object destructors is what makes C++ exceptions safe and manageable. If you were managing resources manually (e.g., raw new
/delete
, fopen
/fclose
), you would leak resources unless you had catch
blocks everywhere doing manual cleanup, which defeats the purpose.
flowchart TD Resource["Resource Acquired (RAII object created)"] TryBlockRAII["try block begins"] ExceptionRAII["Exception thrown"] DestructorCalled["RAII object's destructor called"] ResourceReleased["Resource released automatically"] Resource --> TryBlockRAII --> ExceptionRAII --> DestructorCalled --> ResourceReleased
5. Standard Exceptions & noexcept
- Always prefer throwing standard exception types (
<stdexcept>
) or custom classes derived fromstd::exception
. This allows callers to catch exceptions polymorphically usingcatch (const std::exception& e)
. - Use
noexcept
(C++11) on functions you guarantee will not throw. This helps with compiler optimizations and clearly states intent. Destructors, move constructors, and move assignment operators should generally benoexcept
.
void process_data() noexcept { /* ... safe code ... */ }
class MyData {
// ...
public:
~MyData() noexcept { /* Destructors should not throw */ }
};
Summary of Lesson 10:
- C++ exceptions (
try
/catch
/throw
) provide a cleaner way to handle errors than C-style return codes, separating error logic from normal flow. - Use standard exception classes from
<stdexcept>
derived fromstd::exception
. - Catch exceptions by
const
reference (const std::Type& e
). - Stack Unwinding automatically calls destructors of local stack objects when an exception propagates.
- RAII is essential for exception safety. Use RAII objects (smart pointers, containers, file streams, custom classes with destructors) to ensure automatic resource cleanup during stack unwinding.
- Use
noexcept
to mark functions that guarantee not to throw.
Next Lesson: We’ll dive deep into Smart Pointers, covering std::shared_ptr
for managing shared object ownership and std::weak_ptr
for breaking ownership cycles.
More Sources:
cppreference – Exceptions: https://en.cppreference.com/w/cpp/language/exceptions
Learn C++ – Exception Handling: https://www.learncpp.com/cpp-tutorial/basic-exception-handling/