JavaRush /Курсы /C++ SELF /Стек вызовов

Стек вызовов

C++ SELF
16 уровень , 0 лекция
Открыта

1. Модель выполнения и стек вызовов

Когда вы пишете doSomething();, кажется, что компьютер просто «перепрыгнул» в другое место программы, сделал дело и вернулся. Но как именно он возвращается? Откуда он «помнит», куда вернуться? И почему локальные переменные внутри функции исчезают после return, как будто их «выключили из реальности»?

Сейчас мы построим простую и рабочую модель выполнения, которая потом очень пригодится (особенно когда мы перейдём к рекурсии, но пока без подробностей).

На уровне новичка нам достаточно знать: выполнение программы — это одна «дорожка», которая идёт сверху вниз, и иногда делает «ответвления» в функции. Чтобы эти ответвления не превратились в лабиринт без выхода, у процесса выполнения есть специальная структура памяти — стек вызовов.

Стек вызовов: стопка тарелок и правило LIFO

Слово «стек» (stack) — это буквально «стопка». Представьте, что каждый вызов функции кладёт на стол тарелку с бумажкой: «я — вызов функции foo(), вот мои локальные переменные, вот мои параметры, и вот адрес — куда вернуться». Когда функция заканчивается, тарелка убирается.

Важно, что убирается именно последняя положенная тарелка. Это правило называется LIFO: Last In, First Out — «последний вошёл, первый вышел».

Это правило идеально подходит для вызовов функций: если main() вызвал f(), а f() вызвал g(), то логично, что сначала должна закончиться g(), потом продолжится f(), и только потом продолжится main().

Кадр стека: что хранится «на тарелке»

В этом разделе мы чуть «разберём тарелку на запчасти». Не на уровне процессора и регистров (это отдельная вселенная), а на уровне модели, которая помогает писать код без мистики.

Кадр стека — это данные, которые относятся к одному конкретному вызову функции: её параметры, локальные переменные и самое важное — точка возврата (куда продолжать выполнение после return).

Удобно представить кадр стека как таблицу:

Часть кадра стека Что это Зачем нужно
Параметры то, что пришло в функцию чтобы функция знала, с чем работать
Локальные переменные то, что объявили внутри {} временное «рабочее место» функции
Точка возврата «адрес» в вызывающей функции чтобы продолжить выполнение после завершения вызова
Служебное «внутренности» нам не нужно сейчас это моделировать

Ключевая мысль: у каждого вызова функции — свой отдельный кадр стека. Даже если это два вызова одной и той же функции, это два разных набора локальных переменных.

Мини-демонстрация: порядок входа и выхода из функций

Сейчас мы сделаем самый честный эксперимент: просто напечатаем start/end и посмотрим, в каком порядке они появляются. Этот пример маленький, но он отлично фиксирует правило LIFO.

#include <iostream>

void h() {
    std::cout << "h: start\n"; // h: start
    std::cout << "h: end\n";   // h: end
}

void g() {
    std::cout << "g: start\n"; // g: start
    h();
    std::cout << "g: end\n";   // g: end
}

void f() {
    std::cout << "f: start\n"; // f: start
    g();
    std::cout << "f: end\n";   // f: end
}

int main() {
    f();
}

Если запустить, вы увидите примерно такое:

f: start
g: start
h: start
h: end
g: end
f: end

Это и есть «стопка тарелок» в действии: пока h() не закончит работу, g() «стоит на паузе» ровно на строке h();, а f() — на строке g();.

Точка возврата: почему вызывающая функция «замирает» на месте вызова

Почти у всех новичков в голове сначала живёт странная идея: «функции как будто работают параллельно». Это нормально — мозг экономит энергию и дорисовывает мир как может. Но в обычном однопоточном коде всё проще: параллельности нет, выполнение идёт строго последовательно.

Когда выполняется строка:

int b = inner(a + 1);

вызывающая функция (outer) должна помнить, что после завершения inner(...) нужно продолжить с «хвоста» — то есть присвоить результат в b, а затем выполнить следующие строки. Поэтому в кадре стека хранится точка возврата.

Посмотрим на пример:

#include <iostream>

int inner(int x) {
    std::cout << "inner: x=" << x << '\n'; // inner: x=11
    return x * 2;
}

int outer(int a) {
    std::cout << "outer: before\n";        // outer: before
    int b = inner(a + 1);
    std::cout << "outer: after\n";         // outer: after
    return b + 3;
}

int main() {
    std::cout << outer(10) << '\n';        // 25
}

Здесь outer буквально «останавливается» на вызове inner, ждёт return, получает значение и продолжает.

Жизнь локальных переменных: они живут ровно до конца вызова

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

В терминах стандарта C++ это связано с понятием automatic storage duration (автоматическая длительность хранения) — так описываются типичные локальные переменные, которые живут в пределах блока и уничтожаются при выходе из него.

Пример:

#include <iostream>

int add10(int x) {
    int y = x + 10; // локальная переменная
    return y;
}

int main() {
    std::cout << add10(1) << '\n'; // 11
    std::cout << add10(5) << '\n'; // 15
}

Тут нет «памяти между вызовами»: y создаётся заново при каждом заходе в add10(). Она не «копится», не «стареет», не «вспоминает прошлую жизнь».

Два вызова одной функции — это два разных мира локальных переменных

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

Сделаем маленький трюк: выведем значение локальной переменной. (Мы ещё не изучали указатели глубоко, поэтому не будем уходить в адреса — сама логика и без них хорошо видна.)

#include <iostream>

void demo(int x) {
    int local = x * 10;
    std::cout << "local=" << local << '\n'; // local=20 (потом local=50)
}

int main() {
    demo(2);
    demo(5);
}

Даже если не смотреть на адреса в памяти, идея та же: каждый вызов создаёт собственный local.

2. Практический приём: трассировка вызовов с отступами

В этом разделе мы сделаем штуку, которую любят и преподаватели, и отладчики: будем печатать вход/выход из функций так, чтобы глазами было видно глубину вызовов. Это почти как «ручной стек», только без магии.

Сделаем две маленькие функции trace_enter и trace_exit, а глубину будем хранить в переменной depth, которую передаём по ссылке (чтобы изменения видели все участники цепочки вызовов).

#include <iostream>
#include <string>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

Обратите внимание: depth — это не «часть стека», это просто наш учебный фонарик. Мы подсвечиваем то, что и так происходит внутри программы.

3. Мини-проект: консольный список дел и тонкий main

С этого момента примеры будем связывать в одно маленькое приложение, которое легко расширять. Мы не будем делать ничего сверхсложного: просто список строк (std::vector<std::string>) и команды add/list/exit. Главная цель — увидеть, как вызовы функций «живут» в стеке и почему main() лучше держать тонким.

Каркас приложения

Начнём с каркаса:

#include <iostream>
#include <string>
#include <vector>

void run_app() {
    std::vector<std::string> tasks;
    std::cout << "TodoApp started\n"; // TodoApp started
}

int main() {
    run_app();
}

Пока скучно — но это хороший «скелет»: main() не занимается бизнес-логикой, а передаёт управление run_app().

Добавляем команды и смотрим на стек через трассировку

Теперь сделаем функции: показать меню, прочитать команду, обработать команду. И добавим трассировку, чтобы увидеть, как run_app() вызывает другие функции.

#include <iostream>
#include <string>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

void print_menu(int& depth) {
    trace_enter("print_menu", depth);
    std::cout << "Commands: add, list, exit\n";
    trace_exit("print_menu", depth);
}

std::string read_command(int& depth) {
    trace_enter("read_command", depth);
    std::string cmd;
    std::getline(std::cin, cmd);
    trace_exit("read_command", depth);
    return cmd;
}

Смысл простой: когда вы увидите вывод с отступами, у вас появится «рентген» для понимания вложенности вызовов.

Соединяем всё в run_app и наблюдаем кадры стека

Сейчас мы соберём цепочку: main()run_app()print_menu()read_command(). Даже без рекурсии стек уже работает и уже важен.

#include <iostream>
#include <string>
#include <vector>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

void print_menu(int& depth) {
    trace_enter("print_menu", depth);
    std::cout << "Commands: add, list, exit\n"; // Commands: add, list, exit
    trace_exit("print_menu", depth);
}

std::string read_command(int& depth) {
    trace_enter("read_command", depth);
    std::string cmd;
    std::getline(std::cin, cmd);
    trace_exit("read_command", depth);
    return cmd;
}

void run_app() {
    int depth = 0;
    trace_enter("run_app", depth);

    std::vector<std::string> tasks;

    print_menu(depth);
    std::cout << "Enter command: ";            // Enter command:
    std::string cmd = read_command(depth);

    std::cout << "You typed: " << cmd << '\n'; // например: You typed: add

    trace_exit("run_app", depth);
}

int main() {
    run_app();
}

Если вы введёте add, то увидите примерно такую картину:

-> run_app
  -> print_menu
Commands: add, list, exit
  <- print_menu
Enter command:   -> read_command
  <- read_command
You typed: add
<- run_app

Это визуализация стека: пока мы в read_command(), предыдущие функции не исчезли — они «ждут» продолжения в своей точке возврата.

4. Что реально означает return

Здесь важно сделать паузу и проговорить словами, что делает return. Он не «телепортирует» значение. Он завершает текущую функцию, после чего её кадр стека снимается, а выполнение продолжается в вызывающей функции по сохранённой точке возврата.

Практически это означает две вещи. Во‑первых, всё, что было локальным в текущей функции, больше не существует: переменные ушли вместе с кадром. Во‑вторых, вызывающая функция снова становится «активной», и выполнение продолжается со строки сразу после вызова.

Именно поэтому локальные переменные мы воспринимаем как «временные»: они удобны, но их нельзя пытаться использовать после выхода из функции, потому что они не переживают завершение вызова. Связь локальных объектов с автоматической длительностью хранения и выходом из области видимости — один из базовых кирпичиков модели выполнения.

5. Визуальная схема стека во время вложенных вызовов

Чтобы окончательно зафиксировать, нарисуем простую схему. Это не про «точный байтовый layout», а про смысл.

flowchart TD
    A["main()"] --> B["run_app()"]
    B --> C["print_menu()"]
    B --> D["read_command()"]

И в момент, когда мы находимся внутри read_command(), «стопка» выглядит примерно так:

[ top ]
read_command frame
run_app frame
main frame
[ bottom ]

Когда read_command() делает return, её кадр снимается, и мы снова «находимся» в run_app().

6. Типичные ошибки

Ошибка №1: ожидать, что вызывающая функция продолжит выполняться «параллельно».
Иногда кажется, что run_app() может «успеть сделать ещё что-то», пока выполняется read_command(). В однопоточном коде это не так: вызвавшая функция стоит на паузе до return. Если держать в голове «одна дорожка выполнения» и «точка возврата», путаницы становится меньше.

Ошибка №2: считать, что локальные переменные «помнят» значение между вызовами.
Локальная переменная принадлежит конкретному кадру стека. Новый вызов — новый кадр — новые локальные переменные. Если вам нужно хранить состояние между вызовами, это должна быть переменная «выше» (например, в run_app()) или параметр/контейнер, который передаётся между функциями.

Ошибка №3: путать порядок вызова и порядок завершения.
Вызовы идут «вглубь», а завершения — «наружу». Поэтому при цепочке mainrun_appread_command завершится сначала read_command, потом продолжится run_app, и только потом завершится main. Это правило LIFO — «последний вошёл, первый вышел» — полезно повторять как заклинание, только без мистики.

Ошибка №4: использовать return как «просто выход», не понимая последствий.
Ранний return часто делает код проще, но важно помнить: он завершает функцию прямо сейчас, кадр снимается, локальные переменные уничтожаются, а вызывающая функция продолжает выполнение. Если вы до return не обновили нужное состояние (например, не записали результат в контейнер, который живёт выше), то «поздно пить боржоми».

Ошибка №5: пытаться объяснить стек через «глобальную магическую память».
Иногда новички говорят: «ну переменные где-то там лежат». Полезнее держать конкретную модель: у каждого вызова есть свой кадр стека с параметрами, локальными переменными и точкой возврата. Это объясняет и порядок выполнения, и жизнь переменных, и то, почему вложенность вызовов работает предсказуемо.

1
Задача
C++ SELF, 16 уровень, 0 лекция
Недоступна
Стек звонков
Стек звонков
1
Задача
C++ SELF, 16 уровень, 0 лекция
Недоступна
Маршрут возврата
Маршрут возврата
1
Задача
C++ SELF, 16 уровень, 0 лекция
Недоступна
Отступы глубины
Отступы глубины
1
Задача
C++ SELF, 16 уровень, 0 лекция
Недоступна
Проверка ввода
Проверка ввода
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ