Простыми словами: что такое стек и как он устроен
«Последним зашёл — первым вышел», или что общего между программированием и поездкой в утреннем метро.


Иллюстрация: Оля Ежак для Skillbox Media
Если вы начинали изучать программирование, то наверняка слышали об ошибке переполнения стека. Той самой, в честь которой назвали известный айтишный сайт вопросов и ответов Stack Overflow. Пришло время разобраться, что вообще означает «стек», почему он может переполниться и чем это грозит.
Из этой статьи вы узнаете:
Что такое стек
Стек (stack) — это способ организации данных в памяти компьютера. Он работает по принципу «последним пришёл, первым вышел» (last in first out, LIFO). Это значит, что последний элемент, добавленный в стек, будет взят из него первым. Добавлять новые элементы и удалять существующие из стека можно только с одного конца, который называется вершиной.
Представьте себе детскую пирамидку, где кольца по очереди надеваются на штырёк. Кольца идут друг за другом: мы не можем добавить новые кольца в середину или основание пирамидки — только сверху. Если мы вдруг захотим разобрать пирамидку, сначала нужно снять верхнее кольцо, затем предыдущее и так далее, пока не дойдём до нижнего.
Так же устроен и стек: чем выше элемент находится в пирамиде, тем раньше его заберут. Этим стеки отличаются от очереди (queue) — структуры данных, где первым используется элемент, который появился раньше всех. Если очередь формируется возле кассы в «Пятёрочке», то стек — в забитом вагоне метро, где первым выходит тот, кто зашёл последним.
Если переложить нашу аналогию на язык компьютеров, получится несколько базовых команд, которые можно использовать со стеками:
- push — добавляет элемент на вершину стека.
- pop — удаляет элемент с вершины стека.
- peek — считывает элемент с вершины стека без его удаления.
- isEmpty — проверяет, пуст ли стек.
- size — возвращает количество элементов в стеке.
В зависимости от языка и способа реализации команды могут различаться, но этот список — база, без которой невозможно двигаться дальше.
Считается, что первым понятие стека ввёл в обиход ввёл Алан Тьюринг во время работы над прообразом современного компьютера. Позже эту идею запатентовали немецкие учёные Клаус Самельсон и Фридрих Л. Бауэр.
Где используются стеки
Частый сценарий использования стеков — реализация операции отмены в текстовых и графических редакторах. Например, нарисовали вы тень для кнопки в Figma — в стеке отмены появилась операция «Добавить тень». Если вы решите вернуть как было, компьютер быстро достанет последний элемент в стеке и ему не придётся перебирать всю хронологию ваших действий.
У этого есть и обратная сторона — чем дольше вы работаете над файлом, тем сильнее разрастается стек и тем больше он подъедает оперативки. Поэтому тот же Photoshop может начать тормозить после определённого количества операций. А если памяти мало изначально, система может выдавать ошибку переполнения стекового буфера.
Помимо имплементации отмены, стеки используются для массы других задач:
- Для хранения информации о вызовах функций и их локальных переменных.
- Для управления операциями в базах данных. Это может быть, например, откат транзакций или повтор операций.
- Для вычисления выражений, проверки скобок и выполнения других операций.
- Для управления вызовами системных функций и передачей параметров между приложениями и ядром операционной системы.
На некоторых из этих кейсов мы подробно остановимся чуть позже, а пока — разберёмся с реализациями стека.
Реализации стека
Так как стек — это чистая абстракция, для работы ему нужна реализация в виде конкретной структуры данных. Чаще всего стеки реализуют с помощью динамических массивов и связных списков. Разберём их подробнее.
Реализация с помощью динамического массива.
Динамическим называют массив, размер которого может увеличиваться или уменьшаться в процессе выполнения программы. Операции добавления (push) и удаления (pop) элементов производятся либо с конца, либо с начала массива.
Вот как выглядит создание стека с помощью списка на языке Python.
class Stack:
# Конструктор для инициализации стека
def __init__(self):
self.items = []
# Функция для проверки того, есть ли в стеке элементы
def is_empty(self):
return len(self.items) == 0
# Функция для добавления элемента в стек
def push(self, item):
self.items.append(item)
# Функция для удаления верхнего элемента из стека
def pop(self):
if not self.is_empty():
return self.items.pop()
else:
print("Стек пуст. Мы не можем удалить из него элемент.")
# Функция для чтения верхнего элемента стека
def peek(self):
if not self.is_empty():
return self.items[-1]
else:
print("Стек пуст. Мы не можем прочитать верхний элемент.")
# Функция, возвращающая размер стека
def size(self):
return len(self.items)
stack = Stack()
print(stack.is_empty()) # True
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.size()) # 3
print(stack.peek()) # 3
item = stack.pop()
print(item) # 3
print(stack.size()) # 2
print(stack.is_empty()) # False
Реализация с помощью связанного списка.
Здесь стек представляет собой связанный список, где каждый узел содержит какие-то данные и указатель на предыдущий узел. При добавлении новый элемент становится вершиной стека, а при удалении на вершине оказывается предыдущий элемент.
Пример реализации стека с использованием связанного списка на Python
class Node:
# Конструктор для инициализации узла
def __init__(self, data):
self.data = data
self.next = None
class Stack:
# Конструктор для инициализации стека
def __init__(self):
self.top = None
# Функция для добавления нового узла
def push(self, data):
new_node = Node(data)
new_node.next = self.top
self.top = new_node
# Функция для удаления верхнего элемента
def pop(self):
if not self.is_empty():
popped = self.top
self.top = self.top.next
popped.next = None
return popped.data
else:
print("Стек пуст. Мы не можем удалить из него элемент.")
# Функция для чтения верхнего элемента
def peek(self):
if not self.is_empty():
return self.top.data
else:
print("Стек пуст. Мы не можем прочитать верхний элемент.")
# Функция для проверки, есть ли в стеке элементы
def is_empty(self):
return self.top is None
# Функция, возвращающая размер стека
def size(self):
count = 0
current = self.top
while current:
count += 1
current = current.next
return count
stack = Stack()
print(stack.is_empty()) # True
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.size()) # 3
print(stack.peek()) # 3
item = stack.pop()
print(item) # 3
print(stack.size()) # 2
print(stack.is_empty()) # False
Что выбрать?
Зависит от задачи. Если вам важна эффективность при доступе к элементам и при этом стек имеет фиксированный размер или меняется редко, лучше выбрать динамический массив. Если же вам нужно менять размер стека и вы не знаете заранее его максимальную величину, связанный список может быть удобнее.
Стек вызовов
Стек вызовов — это структура данных, которая управляет вызовами функций во время выполнения программы. Звучит сложно, на деле — всё просто, следите за руками.
Когда компьютер выполняет программу и доходит до вызова какой-то функции, ему нужно ненадолго переключиться, чтобы эту самую функцию выполнить. Чтобы запомнить, где он остановился, компьютер сохраняет в памяти специальные закладки — так называемые точки перехода. Область памяти, где хранятся точки перехода, как раз и называется стеком вызовов.
В точке перехода хранится всё, чтобы компьютер быстро и безболезненно вернулся к выполнению основного кода: значения переменных, аргументы функций и адрес возврата — то место, куда компьютер должен перейти после окончания подпрограммы. Жизненная аналогия для точки перехода — это чекпойнт в игре: добежали, сохранились и можете пойти спокойно пить чай, пребывая в уверенности, что сможете начать с того же места.
Если внутри одной функции произойдёт вызов другой функции, история аналогичная: компьютер поставит на паузу выполнение первой, сохранит в стеке точку перехода, а затем перейдёт к выполнению второй. Этот принцип будет работать для любой степени вложенности функции, вплоть до рекурсии.
Чтобы было понятнее, изобразим весь этот процесс на схеме. Представим, что у нас есть программа с тремя функциями: первая вызывает вторую, вторая — третью и так далее.
Шаг 1. Компьютер выполняет основную программу и доходит до вызова функции 1. Он прерывает выполнение основной программы, помещает адрес возврата в стек и переходит к выполнению функции 1.

Шаг 2. Компьютер выполняет функцию 1 и доходит до вызова функции 2. Он прерывает выполнение функции 1, помещает её адрес возврата в стек и выполняет функцию 2.

Шаг 3. Компьютер заканчивает выполнение функции 2, считывает и удаляет с вершины стека адрес возврата функции 1. Затем он переходит к функции 1 и продолжает её выполнение с инструкции, находящейся по адресу возврата.

Шаг 4. Компьютер заканчивает выполнение функции 1, считывает и удаляет с вершины стека адрес возврата основной программы. Затем он переходит к основной программе и продолжает её выполнение с указанного адреса. Стек полностью очищается до следующего вызова.

Что такое переполнение стека
Чтобы стек не разрастался в памяти, ему задаётся конкретный размер — либо системой, либо самим программистом. Но если вызовов в программе будет слишком много, стек может внезапно переполниться — в этом случае программа аварийно завершит работу и выдаст ошибку о переполнении стека.
Случиться это может по разным причинам:
- Рекурсия большой глубины вложенности. При каждом витке рекурсии в стек добавляются новые элементы. Когда элементов бывает очень много, стек переполняется. В различных языках может быть разная глубина рекурсии, например в Python она ограничена примерно 3000 вызовов.
- Бесконечные циклы, которые тоже приводят к накоплению данных в стеке. Вот пример бесконечного цикла на Python, который приводит к переполнению стека вызовов:
def infinite_loop():
while True:
pass
# Вызываем бесконечный цикл
infinite_loop()
- Огромные массивы или другие структуры в программах.
Когда программа пытается разместить на стеке больше данных, чем он может вместить, система записывает эти данные памяти за пределами выделенного участка. Это может привести к непредсказуемому поведению программы или её аварийному завершению с потерей данных. А ещё переполнение стека могут использовать хакеры, чтобы внедрить в систему вредоносный код.
Стеки данных
Стеки данных работают подобно стекам вызовов — в них можно читать и удалять только последний элемент, остальные недоступны. Этот вид стеков часто используют для работы с разветвлёнными типами данных: деревьями, графами, XML-документами, JSON-объектами и другими.
Например, стеки данных хорошо подходят для обхода деревьев в глубину — когда мы посещаем все узлы дерева, чтобы сделать с ними что-то, скажем, вывести все значения. В этом случае мы начинаем с корня и идём так глубоко, как только можем, а потом возвращаемся, чтобы исследовать другую ветвь. И стеки подходят для этого как нельзя лучше:
- Когда мы посещаем узел, мы добавляем его в стек.
- Когда мы хотим вернуться к предыдущему узлу, мы достаём его из стека.
Другой кейс — вычисление выражений в обратной польской нотации (ОПН). Это такие выражения, в которых сначала пишутся числа, а потом знаки операций. Например, 2+ 3∗4 будет записано как 2 3 4 ∗ +. Идея в том, чтобы создать алгоритм, который будет сначала «откладывать» числа в стек, а как доберётся до знаков операций — достанет их оттуда и проведёт вычисления.
Для самых стойких — вот как это реализуется с помощью Python:
import operator
OPERATORS = {'+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.truediv} # Создаём словарь, содержащий арифметические операции и их обозначения
def polsk(strok):
stack = [] # Создаём стек
lst = strok.split()
for i in lst: # Перебираем элементы списка
if i.isdigit():
stack.append(i) # Если элемент — число, то помещаем его в стек
else:
a, b = stack.pop(), stack.pop() # Считываем в переменные a и b два последних элемента стека и удаляем их из стека
stack.append(OPERATORS[i](int(a), int(b))) # Выполняем над ними операцию и результат помещаем в стек
return stack.pop()
print(polsk('11 26 33 + +')) # 70
Что запомнить
Давайте подытожим всё, что мы узнали из этой статьи:
- Стек — это способ хранения данных, работающий по принципу «последним пришёл, первым вышел». Он применяется в разных областях программирования и алгоритмов.
- Две наиболее распространённые реализации стека: с использованием динамического массива и с помощью связанного списка.
- Есть множество видов стеков, которые применяются в разных областях. Основные из них — стек вызовов и стек данных.
- Стек вызовов — это структура данных, которая управляет вызовами функций во время выполнения программы.
- Если программа пытается разместить на стеке больше данных, чем он может вместить, происходит переполнение стека. Это приводит к аварийному завершению работы программы и другим непредсказуемым последствиям.
- Стеки данных используются при вычислении выражений, обратной польской нотации, для обхода деревьев, графов и других целей.
Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!