Exceptions and the Call Stack

Learn how exceptions interact with the call stack, and how to use std::terminate, std::set_terminate, and std::abort.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

123.jpg
Ryan McCombe
Ryan McCombe
Posted

In this lesson, we’ll begin to see how exception handling is helpful in larger programs, due to its close connection to the call stack.

In previous examples, our catch statements were always catching exceptions from a function that was directly called within the corresponding try block:

void SomeFunction() {
  throw std::runtime_error;
}

void AnotherFunction() {
  try {
    SomeFunction();
  } catch (...) {
    std::cout << "Caught!";
  }
}

But, this need not be the case. The error can be caught right away, in the same stack frame:

void SomeFunction() {
  try {
    throw std::runtime_error;
  } catch (...) {
    std::cout << "Caught!";
  }
}

More usefully, the exception can be caught in any underlying stack frame:

void SomeFunction () {
  throw std::runtime_error;
}

void AnotherFunction() {
  SomeFunction();
}

int main() {
  try {
    AnotherFunction();
  } catch (...) {
    std::cout << "Caught!";
  }
}

This is the true power of exceptions - it gives us the ability to handle errors in a flexible and intuitive way.

throw and Unreachable Code

When a throw expression is encountered during the running of our application, the program immediately exits the current try block and searches for the closest catch statement that can handle the type of exception thrown.

This is demonstrated in the following example:

void MyFunction() {
  try {
    throw std::runtime_error;
    std::cout << "A"; // Unreachable
  } catch (...) {
    std::cout << "B";
  }
  std::cout << "C";
}
BC

The throw on line 3 makes the rest of the try block unreachable, so “A” is not logged. The catch block in this function can handle the type of exception thrown, so “B” is logged out.

Finally, because our function recovered, execution continues, and “C” is logged out as normal.

Exceptions and Memory Leaks

Because of this behavior, exceptions can be a common cause of memory leaks. This simple program has a memory leak if SomeFunction can ever throw an exception:

void MyFunction() {
  int* MyInt { new Int { 2 } };
  SomeFunction();
  delete MyInt;
}

This is yet another reason to use smart pointers and the other techniques covered in the chapter on memory.

Stack Unwinding

If the exception is not handled within the function that threw it, that function’s stack frame is immediately popped off the stack. Here are three examples of where that would happen:

void MyFunction() {
  // Uncaught exception
  throw std::runtime_error;
  
  // This stack frame will be popped before reaching here
  std::cout << "I'm unreachable";
}
void MyFunction() {
  try {
    // ...
  } catch (...) {
    // ...
  }
  // Exception thrown outside a try block
  throw std::runtime_error;

  // This stack frame will be popped before reaching here
  std::cout << "I'm unreachable";
}
void MyFunction() {
  // No catch block matching the type of exception thrown
  try {
    throw std::runtime_error;
  } catch (std::logic_error& e) {
    // ...
  }
  
  // This stack frame will be popped before reaching here
  std::cout << "I'm unreachable";
}

In all three cases, the MyFunction stack frame is popped off the stack. From there, the search for an appropriate catch statement continues on the next stack frame - the function that called our function:

void CallingFunction() {
  // Is there a try block wrapping this,
  // with an appropriate catch?
  MyFunction();
  // If not, I will be popped off the stack, and
  // the search will continue at the next level
  // of the stack - the function that called me
}

If the exception thrown from the call to MyFunction wasn’t caught in CallingFunction, it too is popped off the stack, and the search continues at the function that called CallingFunction.

This process repeats until an appropriate catch is found. If the process reaches main, and that too is unable to handle the exception, the program terminates.

This recursive process is commonly referred to as unwinding the stack.

std::terminate()

When our application has an unhandled exception, it terminates.

However, we can also trigger this termination ourselves, using std::terminate, available from the <exception> header.

This is mostly useful if our code encounters an error it knows is irrecoverable, and wants to close as quickly as possible, bypassing any possible catch statements that might attempt recovery.

#include <exception>
#include <iostream>

void MyFunction() { std::terminate(); }

int main() {
  try {
    MyFunction();
  } catch (...) {
    std::cout << "Caught!";
  }
}
C:\examples\exceptions.exe (process 69992) exited with code 3.

Note how “Caught!” is not logged

std::set_terminate() and std::abort()

By default, std::terminate simply calls another function, std::abort(), to close our application.

However, we can define our program’s terminate handler ourselves, by passing a function to std::set_terminate.

We cannot attempt to recover from within a terminate handler. Our custom handler still needs to call std::abort(), but it doesn’t have to call it immediately - we can do other things first.

#include <exception>
#include <iostream>

void MyFunction() { throw 1; }

int main() {
  std::set_terminate([]() {
    std::cout << "Goodbye!";
    std::abort();
  });

  MyFunction();
}
Goodbye!
C:\examples\exceptions.exe (process 69992) exited with code 3.

In large-scale projects, terminate handlers are most commonly used to generate reports containing additional information about the crash, which may help us debug.

These are typically called crash dumps, and they include information like what the unhandled exception was, the value of important variables, the call stack, hardware configuration, and more.

noexcept Functions

Within our program, we can mark functions as noexcept:

void SomeFunction() noexcept {
  // ...
}

These functions should not throw an exception to their caller. Note, we can still throw exceptions within these functions, or in the functions they call. The noexcept specifier is just stating that an exception will not be thrown to the caller.

If a noexcept function does throw an exception to the caller, std::terminate will be called, bypassing any attempts to catch that exception.

Using noexcept correctly has 3 potential benefits:

  1. It serves as documentation - anyone scanning our code will quickly see that the function will not throw exceptions.
  2. The compiler may be able to better optimize the resulting binary
  3. Some algorithms can be more performant when they are passed a noexcept function

It is possible to pass a boolean to the noexcept specifier, to determine whether or not it applies.

For example, noexcept(true) is equivalent to noexcept and noexcept(false) is equivalent to not having the specifier there at all.

We can pass any boolean expression to this handler, as long as it can be evaluated at compile time.

constexpr bool SomeBoolean{true};
void SomeFunction() noexcept(SomeBoolean) {
  // ...
}

Was this lesson useful?

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Exceptions and Error Handling
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Rethrowing and Nested Exceptions

Learn how we can rethrow errors, and wrap exceptions inside other exceptions using standard library helpers.
DreamShaper_v7_hipster_Sidecut_hair_modest_clothes_fully_cloth_2.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved