1. Введение
Когда вы пишете программу, кажется, что она «просто выполняется сверху вниз». Но как только появляются функции, особенно если одна функция вызывает другую, возникает практический вопрос: где хранить локальные переменные каждой функции так, чтобы они не мешали друг другу? Нужен механизм, который автоматически создаёт «контекст вызова» и так же автоматически его убирает.
И вот тут появляется стек. Стек — это модель (и в реальности часто структура памяти), которая идеально подходит под поведение вызовов функций: вызвали функцию — «положили» её контекст, завершили — «сняли» его. Не потому что авторы языка были романтиками, а потому что это удобно, быстро и предсказуемо.
Представьте себе стопку тарелок в столовой. Вы кладёте сверху новую тарелку, а забираете — тоже сверху. Если попытаться вытащить тарелку из середины, столовая превращается в цирк. Вот вызовы функций — это как раз та самая стопка.
scope и границы жизни локальных объектов
В программировании очень хочется, чтобы «то, что не нужно сейчас», не существовало сейчас. Причём не только в вашем сознании, но и в реальном выполнении программы. scope — это первая линия обороны: он определяет, где имя переменной доступно. Но в нашем сегодняшнем контексте он ещё важнее: для большинства локальных объектов scope совпадает с тем, когда объект создаётся и когда уничтожается.
Давайте посмотрим максимально простую ситуацию: вложенный блок.
#include <iostream>
int main() {
int outer = 10;
{
int inner = 20;
std::cout << outer << " " << inner << '\n'; // 10 20
} // inner "заканчивается" здесь
std::cout << outer << '\n'; // 10
}
Внутри блока создаётся inner. Сразу после } эта переменная больше не существует как локальный объект. Не «становится невидимой», а именно перестаёт существовать (в рамках модели языка для automatic-объектов). Это и есть практический смысл: вышли из блока — локальные автоматические объекты блока закончились.
Важно: outer живёт дальше, потому что его блок — внешний.
2. Стек вызовов: что происходит при вызове функции
Обычно новички воспринимают вызов функции почти как телепорт: «прыгнули в функцию — вернулись обратно». Но у “прыжка” есть багаж: параметры, локальные переменные, адрес возврата (куда вернуться). Всё это вместе можно представить как кадр стека (stack frame).
Главная идея: каждый вызов функции получает свой собственный набор локальных переменных. Если вы вызвали функцию два раза — это два независимых набора. Если функция вызывает саму себя (рекурсия) — наборов будет столько, сколько уровней рекурсии одновременно “живут”.
Мини-демо:
#include <iostream>
void demo() {
int x = 0; // локальная automatic переменная
++x;
std::cout << x << '\n';
}
int main() {
demo(); // 1
demo(); // 1
}
Комментарии к выводу: оба раза печатается 1, потому что x каждый раз создаётся заново при входе в demo().
Если хочется «пощупать» идею ещё сильнее, можно сделать трассировку:
#include <iostream>
void foo() {
int a = 1;
std::cout << "foo: a=" << a << '\n'; // foo: a=1
}
void bar() {
int b = 2;
std::cout << "bar: b=" << b << '\n'; // bar: b=2
foo();
std::cout << "bar end\n"; // bar end
}
int main() {
bar();
}
Пока выполняется bar(), внутри него вызывается foo(). Значит, в какой-то момент одновременно существуют локальные данные и bar, и foo, но в разных кадрах стека.
Можно нарисовать это схемой:
flowchart TD
A["main() frame"] --> B["bar() frame"]
B --> C["foo() frame"]
C --> B
B --> A
Смысл стрелок тут не “передача управления навсегда”, а “вложенность”: foo() живёт внутри выполнения bar().
4. Выход из scope: } и ранний return
Когда вы только начинаете писать функции, return воспринимается как «выход из функции». Но в модели времени жизни объектов важнее другое: return — это выход из блока функции, то есть завершение её scope. А значит, все локальные automatic-объекты функции завершаются при любом выходе: хоть вы дошли до конца, хоть сделали ранний return.
Пример с ранним выходом:
#include <iostream>
#include <string>
void greet(bool loud) {
std::string msg = "hello";
if (!loud) {
std::cout << msg << '\n'; // hello
return; // выходим раньше
}
std::cout << msg << "!!!\n"; // hello!!!
}
int main() {
greet(false);
greet(true);
}
Здесь важно не то, что msg — строка (хотя строка — хороший пример “не простого” типа), а то, что она не переживает выход из greet. Ранний return не «обманывает язык» и не оставляет локальные переменные “болтаться в памяти”. Как только вы вышли из scope, локальные automatic-объекты закончились.
На этом месте многие чувствуют облегчение: можно писать код с ранними return, не боясь, что «переменные не уберутся». Это одна из причин, почему ранний выход любят: меньше вложенных if, а жизнь локальных объектов всё равно корректна.
Вложенные блоки как инструмент: уменьшаем время жизни “тяжёлых” переменных
На первых неделях обучения вы обычно пишете так: «объявлю всё наверху функции, чтобы было видно». Это естественно, но потом приводит к коду, где переменные живут слишком долго: они уже не нужны, а всё ещё существуют. Для int это почти не проблема, но для объектов вроде std::vector или больших строк — это уже заметнее, особенно в больших функциях.
Вложенный блок {} позволяет сказать: “эта часть данных нужна только здесь”.
Представим, что мы читаем строку команды пользователя и временно разбираем её на токены. Токены нужны только на время обработки одной команды, значит можно ограничить их жизнь блоком.
5. Практический пример: CLI-приложение и жизнь переменных
Чтобы примеры не были “в вакууме”, давайте начнём мини-приложение, которое будем развивать. Пусть это будет простейший список дел TodoLite: можно добавить задачу и показать список.
Модель данных (очень скромная):
#include <string>
struct Task {
std::string title;
bool done = false;
};
Теперь функция, которая печатает задачи:
#include <iostream>
#include <vector>
void print_tasks(const std::vector<Task>& tasks) {
std::cout << "Tasks: " << tasks.size() << '\n';
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i].title << '\n';
}
}
А теперь самое интересное: обработка одной команды. Мы будем читать строку, парсить её через std::istringstream (вы это уже проходили в дне про stringstream), и токены будем держать только внутри вложенного блока.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
void process_line(std::vector<Task>& tasks, const std::string& line) {
std::string cmd;
{ // <-- ограничиваем жизнь парсера и временных строк
std::istringstream iss(line);
iss >> cmd;
if (cmd == "add") {
std::string title;
std::getline(iss, title); // забираем остаток строки
if (!title.empty() && title[0] == ' ') title.erase(0, 1);
tasks.push_back(Task{title, false});
std::cout << "Added: " << title << '\n';
}
} // <-- iss и title (если был) заканчиваются здесь
if (cmd == "list") {
// cmd всё ещё живёт, потому что объявлен снаружи блока
print_tasks(tasks);
}
}
Обратите внимание на “архитектуру жизни” переменных:
cmd объявлен снаружи блока, потому что он нужен и после блока (чтобы проверить "list").
iss и title живут внутри блока, потому что они нужны только там, где мы реально парсим строку.
Это очень “взрослый” приём, хотя выглядит почти по-детски: просто лишние {}. Но в больших функциях это резко повышает читаемость: вы видите, где заканчивается этап, и вместе с этапом заканчиваются временные переменные.
Теперь соберём минимальный main():
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks;
std::string line;
while (std::getline(std::cin, line)) {
if (line == "exit") break;
process_line(tasks, line);
}
}
Локальные переменные tasks и line живут до конца main() — потому что их scope это весь main. А все временные объекты парсинга живут коротко — потому что мы так спроектировали блоки.
6. Рекурсия и стек: почему глубокая рекурсия ограничена ресурсами
Рекурсия часто кажется магией: функция вызывает саму себя, и вроде бы всё работает. Но стек тут показывает свою “цену”: каждый рекурсивный вызов создаёт новый кадр стека, а значит, новые локальные переменные и новый контекст.
Сделаем небольшой пример, который печатает глубину:
#include <iostream>
void depth(int d) {
int local = d;
std::cout << "d=" << local << '\n';
if (d > 0) {
depth(d - 1);
}
}
int main() {
depth(3);
}
Вывод будет таким:
d=3
d=2
d=1
d=0
Почему это важно? Потому что одновременно существуют несколько local: один для d=3, один для d=2, и так далее. Они не “перезаписывают” друг друга, потому что каждый живёт в своём кадре стека.
Можно мысленно представить стек во время выполнения depth(3) так:
top -> depth(0) frame
depth(1) frame
depth(2) frame
bottom depth(3) frame
И когда depth(0) заканчивается, снимается верхний кадр, и управление возвращается в depth(1). Это и есть LIFO.
Практический вывод (без паники): рекурсия красива и полезна, но она “ест” стек. Поэтому глубокая рекурсия упирается в ограничение ресурсов. Мы ещё не лезем в системные детали, но на уровне мышления достаточно понимать: “каждый вызов = ещё один слой”.
7. Памятка: что заканчивается и когда
Когда мозг устал, хорошо иметь короткую “карту”. Ниже — простая табличка, которую полезно держать в голове, пока вы пишете функции.
| Событие в коде | Что происходит с локальными переменными (automatic) |
|---|---|
| Вход в блок { | Создаются переменные, объявленные внутри блока, когда выполнение доходит до их объявления |
| Выход на } | Заканчиваются переменные, чей scope — этот блок |
| Вызов функции f() | Создаётся новый кадр стека для f() с её локальными переменными |
| Возврат из функции return | Функция покидает свой scope; локальные переменные функции заканчиваются |
| Рекурсивный вызов | Создаётся ещё один кадр стека, даже если “функция та же самая” |
Термины automatic storage duration и dynamic storage duration в языке разделяются именно так: локальные автоматические объекты живут в рамках блока, а “dynamic” не привязан к кадрам стека. Сегодня мы концентрируемся на первом.
8. Типичные ошибки
Ошибка №1: думать, что переменная “умирает”, когда вы перестали её использовать.
Новичок часто пишет: “я уже вывел x, значит он больше не нужен, значит он исчез”. Нет: исчезновение локальной переменной привязано не к последнему использованию, а к границе scope. Если x объявлен в начале функции — он живёт до выхода из функции, даже если вы к нему не обращались последние 100 строк.
Ошибка №2: объявлять все переменные в начале функции “на всякий случай”.
Так код кажется “структурированным”, но на практике он становится мутным: вы читаете низ функции и видите переменную, которая была создана вверху и непонятно зачем живёт до сих пор. Вложенные блоки {} — простой способ укоротить жизнь временных объектов и одновременно сделать код более “поэтапным”.
Ошибка №3: ожидать, что локальная переменная сохранит значение между вызовами функции.
Если переменная automatic, то каждый вызов создаёт её заново. В этом нет бага: это базовая гарантия, благодаря которой функции остаются предсказуемыми. Если вам нужно “память между вызовами”, это уже другая длительность хранения (об этом говорили в предыдущей лекции), но пытаться получить её от обычной локальной переменной — как пытаться сохранить суп в дуршлаге.
Ошибка №4: недооценивать стоимость рекурсии “потому что код короткий”.
Короткий код не означает маленькие затраты. Каждый уровень рекурсии — это ещё один кадр стека, ещё один набор локальных данных. Поэтому рекурсия должна быть осознанным выбором: когда она делает код понятнее, и когда глубина гарантированно не станет огромной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ