Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 4 – Functions, files and scope

Recursion and the call stack in C++

Write recursive C++ functions with base cases every input reaches, trace their frames on the call stack and measure how deep the stack lets them go.

  • Beginner
  • 15 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 15 minutes

What you will learn

  • Write a recursive function whose base case every input reaches
  • Trace the frames a recursive call pushes and pops on the call stack
  • Estimate how deep a recursion can go from the frame size and the stack limit
  • Explain why a tail call can overflow the stack in one build and not in another
  • Use memoisation to stop recursive calls from repeating each other's work

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a recursive function whose base case every input reaches
  • Trace the frames a recursive call pushes and pops on the call stack
  • Estimate how deep a recursion can go from the frame size and the stack limit
  • Explain why a tail call can overflow the stack in one build and not in another
  • Use memoisation to stop recursive calls from repeating each other's work

Before you start

On this page

A function that keeps 1 KB of local data could call itself 7,579 levels deep on the Mac that ran this lesson’s examples, and one level more crashed the program: the main thread’s stack holds 8,372,224 bytes. A recursive function calls itself to solve a smaller version of its own problem, and each call still waiting for its answer occupies a frame of stack memory, so the deeper it goes, the more stack it uses.

You will write recursive functions whose base case every input reaches, trace their frames as they are pushed and popped, measure the depth limit and read AddressSanitizer’s report past it, see why a tail call is not guaranteed to save a frame, and stop repeated work with memoisation. Every program was built with Apple clang 21 and GCC 16.

A recursive function solves a smaller case of its own problem

A recursive function: A function that calls itself, directly or through other functions, to solve a smaller case of its own problem, such as factorial(n) calling factorial(n - 1). It needs a base case that every input reaches. calls itself: factorial(4) is 4 * factorial(3). Here are three recursive functions that print every call, indented by how deep the call is:

Three recursive functions, with every call printed C++ · trace.cpp
// trace.cpp: three recursive functions that print each call, indented by its depth.
#include <cstdint>
#include <print>
#include <string>

std::string indent(int depth) { return std::string(2 * depth, ' '); }

std::int64_t factorial(int n, int depth = 0) {
    std::println("{}factorial({})", indent(depth), n);
    if (n <= 1) {  // the base case: answered without another call
        std::println("{}factorial({}) returns 1, the base case", indent(depth), n);
        return 1;
    }
    // the recursive case: a smaller problem
    std::int64_t result = n * factorial(n - 1, depth + 1);
    std::println("{}factorial({}) returns {}", indent(depth), n, result);
    return result;
}

// x to the power n in about log2(n) calls: x^n = (x^(n/2))^2, times x again if n is odd.
std::int64_t power(std::int64_t x, int n, int depth = 0) {
    if (n == 0) {
        std::println("{}power({}, 0) returns 1, the base case", indent(depth), x);
        return 1;
    }
    std::println("{}power({}, {})", indent(depth), x, n);
    std::int64_t half = power(x, n / 2, depth + 1);
    std::int64_t result = n % 2 == 0 ? half * half : half * half * x;
    std::println("{}power({}, {}) returns {}", indent(depth), x, n, result);
    return result;
}

// Moves n discs between pegs, never a larger disc on a smaller one; returns the moves.
int hanoi(int n, char from, char to, char spare) {
    if (n == 0) return 0;
    int moves = hanoi(n - 1, from, spare, to);
    std::println("move disc {} from {} to {}", n, from, to);
    return moves + 1 + hanoi(n - 1, spare, to, from);
}

int main() {
    factorial(4);
    power(2, 10);
    std::println("{} moves", hanoi(3, 'A', 'C', 'B'));
}

Output

factorial(4)
  factorial(3)
    factorial(2)
      factorial(1)
      factorial(1) returns 1, the base case
    factorial(2) returns 2
  factorial(3) returns 6
factorial(4) returns 24
power(2, 10)
  power(2, 5)
    power(2, 2)
      power(2, 1)
        power(2, 0) returns 1, the base case
      power(2, 1) returns 2
    power(2, 2) returns 4
  power(2, 5) returns 32
power(2, 10) returns 1024
move disc 1 from A to C
move disc 2 from A to B
move disc 1 from C to B
move disc 3 from A to C
move disc 1 from B to A
move disc 2 from B to C
move disc 1 from A to C
7 moves

Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined trace.cpp -o trace && ./trace

Each function has the same two parts. The base case: The input a recursive function answers without calling itself, such as factorial(1), which is 1. Every chain of recursive calls must reach one, or the calls go on until the stack overflows. answers directly: factorial(1) is 1, power(x, 0) is 1, and moving zero discs takes no moves. The recursive case reduces the problem and trusts the function to solve the smaller version: factorial(n) multiplies n by factorial(n - 1). The GCC build printed the same lines. Step through factorial(4) and watch the frames pile up and come off again:

Interactive · step by step The call stack of factorial(4), frame by frame Step through factorial(4) from trace.cpp. Each box is a frame; the newest is on top, and each has its own n.
The call stack of factorial(4), frame by frame, step 7 of 7. Showing factorial(4) (returns 4 × 6 = 24), main (received 24). In focus: factorial(4), main.factorial(4)returns 4 × 6 = 24mainreceived 24

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The first frame. main calls factorial(4). The call pushes a frame with its own n, 4, and prints factorial(4). 4 is not the base case, so it needs factorial(3) first.
  2. A frame for each call. factorial(3) gets a frame of its own, with an n of 3. The frame of factorial(4) stays where it is: it still has to multiply by 4 when the answer comes back.
  3. Deeper. factorial(2) pushes a third frame. Each call so far is unfinished, so each keeps its frame.
  4. The base case. factorial(1) is the base case: n <= 1, so it prints factorial(1) returns 1, the base case, without another call. This is the deepest point: four frames of factorial on top of main.
  5. 1 comes back. factorial(1) returns, and its frame is popped. factorial(2) can now finish: 2 × 1 = 2, and it prints factorial(2) returns 2.
  6. 2 comes back. The frame of factorial(2) is popped, and factorial(3) multiplies its own n by the 2 it receives: it returns 6.
  7. 24 for main. Last, factorial(4) returns 4 × 6 = 24 to main, and the stack is back to main's frame alone. The results came back in reverse order: 1, 2, 6, 24.

factorial(4) cannot finish until factorial(3) has finished, which waits for factorial(2), and so on down to the base case. Each waiting call keeps a stack frame: The block of the call stack that belongs to one call that has not returned yet: its parameters, its local variables, saved registers and the address to return to. A call pushes it and the return pops it. with its own n, which is why each one remembers what to multiply by when the answer comes back. The results then return in the reverse order: 1, 2, 6, 24.

The trace also shows how deep each function goes. factorial(n) is n calls deep. power(2, 10) halves its exponent on every call, so it is only five calls deep (10, 5, 2, 1, 0): halving the problem keeps the depth near log₂ n, which is why fast exponentiation handles huge exponents. The Tower of Hanoi makes two recursive calls for one disc fewer, so 3 discs take 2³ − 1 = 7 moves while at most 4 calls are on the stack at once (down to hanoi(0)); 20 discs would take 1,048,575 moves with never more than 21 calls waiting.

main is the one exception

The standard permits recursive calls to every function except main: a program may never call main.

Checkpoint

Check: How a recursive function works

  1. Question 1 of 2 power from trace.cpp halves its exponent on every call. How many calls does power(2, 16) make, the base case included?

    Type a number.

    A hint

    Write down the exponents until you reach 0.

    Show the answer: Question 1 of 2

    Answer: 6 calls

    The exponents are 16, 8, 4, 2, 1 and 0: six calls, and at most six frames at once. power(2, 10) made five (10, 5, 2, 1, 0). Halving keeps the depth near log₂ n.

  2. Question 2 of 2 In the trace of factorial(4), which call returns first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: factorial(1), the base case

    Calls go down to the base case, and results come back up in the reverse order of the calls: the newest frame is always the first to go.

Every input must reach a base case

A recursive function that misses its base case never stops calling itself. Here are two ways to get it wrong:

no_base_case.cpp C++ · no_base_case.cpp
// no_base_case.cpp: two recursive functions that never stop.
#include <print>

// No base case at all: every call makes another call.
int count_down(int n) {
    return count_down(n - 1) + 1;
}

// A base case that some inputs never reach: odd or negative n step past n == 0.
int half_steps(int n) {
    if (n == 0) return 0;
    return half_steps(n - 2) + 1;
}

int main(int argc, char*[]) {
    std::println("half_steps(10) = {}", half_steps(10));
    if (argc > 1) std::println("half_steps(7) = {}", half_steps(7));
    if (argc > 2) std::println("count_down(5) = {}", count_down(5));
}
The warnings, and the crash Python · no_base_case_session.py
from session import run

run(r"""
clang++ -std=c++23 -Wall -Wextra no_base_case.cpp -o no_base_case
g++-16 -std=c++23 -Wall -Wextra no_base_case.cpp -o no_base_case_gcc
./no_base_case
./no_base_case odd
""")

Output

$ clang++ -std=c++23 -Wall -Wextra no_base_case.cpp -o no_base_case
no_base_case.cpp:5:23: warning: all paths through this function will call itself [-Winfinite-recursion]
    5 | int count_down(int n) {
      |                       ^
1 warning generated.
$ g++-16 -std=c++23 -Wall -Wextra no_base_case.cpp -o no_base_case_gcc
no_base_case.cpp: In function 'int count_down(int)':
no_base_case.cpp:5:5: warning: infinite recursion detected [-Winfinite-recursion]
    5 | int count_down(int n) {
      |     ^~~~~~~~~~
no_base_case.cpp:6:22: note: recursive call
    6 |     return count_down(n - 1) + 1;
      |            ~~~~~~~~~~^~~~~~~
$ ./no_base_case
half_steps(10) = 5
$ ./no_base_case odd
[stopped by signal 11 (SIGSEGV): a shell reports exit status 139]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 no_base_case_session.py

count_down has no base case at all, and both compilers notice: Apple clang warns that “all paths through this function will call itself”, GCC that it detected infinite recursion, both under -Winfinite-recursion. half_steps is the harder case. It has a base case, n == 0, but stepping down by 2 from an odd number jumps over 0, and from there n only gets smaller. Neither compiler warned about it. half_steps(10) works, and half_steps(7) called itself until the stack ran out and the program was stopped with SIGSEGV, the signal POSIX sends to a program that grows its stack past the limit. The line half_steps(10) = 5 is missing from that second run because it was still in the output buffer when the program was stopped.

A base case that only some inputs reach

if (n == 0) return 0; with a step of n - 2 misses 0 for every odd n. Write the base case for every input that cannot be reduced, if (n <= 0) return 0;, or reject bad input before the recursion starts.

Before you write a recursive function, name the quantity that shrinks on every call and the point where it stops. For half_steps that is n, which falls by 2, and the stopping point has to be “at or below zero”, not “exactly zero”.

Checkpoint

Check: Base cases every input reaches

  1. Question 1 of 2 half_steps(10) printed 5, but half_steps(7) crashed. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 7, 5, 3, 1, -1 … steps over 0, so the base case n == 0 is never reached and the calls go on until the stack overflows

    A base case must cover every input that cannot be reduced. Name what shrinks on each call and where it stops, and the gap appears at once.

  2. Question 2 of 2 Which base case makes half_steps stop for every int n?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: if (n <= 0) return 0;

    The fix of the lesson's mistake box: test for every value that cannot be reduced, not only for one exact value, or reject bad input before the recursion starts.

Each pending call holds a frame, and the stack is small

Every call pushes a frame onto the stack, and every return pops it, so the stack holds exactly the calls that have not finished yet. A frame costs memory, and the stack is small and fixed. This program measures both:

depth.cpp C++ · depth.cpp
// depth.cpp: the stack bytes one call takes, and how many calls the stack has room for.
#include <sys/resource.h>  // getrlimit, the POSIX call that reports the stack limit

#include <cstdint>
#include <print>
#include <string>

std::uintptr_t address_at_depth_0 = 0;
std::uintptr_t address_at_depth_1 = 0;

// Each call keeps a 1,024-byte buffer in its frame, so frames are big and easy to measure.
int dive(int depth, int target) {
    volatile char buffer[1024];  // volatile: the compiler must keep the buffer
    buffer[0] = 1;
    auto here = reinterpret_cast<std::uintptr_t>(&buffer[0]);
    if (depth == 0) address_at_depth_0 = here;
    if (depth == 1) address_at_depth_1 = here;
    if (depth == target) return depth;
    return dive(depth + 1, target) + buffer[0] - 1;
}

int main(int argc, char* argv[]) {
    int target = argc > 1 ? std::stoi(argv[1]) : 1000;
    dive(0, target);
    // the stack grows towards lower addresses
    std::uintptr_t frame = address_at_depth_0 - address_at_depth_1;
    rlimit limit{};
    getrlimit(RLIMIT_STACK, &limit);
    std::println("reached depth {}", target);
    std::println("one call of dive uses {} bytes of stack", frame);
    std::println("the stack limit is {} bytes: room for about {} calls", limit.rlim_cur, limit.rlim_cur / frame);
}
Frame size, stack limit and the deepest call Python · depth_session.py
from session import run

# -O0 keeps every call as it is written. The search runs depth again and again, halving the range each time.
run(r"""
clang++ -std=c++23 -O0 depth.cpp -o depth_clang && ./depth_clang 1000
g++-16 -std=c++23 -O0 depth.cpp -o depth_gcc && ./depth_gcc 1000
./depth_clang 100000
lo=1000; hi=100000; while [ $((hi - lo)) -gt 1 ]; do mid=$(((lo + hi) / 2)); if ./depth_clang $mid > /dev/null; then lo=$mid; else hi=$mid; fi; done 2> /dev/null; echo "deepest call that worked, Apple clang build: $lo"
lo=1000; hi=100000; while [ $((hi - lo)) -gt 1 ]; do mid=$(((lo + hi) / 2)); if ./depth_gcc $mid > /dev/null; then lo=$mid; else hi=$mid; fi; done 2> /dev/null; echo "deepest call that worked, GCC build: $lo"
""")

Output

$ clang++ -std=c++23 -O0 depth.cpp -o depth_clang && ./depth_clang 1000
reached depth 1000
one call of dive uses 1104 bytes of stack
the stack limit is 8372224 bytes: room for about 7583 calls
$ g++-16 -std=c++23 -O0 depth.cpp -o depth_gcc && ./depth_gcc 1000
reached depth 1000
one call of dive uses 1072 bytes of stack
the stack limit is 8372224 bytes: room for about 7809 calls
$ ./depth_clang 100000
[stopped by signal 11 (SIGSEGV): a shell reports exit status 139]
$ lo=1000; hi=100000; while [ $((hi - lo)) -gt 1 ]; do mid=$(((lo + hi) / 2)); if ./depth_clang $mid > /dev/null; then lo=$mid; else hi=$mid; fi; done 2> /dev/null; echo "deepest call that worked, Apple clang build: $lo"
deepest call that worked, Apple clang build: 7579
$ lo=1000; hi=100000; while [ $((hi - lo)) -gt 1 ]; do mid=$(((lo + hi) / 2)); if ./depth_gcc $mid > /dev/null; then lo=$mid; else hi=$mid; fi; done 2> /dev/null; echo "deepest call that worked, GCC build: $lo"
deepest call that worked, GCC build: 7805

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 depth_session.py

At -O0, one call of dive used a little more than its 1,024-byte buffer: 1,104 bytes with Apple clang and 1,072 with GCC, because a frame also holds the parameters, saved registers and the return address. getrlimit, a POSIX call, reported the limit of the main thread’s stack: 8,372,224 bytes (8,176 KiB, the value ulimit -s prints). Dividing the two gives an estimate of how deep dive can go, and the search found the deepest call that worked a few calls short of each estimate:

Build Frame Estimate Deepest that worked
Apple clang, -O0 1,104 bytes 7,583 calls 7,579
GCC, -O0 1,072 bytes 7,809 calls 7,805

The rest of the stack was taken by main, the code that runs before it and the program’s environment. One level deeper and the program crashed, as ./depth_clang 100000 did: a stack overflow: What happens when the calls still running need more stack than the thread has: the program is stopped, on POSIX systems with SIGSEGV. AddressSanitizer reports it as a stack-overflow with the function that caused it..

That limit differs between systems and between threads. A Windows executable gets a 1 MB stack unless it is linked with another /STACK size, so the same program would manage only about an eighth of the depth there. On macOS, threads other than the main thread get 512 KB by default, according to Apple’s threading guide. Programming judges set limits of their own. So a recursion depth that grows with the size of the input, such as a list of a million items or a path through a big grid, is a crash waiting for the first large input.

AddressSanitizer names the problem

A bare SIGSEGV says nothing about the cause. Built with -fsanitize=address, the same program reports a stack overflow and the function that caused it:

The AddressSanitizer report with both compilers Python · asan_session.py
from session import run

# AddressSanitizer reports the overflow instead of a bare crash. Its report is long, so the session keeps the
# error line, the first two frames and the summary, and hides the process number and addresses, which change on
# every run.
run(r"""
clang++ -std=c++23 -O0 -g -fsanitize=address depth.cpp -o depth_asan && ./depth_asan 100000
g++-16 -std=c++23 -O0 -g -fsanitize=address depth.cpp -o depth_asan_gcc && ./depth_asan_gcc 100000
""", keep=r"ERROR: AddressSanitizer|^\s+#[01] |SUMMARY", replace=[(r"==\d+==", "==…=="), (r"0x[0-9a-f]+", "0x…")])

Output

$ clang++ -std=c++23 -O0 -g -fsanitize=address depth.cpp -o depth_asan && ./depth_asan 100000
==…==ERROR: AddressSanitizer: stack-overflow on address 0x… (pc 0x… bp 0x… sp 0x… T0)
    #0 0x… in dive(int, int) depth.cpp:12
    #1 0x… in dive(int, int) depth.cpp:19
SUMMARY: AddressSanitizer: stack-overflow depth.cpp:12 in dive(int, int)
[exit status 134]
$ g++-16 -std=c++23 -O0 -g -fsanitize=address depth.cpp -o depth_asan_gcc && ./depth_asan_gcc 100000
==…==ERROR: AddressSanitizer: stack-overflow on address 0x… (pc 0x… bp 0x… sp 0x… T0)
    #0 0x… in dive(int, int) depth.cpp:12
    #1 0x… in dive(int, int) depth.cpp:19
SUMMARY: AddressSanitizer: stack-overflow depth.cpp:12 in dive(int, int)
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 asan_session.py

The report names the error (stack-overflow), the function (dive) and the lines: line 12, where each call sets up its frame, and line 19, the recursive call, which repeats in every frame below it. AddressSanitizer makes frames larger and the program about twice as slow, so use it to find bugs, not to measure depths.

Key idea

Frame size times depth must fit the stack: about 8 MB for the main thread here, 1 MB by default on Windows, and less for other threads. A depth that grows with the input needs a loop instead.

Checkpoint

Check: Stack frames and the depth limit

  1. Question 1 of 2 A recursive function uses 2,048 bytes of stack per call, and getrlimit reports 8,372,224 bytes. About how many calls deep can it go?

    Type a number.

    A hint

    Divide the limit by the frame size, as depth.cpp does.

    Show the answer: Question 1 of 2

    Answer: 4088 calls (anything from 4083 to 4093 counts)

    8,372,224 ÷ 2,048 = 4,088. The real limit is a few calls lower, because main and the start-up code use some of the stack, as the session found 7,579 against an estimate of 7,583 for 1,104-byte frames.

  2. Question 2 of 2 The Apple clang build of dive used 1,104 bytes per call. About how deep would it get on a Windows executable with the default 1 MB stack?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: About 950 calls

    The same program and the same input can crash on one system and not another. A depth that grows with the input is a risk on every system.

A tail call saves a frame only when the optimiser chooses

A tail call: A call that is the last thing a function does, as in return sum_to(n - 1, total + n);. A compiler may reuse the caller's frame for it, turning the recursion into a loop, but C++ does not require that. is a call that is the last thing a function does: return sum_to(n - 1, total + n); has nothing left to do after the call returns. A compiler is then free to let the called function take over the caller’s frame, so the recursion runs like a loop, in a fixed amount of stack. The C++ standard does not require this, so it happens only where a compiler, at a given optimisation level, decides to do it:

A tail call, and two ways to demand one

C++ (tail.cpp) · tail.cpp

// tail.cpp: adds 1 + 2 + ... + n with a tail call, a call that is the last thing done.
#include <cstdint>
#include <print>
#include <string>

std::int64_t sum_to(std::int64_t n, std::int64_t total) {
    if (n == 0) return total;
    return sum_to(n - 1, total + n);  // nothing is left to do after this call returns
}

int main(int argc, char* argv[]) {
    std::int64_t n = argc > 1 ? std::stoll(argv[1]) : 10;
    std::println("sum_to({}) = {}", n, sum_to(n, 0));
}

C++ (musttail.cpp) · musttail.cpp

// musttail.cpp: the same function; the attribute demands a real tail call (Clang).
#include <cstdint>
#include <print>
#include <string>

std::int64_t sum_to(std::int64_t n, std::int64_t total) {
    if (n == 0) return total;
    [[clang::musttail]] return sum_to(n - 1, total + n);
}

int main(int argc, char* argv[]) {
    std::int64_t n = argc > 1 ? std::stoll(argv[1]) : 10;
    std::println("sum_to({}) = {}", n, sum_to(n, 0));
}

C++ (musttail_gnu.cpp) · musttail_gnu.cpp

// musttail_gnu.cpp: the same demand in GCC's own spelling of the attribute.
#include <cstdint>

std::int64_t sum_to(std::int64_t n, std::int64_t total) {
    if (n == 0) return total;
    [[gnu::musttail]] return sum_to(n - 1, total + n);
}
Ten million tail calls, four builds Python · tail_session.py
from session import run

# Ten million nested calls need far more than the 8 MB stack, unless the compiler turns them into a loop.
run(r"""
clang++ -std=c++23 -O0 tail.cpp -o tail_clang_O0
clang++ -std=c++23 -O1 tail.cpp -o tail_clang_O1
g++-16 -std=c++23 -O1 tail.cpp -o tail_gcc_O1
g++-16 -std=c++23 -O2 tail.cpp -o tail_gcc_O2
./tail_clang_O0 10000000
./tail_clang_O1 10000000
./tail_gcc_O1 10000000
./tail_gcc_O2 10000000
clang++ -std=c++23 -O0 musttail.cpp -o musttail_clang && ./musttail_clang 10000000
g++-16 -std=c++23 -O0 musttail.cpp -o musttail_gcc && ./musttail_gcc 10000000
g++-16 -std=c++23 -O0 -c musttail_gnu.cpp
clang++ -std=c++23 -O0 -c musttail_gnu.cpp
""")

Output

$ clang++ -std=c++23 -O0 tail.cpp -o tail_clang_O0
$ clang++ -std=c++23 -O1 tail.cpp -o tail_clang_O1
$ g++-16 -std=c++23 -O1 tail.cpp -o tail_gcc_O1
$ g++-16 -std=c++23 -O2 tail.cpp -o tail_gcc_O2
$ ./tail_clang_O0 10000000
[stopped by signal 11 (SIGSEGV): a shell reports exit status 139]
$ ./tail_clang_O1 10000000
sum_to(10000000) = 50000005000000
$ ./tail_gcc_O1 10000000
[stopped by signal 11 (SIGSEGV): a shell reports exit status 139]
$ ./tail_gcc_O2 10000000
sum_to(10000000) = 50000005000000
$ clang++ -std=c++23 -O0 musttail.cpp -o musttail_clang && ./musttail_clang 10000000
sum_to(10000000) = 50000005000000
$ g++-16 -std=c++23 -O0 musttail.cpp -o musttail_gcc && ./musttail_gcc 10000000
sum_to(10000000) = 50000005000000
$ g++-16 -std=c++23 -O0 -c musttail_gnu.cpp
$ clang++ -std=c++23 -O0 -c musttail_gnu.cpp
musttail_gnu.cpp:6:7: warning: unknown attribute 'gnu::musttail' ignored [-Wunknown-attributes]
    6 |     [[gnu::musttail]] return sum_to(n - 1, total + n);
      |       ^~~~~~~~~~~~~
1 warning generated.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tail_session.py

Build of sum_to(10000000, 0) What happened
Apple clang, -O0 Crashed: a frame for every call
Apple clang, -O1 Printed the sum: the call became a loop
GCC, -O1 Crashed
GCC, -O2 Printed the sum
[[clang::musttail]], both, -O0 Printed the sum

So a tail-recursive function can work in a release build and crash in a debug build of the same code. The attribute [[clang::musttail]] on the return demands a real tail call even at -O0, and the compiler must report an error when it cannot make one; in this session both Apple clang and GCC 16 accepted it and printed the sum. GCC documents its own spelling, [[gnu::musttail]]: the last two commands show GCC accepting it and Apple clang ignoring it with a warning, unknown attribute. Both spellings are compiler extensions, not standard C++, so portable code that may recurse deeply should be written as a loop.

Checkpoint

Check: Tail calls and the optimiser

  1. Question 1 of 2 Which builds of tail.cpp crashed on sum_to(10000000, 0)?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • GCC at -O1
    • Apple clang at -O0

    Tail-call elimination is an optimisation, not a rule of C++: the same source crashed in two builds and worked in two others.

  2. Question 2 of 2 Is [[clang::musttail]] a way to get a guaranteed tail call in standard C++?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: it is a compiler extension; portable code that recurses deeply is written as a loop

    An unknown attribute is ignored with a warning, as Apple clang did with [[gnu::musttail]], so a program relying on one compiler's attribute can quietly lose the guarantee on another.

Memoisation turns repeated calls into one call each

Depth is not the only cost. The textbook Fibonacci function makes two recursive calls, and they recompute the same values over and over:

Calls counted, plain and memoised C++ · fib.cpp
// fib.cpp: Fibonacci numbers, plain and memoised, counting the calls each one makes.
#include <cstdint>
#include <print>
#include <vector>

std::int64_t calls = 0;

std::int64_t fib(int n) {
    ++calls;
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);  // two calls, and they repeat each other's work
}

// memo[k] holds fib(k) once known, and 0 before that (n < 2 returns before it looks).
std::int64_t fib_memo(int n, std::vector<std::int64_t>& memo) {
    ++calls;
    if (n < 2) return n;
    if (memo[n] == 0) memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);
    return memo[n];
}

int main() {
    for (int n : {10, 20, 30, 40}) {
        calls = 0;
        std::int64_t plain = fib(n);
        std::int64_t plain_calls = calls;

        calls = 0;
        std::vector<std::int64_t> memo(n + 1, 0);
        std::int64_t remembered = fib_memo(n, memo);

        std::println("fib({}) = {} ({}): {} calls plain, {} calls memoised", n, plain, remembered, plain_calls, calls);
    }
}

Output

fib(10) = 55 (55): 177 calls plain, 19 calls memoised
fib(20) = 6765 (6765): 21891 calls plain, 39 calls memoised
fib(30) = 832040 (832040): 2692537 calls plain, 59 calls memoised
fib(40) = 102334155 (102334155): 331160281 calls plain, 79 calls memoised

Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined fib.cpp -o fib && ./fib

fib(40) made 331,160,281 calls, and every increase of n by 10 multiplied the count by more than 120: the plain function is exponential, and its call count grows like the Fibonacci numbers themselves. memoisation: Keeping the result of a call the first time it is computed and returning the stored result when the same arguments come again, so that repeated work is done only once. keeps each answer the first time it is computed, here in a std::vector passed by reference, and returns the stored answer afterwards. The memoised fib(40) made 79 calls, 2n − 1, and its depth is still about n; the GCC build counted the same calls.

n Plain calls Memoised calls
10 177 19
20 21,891 39
30 2,692,537 59
40 331,160,281 79

Recursion depth and the number of calls are different costs, and this lesson’s measurements tell them apart: Fibonacci needed memoisation for the calls, and the deep sum needed a loop for the depth.

Big Number Calculator Check factorials beyond what an int64 holds: 20! is 2,432,902,008,176,640,000 and still fits, but 21! does not.

Checkpoint

Check: Memoisation

  1. Question 1 of 2 The memoised fib made 2n − 1 calls for each n of the run. How many calls would fib_memo(50, memo) make?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 99 calls

    2 × 50 − 1 = 99. The recorded run showed the pattern: 19, 39, 59 and 79 calls for n = 10, 20, 30 and 40.

  2. Question 2 of 2 fib_memo takes std::vector<std::int64_t>& memo. What would go wrong with std::vector<std::int64_t> memo, by value?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each call would get its own copy, so answers stored by one call would be lost to the others, and every call would copy the vector

    This is the in-out parameter of the parameter-passing lesson: the function must change the caller's object, so it takes a non-const reference.

Practise: flood fill, recursively and with your own stack

A flood fill colours a whole region, as a paint program’s bucket tool does: start at one cell, colour it, then fill each open neighbour. The recursive version is short, and its depth grows with the size of the region. Write it, and then an iterative version that keeps the cells still to visit in a std::vector of its own, on the heap, where a few million entries fit easily.

The exercise files

exercise/fill.h

// fill.h: the two functions of the exercise. Keep these declarations as they are.
#pragma once
#include <string>
#include <vector>

// A grid is a list of rows of the same length: '.' is an open cell and '#' a wall.
using Grid = std::vector<std::string>;

// Both functions fill the open region that contains the cell (row, col): every '.' that
// can be reached from it by steps up, down, left or right through '.' cells becomes
// 'o'. They change nothing when (row, col) is outside the grid or is not '.'.
// fill_recursive calls itself for each of the four neighbours; fill_iterative keeps the
// cells still to visit in a std::vector instead.
void fill_recursive(Grid& grid, int row, int col);
void fill_iterative(Grid& grid, int row, int col);

fill.cpp (starter) · exercise/fill.cpp

// fill.cpp: the starter. Both functions compile but fill nothing yet.
#include "fill.h"

void fill_recursive(Grid& grid, int row, int col) {
    (void)grid;  // remove these three lines when you use the parameters
    (void)row;
    (void)col;
}

void fill_iterative(Grid& grid, int row, int col) {
    (void)grid;
    (void)row;
    (void)col;
}

exercise/fill_test.cpp

// fill_test.cpp: the public tests. Build them with your fill.cpp. With --large, both
// functions also fill one open region of 2,000 by 2,000 cells.
#include <cstdio>
#include <cstdlib>
#include <print>
#include <string_view>

#include "fill.h"

int checks = 0;
int failures = 0;

void check(bool passed, std::string_view what) {
    ++checks;
    if (!passed) {
        ++failures;
        std::println("FAIL: {}", what);
    }
}

using FillFunction = void (*)(Grid&, int, int);  // either of the two functions

void small_tests(FillFunction fill, std::string_view name) {
    Grid rooms{"..#..", ".##..", "....#", "#####", "..#.."};
    fill(rooms, 0, 0);
    check(rooms == Grid{"oo#oo", "o##oo", "oooo#", "#####", "..#.."}, std::string(name) + ": the region around the top left");
    fill(rooms, 4, 0);
    check(rooms[4] == "oo#..", std::string(name) + ": a second region, below the wall");
    Grid corners{".#", "#."};
    fill(corners, 0, 0);
    check(corners == Grid{"o#", "#."}, std::string(name) + ": no diagonal steps");
    Grid walls{"##", "#."};
    fill(walls, 0, 0);
    fill(walls, -1, 5);
    check(walls == Grid{"##", "#."}, std::string(name) + ": a wall or a cell outside the grid changes nothing");
}

long long filled_cells(const Grid& grid) {
    long long count = 0;
    for (const auto& row : grid) {
        for (char c : row) count += c == 'o';
    }
    return count;
}

void large_test(FillFunction fill, std::string_view name) {
    Grid big(2000, std::string(2000, '.'));
    fill(big, 1000, 1000);
    std::println("large grid, {}: {} cells filled", name, filled_cells(big));
    // print it now: if the next test crashes, the line is already out
    std::fflush(stdout);
}

int main(int argc, char* argv[]) {
    small_tests(fill_iterative, "fill_iterative");
    small_tests(fill_recursive, "fill_recursive");
    if (failures == 0) {
        std::println("All {} checks passed", checks);
    } else {
        std::println("{} of {} checks failed", failures, checks);
    }
    std::fflush(stdout);
    if (argc > 1 && std::string_view{argv[1]} == "--large") {
        large_test(fill_iterative, "fill_iterative");
        large_test(fill_recursive, "fill_recursive");
    }
    return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
The tests with the starter file Python · fill_session.py
from session import run

# The public tests built with the starter file, which fills nothing yet.
run(r"""
cd exercise && clang++ -std=c++23 -Wall -Wextra fill.cpp fill_test.cpp -o fill_test && ./fill_test
""")

Output

$ cd exercise && clang++ -std=c++23 -Wall -Wextra fill.cpp fill_test.cpp -o fill_test && ./fill_test
FAIL: fill_iterative: the region around the top left
FAIL: fill_iterative: a second region, below the wall
FAIL: fill_iterative: no diagonal steps
FAIL: fill_recursive: the region around the top left
FAIL: fill_recursive: a second region, below the wall
FAIL: fill_recursive: no diagonal steps
6 of 8 checks failed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fill_session.py

  1. Write fill_recursive: return at once when the cell is outside the grid or is not '.'; otherwise set it to 'o' and call fill_recursive for the four neighbours.
  2. Write fill_iterative: put the first cell in a std::vector of pending cells. Until that vector is empty, remove its last cell (read it with back(), then call pop_back()); ignore a cell that lies outside the grid or holds anything but '.', and colour any other one and push its four neighbours onto the vector.
  3. Build with clang++ -std=c++23 -Wall -Wextra fill.cpp fill_test.cpp -o fill_test and run ./fill_test until it prints All 8 checks passed.
  4. Run ./fill_test --large. It fills one open grid of 2,000 by 2,000 cells with each function. Expect fill_iterative to report 4,000,000 cells filled, and fill_recursive to crash with a stack overflow, with either compiler at -O0 or -O2: four million cells are far more frames than 8 MB can hold.

A first hint for step 2: a small struct such as struct Cell { int row; int col; }; makes the vector easy to read, std::vector<Cell> pending{{row, col}};.

Once both fills pass, take the questions on base cases, frames, depth, tail calls and memoisation. The first ones probe the idea you have answered worst so far, and the note on each option says which part of the lesson it rests on:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 What two parts does every recursive function in trace.cpp have?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: A base case answered directly, and a recursive case that calls the function on a smaller problem

    The base case stops the recursion; the recursive case moves towards it. Missing either one is a bug.

  2. Question 2 of 10 hanoi(3, …) made 7 moves, 2³ − 1. How many moves does hanoi(5, …) make?

    Type a number.

    Show the answer: Question 2 of 10

    Answer: 31 moves

    Each disc count doubles the moves and adds one: 2⁵ − 1 = 31. The depth grows only by one per disc, so at most 6 calls wait at once.

  3. Question 3 of 10 What did both compilers report for count_down, which has no base case?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: A -Winfinite-recursion warning: every path through the function calls itself

    Warnings catch the missing base case, not the one that some inputs skip, so the shrinking quantity still has to be checked by hand or by tests.

  4. Question 4 of 10 Which of these functions reaches its base case for every int n >= 0?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: int f(int n) { return n == 0 ? 0 : 1 + f(n / 2); }

    For each function, follow the shrinking value from a few starting points, 0, 1, 4 and 11, and check that each one lands on the base case.

  5. Question 5 of 10 What does a stack frame hold?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: The parameters and local variables of one call that has not returned, saved registers and the address to return to

    A call pushes a frame and its return pops it, so the stack holds exactly the calls still running.

  6. Question 6 of 10 Why build with -fsanitize=address when a recursive program crashes with a bare SIGSEGV?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: AddressSanitizer reports a stack-overflow and names the function and lines, here dive at lines 12 and 19

    Both compilers' AddressSanitizer builds gave the same report: ERROR: AddressSanitizer: stack-overflow, with the frames of dive.

  7. Question 7 of 10 Which return statement is a tail call?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: return sum_to(n - 1, total + n);

    sum_to carries the running total in a parameter, which is what moves the work before the call and makes the call the last step.

  8. Question 8 of 10 A tail-recursive function handles a million-item list in the release build but crashes in the debug build of the same code. What is the lasting fix?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Write it as a loop, which needs one frame whatever the optimiser does

    The session showed one source working in two builds and crashing in two others. Code that must not crash cannot depend on an optimisation the language does not promise.

  9. Question 9 of 10 How did the number of calls of the plain fib grow in the recorded run?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: Exponentially: more than 120 times as many calls for every 10 added to n

    The plain function's call count grows like the Fibonacci numbers themselves, because both recursive calls recompute everything below them.

  10. Question 10 of 10 Which function of this lesson gains from memoisation?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: fib, whose two recursive calls ask for the same values again and again

    Memoise when the same arguments come back; remove recursion when the depth is the problem.

Interview questions

Fresher · What is a stack frame, and what does a recursive call cost?

A stack frame is the block of the call stack that belongs to one call that has not returned yet: its parameters, its local variables, saved registers and the address to return to. Each call pushes a frame and each return pops it, so a recursion n levels deep holds n frames at once. That costs memory proportional to the depth, from a limited stack (8,372,224 bytes for the main thread on the Mac of this lesson, 1 MB by default for Windows executables), plus a little time per call. Too deep, and the program crashes with a stack overflow; C++ does not guarantee that tail calls reuse a frame.

Follow-up questions here tend to probe threads and the cost of a fix. Pick your answers before you read the note on each option:

Checkpoint

Interview round: two follow-up questions

  1. Question 1 of 2 A follow-up: a recursion that reached 7,000 calls in main crashes after a few hundred when the same code runs in a std::thread on macOS. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Threads other than the main thread get 512 KB of stack by default, a sixteenth of the main thread's 8 MB

    Apple's threading guide gives 512 KB for secondary threads. A depth that worked in main is no promise for a thread, which is one more reason to bound recursion depth or use a loop.

  2. Question 2 of 2 A second follow-up: memoisation cut fib(40) from 331,160,281 calls to 79. Does it also remove the risk of a stack overflow for a very large n?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: the calls drop to 2n − 1, but the depth is still about n, so a large enough n still overflows; a loop with two variables needs neither

    Two costs, two fixes: memoisation for repeated calls, an explicit stack or a loop for depth.

Key takeaways

  • A recursive function needs a base case that every input reaches; name what shrinks on each call and where it stops.
  • Every pending call holds a stack frame with its own parameters and locals; the frames come off in reverse order as the calls return.
  • Frame size times depth must fit the stack: dive with 1 KB of locals reached 7,579 calls with Apple clang and 7,805 with GCC, a few calls short of the estimate from getrlimit.
  • Tail calls become loops only when the optimiser chooses: Apple clang from -O1, GCC from -O2 here. [[clang::musttail]] demands it, but it is not standard C++.
  • Memoisation fixes repeated work (fib(40): 331,160,281 calls down to 79); an explicit stack on the heap fixes depth.

Terms in this lesson

base case
The input a recursive function answers without calling itself, such as factorial(1), which is 1. Every chain of recursive calls must reach one, or the calls go on until the stack overflows.
memoisation
Keeping the result of a call the first time it is computed and returning the stored result when the same arguments come again, so that repeated work is done only once.
recursive function
A function that calls itself, directly or through other functions, to solve a smaller case of its own problem, such as factorial(n) calling factorial(n - 1). It needs a base case that every input reaches.
stack frame
The block of the call stack that belongs to one call that has not returned yet: its parameters, its local variables, saved registers and the address to return to. A call pushes it and the return pops it.
stack overflow
What happens when the calls still running need more stack than the thread has: the program is stopped, on POSIX systems with SIGSEGV. AddressSanitizer reports it as a stack-overflow with the function that caused it.
tail call
A call that is the last thing a function does, as in return sum_to(n - 1, total + n);. A compiler may reuse the caller's frame for it, turning the recursion into a loop, but C++ does not require that.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.