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()"] --> B["кадр bar()"]
B --> C["кадр foo()"]
C --> B
B --> A
Сенс стрілок тут — не «передавання керування назавжди», а вкладеність: foo() живе всередині виконання bar().
3. Вихід зі 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 або великих рядків це вже помітніше, особливо у великих функціях.
Вкладений блок {} дозволяє прямо сказати: «ця частина даних потрібна лише тут».
Уявімо, що ми читаємо рядок команди користувача й тимчасово розбираємо його на токени. Вони потрібні лише під час обробки однієї команди, тож їхнє життя можна обмежити блоком.
4. Практичний приклад: 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.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 << "Додано: " << 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. А всі тимчасові обʼєкти парсингу живуть недовго, бо ми так спроєктували блоки.
5. Рекурсія і стек: чому глибока рекурсія обмежена ресурсами
Рекурсія часто здається магією: функція викликає сама себе — і наче все працює. Але саме тут стек показує свою «ціну»: кожен рекурсивний виклик створює новий кадр стека, а отже — нові локальні змінні й новий контекст.
Зробімо невеликий приклад, який виводить глибину:
#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.
Практичний висновок — без паніки: рекурсія красива й корисна, але вона «їсть» стек. Ми ще не занурюємося в системні деталі, але для правильного мислення достатньо розуміти просту річ: «кожен виклик = ще один шар».
6. Памʼятка: що закінчується і коли
Коли мозок уже втомився, добре мати коротку «карту». Нижче — проста табличка, яку корисно тримати в голові, поки ви пишете функції.
| Подія в коді | Що відбувається з локальними змінними (automatic) |
|---|---|
| Вхід у блок { | Створюються змінні, оголошені всередині блоку, коли виконання доходить до їхнього оголошення |
| Вихід на } | Завершують своє існування змінні, чий scope — цей блок |
| Виклик функції f() | Створюється новий кадр стека для f() з її локальними змінними |
| Повернення з функції через return | Функція залишає свій scope; її локальні змінні завершують своє існування |
| Рекурсивний виклик | Створюється ще один кадр стека, навіть якщо «функція та сама» |
Терміни automatic storage duration і dynamic storage duration у мові розрізняють саме це: локальні автоматичні обʼєкти живуть у межах блоку, а dynamic не привʼязаний до кадрів стека. Сьогодні ми зосереджуємося на першому варіанті.
7. Типові помилки
Помилка № 1: думати, що змінна «помирає», коли ви перестали її використовувати.
Новачок часто міркує так: «я вже вивів x, отже він мені більше не потрібен, отже він зник». Ні. Локальна змінна зникає не після останнього використання, а на межі scope. Якщо x оголошено на початку функції, він живе до виходу з неї, навіть якщо ви не зверталися до нього останні 100 рядків.
Помилка № 2: оголошувати всі змінні на початку функції «про всяк випадок».
Так код може здаватися «структурованим», але на практиці він стає каламутним: ви читаєте кінець функції, бачите змінну, яку створили нагорі, і вже не дуже розумієте, навіщо вона досі живе. Вкладені блоки {} — простий спосіб скоротити час життя тимчасових обʼєктів і водночас зробити код більш «поетапним».
Помилка № 3: очікувати, що локальна змінна збереже значення між викликами функції.
Якщо змінна має тип automatic, то кожен виклик створює її заново. У цьому немає помилки: це базова гарантія, завдяки якій функції залишаються передбачуваними. Якщо вам потрібна «памʼять між викликами», це вже інша тривалість зберігання, але намагатися отримати її від звичайної локальної змінної — це як намагатися зберегти суп у друшляку.
Помилка № 4: недооцінювати вартість рекурсії, бо «код короткий».
Короткий код не означає малих витрат. Кожен рівень рекурсії — це ще один кадр стека й ще один набір локальних даних. Тому рекурсія має бути свідомим вибором: коли вона робить код зрозумілішим і коли глибина гарантовано не стане величезною.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ