Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Стек вызовов и рекурсия

This lesson is not translated yet – the Russian original is below.

Стек – самая используемая область памяти в C++ и при этом самая незаметная: вы никогда не пишете код, который с ним работает, всё делает компилятор. Понимать его устройство нужно ровно по трём причинам: висячие указатели, переполнение стека и чтение стектрейса в отладчике.

Зачем вообще стек

Функция вызывает функцию, та – ещё одну. У каждой свои параметры и локальные переменные, и каждой нужно знать, куда вернуться. Порядок здесь всегда один: последняя вызванная завершается первой. Такая дисциплина называется LIFO и идеально ложится на структуру «стек» – достаточно одного указателя на вершину.

void C() { /* здесь на стеке кадры A, B, C */ }
void B() { C(); }
void A() { B(); }

int main() { A(); }

Кадр вызова

При каждом вызове на стек кладётся кадр (stack frame) – блок, в котором лежит всё, что нужно одному конкретному вызову:

кадр main кадр A кадр B кадр C ← вершина стека ← дно параметры, локальные, адрес возврата

Внутри кадра, в разных сочетаниях в зависимости от платформы:

  • аргументы, не поместившиеся в регистры;
  • адрес возврата – куда передать управление после return;
  • сохранённое значение регистра базы кадра предыдущей функции;
  • локальные переменные.

Выделение кадра – это сдвиг одного регистра на нужное число байт. Поэтому локальная переменная в C++ практически бесплатна, и поэтому же размер кадра должен быть известен на этапе компиляции: массив int a[n] с переменным n стандартом не предусмотрен.

Стектрейс в отладчике или в дампе – это просто перечисление кадров от вершины к дну. Первая строка – где упало, последняя – main. Читать его снизу вверх обычно понятнее: видно путь, которым программа туда пришла.

Кадр умирает вместе с вызовом

Главное следствие: всё, что лежало в кадре, перестаёт существовать при возврате из функции. Память не обнуляется и не помечается – просто вершина стека сдвигается обратно, и следующий же вызов положит туда свои данные.

int* Broken() {
    int x = 42;
    return &x;             // адрес есть, объекта уже нет
}

const std::string& AlsoBroken() {
    std::string s = "hello";
    return s;              // то же самое, только со ссылкой
}

Оба варианта компилируются. Оба дают неопределённое поведение, причём коварное: сразу после возврата по этому адресу ещё лежат нужные байты, и в маленьком тесте всё «работает». Ломается после первого же следующего вызова.

Правило без исключений: не возвращайте указатель или ссылку на локальную переменную. Возвращать по значению – можно и нужно: компилятор почти всегда строит результат прямо в кадре вызывающего, копирования не происходит.

Рекурсия

Рекурсивная функция вызывает сама себя – напрямую или через другие функции. Никакой особой поддержки для этого не требуется: кадры просто складываются один на другой.

int Factorial(int n) {
    if (n == 0) {
        return 1;          // база: рекурсия заканчивается
    }

    return Factorial(n - 1) * n;
}

Вызовы могут образовывать не цепочку, а дерево:

int Fibonacci(int n) {
    if (n < 2) {
        return n;
    }

    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

У любой корректной рекурсии есть две части: база – случай, который решается без вызова себя, и шаг, который сводит задачу к меньшей и обязан к базе приближаться. Если шаг не уменьшает задачу, получается бесконечная рекурсия.

Цена рекурсии в C++

Здесь C++ отличается от функциональных языков, где рекурсия – основной способ выражать циклы.

Глубина рекурсии ограничена размером стека. Миллион вложенных вызовов – это миллион кадров, и программа упадёт с переполнением стека задолго до результата. Хвостовую рекурсию (когда рекурсивный вызов – последнее действие функции) компилятор умеет превращать в цикл, но стандарт этого не гарантирует: с -O2 превратит, в отладочной сборке – нет.

int SumTo(int n, int acc = 0) {
    if (n == 0) return acc;
    return SumTo(n - 1, acc + n);   // хвостовой вызов: может стать циклом, а может и не стать
}
Задача Чем решать
Обход дерева, разбор выражений, backtracking рекурсия – структура задачи сама рекурсивна
Проход по массиву, накопление суммы цикл – глубина была бы равна числу элементов
Глубина заранее неизвестна и может быть большой цикл с явным стеком в куче

У наивного Fibonacci выше экспоненциальное число вызовов: Fibonacci(45) считается секундами. Проблема не в рекурсии как таковой, а в том, что одни и те же значения пересчитываются заново. Кэш посчитанного или обычный цикл превращают её в линейную.