JavaRush /Курсы /C++ SELF /Stack: жизнь локальных переменных и выход из scope

Stack: жизнь локальных переменных и выход из scope

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

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: недооценивать стоимость рекурсии “потому что код короткий”.
Короткий код не означает маленькие затраты. Каждый уровень рекурсии — это ещё один кадр стека, ещё один набор локальных данных. Поэтому рекурсия должна быть осознанным выбором: когда она делает код понятнее, и когда глубина гарантированно не станет огромной.

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