Exceptions, functions, and stack unwinding

In previous lessons, we explored how exceptions enable error handling through throw, try, and catch mechanisms. Now we'll examine how exceptions behave when they cross function boundaries and what happens during the stack unwinding process.

Propagating exceptions across function boundaries

Previously, we saw examples where throw statements appeared directly inside try blocks. While this works, it severely limits the usefulness of exception handling. Real applications need a more flexible approach.

The power of exception handling becomes apparent when we realize that exceptions can traverse function call boundaries. When a function throws an exception, that exception travels up the call stack until an appropriate catch block is found. The try block doesn't need to directly contain the throw statement—it catches exceptions from any function called within its scope.

Key Concept
Exception handlers catch not just exceptions from direct statements in the try block, but also from any function invoked within that try block.

This capability enables modular error handling where functions can signal problems without needing to handle them locally. Let's demonstrate this with a network connection example.

```cpp #include #include

// Validates port number and throws if invalid int validatePort(int port) { if (port < 1024 || port > 65535) { throw std::string{"Port must be between 1024 and 65535"}; }

return port;

}

int main() { std::cout << "Enter port number: "; int port{}; std::cin >> port;

try
{
    int validPort{ validatePort(port) };
    std::cout << "Connecting to port " << validPort << '\n';
}
catch (const std::string& error)
{
    std::cerr << "Connection failed: " << error << '\n';
}

return 0;

}


Running with invalid input:

Enter port number: 80
Connection failed: Port must be between 1024 and 65535

Notice how the `validatePort()` function throws an exception but doesn't catch it. When the exception is thrown, control immediately transfers from inside `validatePort()` to the catch block in `main()`. The function `validatePort()` delegates error handling responsibility to its caller.

This delegation is crucial for creating flexible, reusable code. Different applications might handle the same error differently—a console application might print to stderr, a GUI application might display a dialog, and a server might log to a file. By letting callers handle exceptions, `validatePort()` remains maximally reusable without imposing specific error handling strategies.

**Understanding stack unwinding**

When an exception is thrown, C++ performs a systematic search for an appropriate handler. Let's examine this process in detail.

<div class="info">
<strong>Related Content</strong><br>
Review the lesson on call stacks if you need a refresher on how function calls are managed.

When an exception is thrown, the runtime first checks whether the current function can handle it. This requires two conditions: the throw must occur inside a try block, and an associated catch block must match the exception type.

If the current function cannot handle the exception, the runtime examines the calling function. For the caller to handle the exception, the call to the current function must be inside a try block with a matching catch block.

This process continues up the call stack, checking each function in succession. The search proceeds until either a matching handler is found, or all functions have been checked without finding a handler.

When a matching handler is located, execution jumps from the throw point to the beginning of the matching catch block. To accomplish this, the runtime must unwind the stack—removing functions from the call stack until the handling function becomes the top-level function.

If no handler is found, the behavior depends on the circumstances (we'll discuss this in the next lesson on uncaught exceptions).

During stack unwinding, local variables in each removed function are destroyed in reverse order of their construction. However, no return value is generated because the function exits abnormally.

</div>
<div class="info">
<strong>Key Concept</strong><br>
Stack unwinding ensures that destructors run for all local objects in unwound functions, guaranteeing proper cleanup.

**A comprehensive stack unwinding example**

Let's trace exception behavior through multiple function levels. This program creates a deep call stack where an exception originates at the bottom and propagates upward.

</div>
```cpp
#include <iostream>
#include <string>

void loadConfiguration() // called by initializeSystem()
{
    std::cout << "loadConfiguration() started\n";
    std::cout << "Configuration file missing, throwing exception\n";

    throw std::string{"config.ini not found"};

    std::cout << "loadConfiguration() completed\n"; // never executes
}

void initializeSystem() // called by startApplication()
{
    std::cout << "initializeSystem() started\n";
    loadConfiguration();
    std::cout << "initializeSystem() completed\n";
}

void startApplication() // called by runProgram()
{
    std::cout << "startApplication() started\n";

    try
    {
        initializeSystem();
    }
    catch (int errorCode) // won't match: wrong type
    {
        std::cerr << "startApplication() caught int: " << errorCode << '\n';
    }

    try
    {
    }
    catch (std::string errorMsg) // won't match: no exception in this try block
    {
        std::cerr << "startApplication() caught string: " << errorMsg << '\n';
    }

    std::cout << "startApplication() completed\n";
}

void runProgram() // called by main()
{
    std::cout << "runProgram() started\n";

    try
    {
        startApplication();
    }
    catch (std::string errorMsg) // matches and handles the exception
    {
        std::cerr << "runProgram() caught string: " << errorMsg << '\n';
    }
    catch (int errorCode) // not called: exception already handled
    {
        std::cerr << "runProgram() caught int: " << errorCode << '\n';
    }

    std::cout << "runProgram() completed\n";
}

int main()
{
    std::cout << "main() started\n";

    try
    {
        runProgram();
    }
    catch (std::string errorMsg) // not called: exception already handled
    {
        std::cerr << "main() caught string: " << errorMsg << '\n';
    }

    std::cout << "main() completed\n";

    return 0;
}

Study this program and predict its output. Here's what actually prints:

main() started runProgram() started startApplication() started initializeSystem() started loadConfiguration() started Configuration file missing, throwing exception runProgram() caught string: config.ini not found runProgram() completed main() completed

Let's analyze the execution flow. All the "started" messages print straightforwardly as each function begins. The interesting behavior starts when loadConfiguration() prints its message and throws a string exception.

Since loadConfiguration() doesn't handle its own exception, the runtime searches up the call stack. Function initializeSystem() contains no exception handling, so no match exists there.

Function startApplication() has two try blocks. The first try block, which contains the call to initializeSystem(), has a catch handler for int exceptions. However, our exception is a std::string, and exceptions don't perform type conversion, so this handler doesn't match. The second try block has a string handler, but the call to initializeSystem() isn't inside that try block, so this handler is also ineligible.

Function runProgram() also has a try block, and the call to startApplication() is within it. The runtime checks for catch handlers and finds one for std::string exceptions—a perfect match! Consequently, runProgram() handles the exception and prints "runProgram() caught string: config.ini not found".

Having handled the exception, execution continues normally after the catch blocks. Function runProgram() prints "runProgram() completed" and returns to main().

Control returns to main(). Although main() has a string exception handler, our exception was already handled by runProgram(), so this catch block never executes. Function main() simply prints "main() completed" and terminates normally.

Several important principles emerge from this example:

First, a function whose called function throws an exception doesn't have to handle it. Here, initializeSystem() didn't handle the exception from loadConfiguration(), delegating that responsibility up the stack.

Second, if a try block lacks the appropriate catch handler type, stack unwinding proceeds as if no try block existed. Function startApplication() couldn't handle the exception because it had the wrong type of catch block.

Third, a catch block is only considered if the problematic function call occurred within the associated try block. We saw this principle demonstrated in startApplication().

Fourth, after a matching catch block handles an exception, control flow resumes normally with the first statement after all catch blocks. This was illustrated by runProgram() handling the error, then continuing with "runProgram() completed", and finally returning to the caller. By the time execution reached main(), the exception was ancient history—main() had no indication anything exceptional had occurred.

Stack unwinding provides tremendous flexibility—functions that don't want to handle exceptions don't have to. The exception propagates up the stack until something wants to deal with it, allowing us to choose the most appropriate location in the call hierarchy for error handling.

In the next lesson, we'll explore what happens when no catch block handles an exception, and techniques to prevent that scenario.

Summary

Exception propagation: Exceptions can cross function boundaries, allowing errors to be thrown in one function and caught in another. The try block doesn't need to directly contain the throw statement—it catches exceptions from any function called within its scope.

Stack unwinding: When an exception is thrown, the runtime searches up the call stack for an appropriate handler. This process systematically checks each function, and if a handler is found, the runtime removes functions from the call stack until the handling function becomes the top-level function.

Destructor execution during unwinding: During stack unwinding, local variables in each removed function are destroyed in reverse order of their construction. This ensures proper cleanup even when functions exit abnormally.

Handler matching rules: A catch block is only considered if the problematic function call occurred within the associated try block. Exceptions don't perform type conversion, so the exception type must exactly match the catch block type (or be derived from it).

Delegation flexibility: Functions that don't want to handle exceptions don't have to—the exception propagates up the stack until something wants to deal with it, allowing you to choose the most appropriate location in the call hierarchy for error handling.

Understanding exception propagation and stack unwinding is fundamental to writing robust exception-safe code that properly handles errors across complex function call hierarchies.