Простыми словами: что такое стек и как он устроен
«Последним зашёл — первым вышел», или что общего между программированием и поездкой в утреннем метро.
Иллюстрация: Оля Ежак для Skillbox Media
Если вы начинали изучать программирование, то наверняка слышали об ошибке переполнения стека. Той самой, в честь которой назвали известный айтишный сайт вопросов и ответов Stack Overflow. Пришло время разобраться, что вообще означает «стек», почему он может переполниться и чем это грозит.
Из этой статьи вы узнаете:
Что такое стек
Стек (stack) — это способ организации данных в памяти компьютера. Он работает по принципу «последним пришёл, первым вышел» (last in first out, LIFO). Это значит, что последний элемент, добавленный в стек, будет взят из него первым. Добавлять новые элементы и удалять существующие из стека можно только с одного конца, который называется вершиной.
Представьте себе детскую пирамидку, где кольца по очереди надеваются на штырёк. Кольца идут друг за другом: мы не можем добавить новые кольца в середину или основание пирамидки — только сверху. Если мы вдруг захотим разобрать пирамидку, сначала нужно снять верхнее кольцо, затем предыдущее и так далее, пока не дойдём до нижнего.
Так же устроен и стек: чем выше элемент находится в пирамиде, тем раньше его заберут. Этим стеки отличаются от очереди (queue) — структуры данных, где первым используется элемент, который появился раньше всех. Если очередь формируется возле кассы в «Пятёрочке», то стек — в забитом вагоне метро, где первым выходит тот, кто зашёл последним.
Если переложить нашу аналогию на язык компьютеров, получится несколько базовых команд, которые можно использовать со стеками:
- push — добавляет элемент на вершину стека.
- pop — удаляет элемент с вершины стека.
- peek — считывает элемент с вершины стека без его удаления.
- isEmpty — проверяет, пуст ли стек.
- size — возвращает количество элементов в стеке.
В зависимости от языка и способа реализации команды могут различаться, но этот список — база, без которой невозможно двигаться дальше.
Считается, что первым понятие стека ввёл в обиход ввёл Алан Тьюринг во время работы над прообразом современного компьютера. Позже эту идею запатентовали немецкие учёные Клаус Самельсон и Фридрих Л. Бауэр.
Где используются стеки
Частый сценарий использования стеков — реализация операции отмены в текстовых и графических редакторах. Например, нарисовали вы тень для кнопки в Figma — в стеке отмены появилась операция «Добавить тень». Если вы решите вернуть как было, компьютер быстро достанет последний элемент в стеке и ему не придётся перебирать всю хронологию ваших действий.
У этого есть и обратная сторона — чем дольше вы работаете над файлом, тем сильнее разрастается стек и тем больше он подъедает оперативки. Поэтому тот же Photoshop может начать тормозить после определённого количества операций. А если памяти мало изначально, система может выдавать ошибку переполнения стекового буфера.
Помимо имплементации отмены, стеки используются для массы других задач:
- Для хранения информации о вызовах функций и их локальных переменных.
- Для управления операциями в базах данных. Это может быть, например, откат транзакций или повтор операций.
- Для вычисления выражений, проверки скобок и выполнения других операций.
- Для управления вызовами системных функций и передачей параметров между приложениями и ядром операционной системы.
На некоторых из этих кейсов мы подробно остановимся чуть позже, а пока — разберёмся с реализациями стека.
Реализации стека
Так как стек — это чистая абстракция, для работы ему нужна реализация в виде конкретной структуры данных. Чаще всего стеки реализуют с помощью динамических массивов и связных списков. Разберём их подробнее.
Реализация с помощью динамического массива.
Динамическим называют массив, размер которого может увеличиваться или уменьшаться в процессе выполнения программы. Операции добавления (push) и удаления (pop) элементов производятся либо с конца, либо с начала массива.
Вот как выглядит создание стека с помощью списка на языке Python.
Реализация с помощью связанного списка.
Здесь стек представляет собой связанный список, где каждый узел содержит какие-то данные и указатель на предыдущий узел. При добавлении новый элемент становится вершиной стека, а при удалении на вершине оказывается предыдущий элемент.
Пример реализации стека с использованием связанного списка на Python
Что выбрать?
Зависит от задачи. Если вам важна эффективность при доступе к элементам и при этом стек имеет фиксированный размер или меняется редко, лучше выбрать динамический массив. Если же вам нужно менять размер стека и вы не знаете заранее его максимальную величину, связанный список может быть удобнее.
Стек вызовов
Стек вызовов — это структура данных, которая управляет вызовами функций во время выполнения программы. Звучит сложно, на деле — всё просто, следите за руками.
Когда компьютер выполняет программу и доходит до вызова какой-то функции, ему нужно ненадолго переключиться, чтобы эту самую функцию выполнить. Чтобы запомнить, где он остановился, компьютер сохраняет в памяти специальные закладки — так называемые точки перехода. Область памяти, где хранятся точки перехода, как раз и называется стеком вызовов.
В точке перехода хранится всё, чтобы компьютер быстро и безболезненно вернулся к выполнению основного кода: значения переменных, аргументы функций и адрес возврата — то место, куда компьютер должен перейти после окончания подпрограммы. Жизненная аналогия для точки перехода — это чекпойнт в игре: добежали, сохранились и можете пойти спокойно пить чай, пребывая в уверенности, что сможете начать с того же места.
Если внутри одной функции произойдёт вызов другой функции, история аналогичная: компьютер поставит на паузу выполнение первой, сохранит в стеке точку перехода, а затем перейдёт к выполнению второй. Этот принцип будет работать для любой степени вложенности функции, вплоть до рекурсии.
Чтобы было понятнее, изобразим весь этот процесс на схеме. Представим, что у нас есть программа с тремя функциями: первая вызывает вторую, вторая — третью и так далее.
Шаг 1. Компьютер выполняет основную программу и доходит до вызова функции 1. Он прерывает выполнение основной программы, помещает адрес возврата в стек и переходит к выполнению функции 1.
Шаг 2. Компьютер выполняет функцию 1 и доходит до вызова функции 2. Он прерывает выполнение функции 1, помещает её адрес возврата в стек и выполняет функцию 2.
Шаг 3. Компьютер заканчивает выполнение функции 2, считывает и удаляет с вершины стека адрес возврата функции 1. Затем он переходит к функции 1 и продолжает её выполнение с инструкции, находящейся по адресу возврата.
Шаг 4. Компьютер заканчивает выполнение функции 1, считывает и удаляет с вершины стека адрес возврата основной программы. Затем он переходит к основной программе и продолжает её выполнение с указанного адреса. Стек полностью очищается до следующего вызова.
Что такое переполнение стека
Чтобы стек не разрастался в памяти, ему задаётся конкретный размер — либо системой, либо самим программистом. Но если вызовов в программе будет слишком много, стек может внезапно переполниться — в этом случае программа аварийно завершит работу и выдаст ошибку о переполнении стека.
Случиться это может по разным причинам:
- Рекурсия большой глубины вложенности. При каждом витке рекурсии в стек добавляются новые элементы. Когда элементов бывает очень много, стек переполняется. В различных языках может быть разная глубина рекурсии, например в Python она ограничена примерно 3000 вызовов.
- Бесконечные циклы, которые тоже приводят к накоплению данных в стеке. Вот пример бесконечного цикла на Python, который приводит к переполнению стека вызовов:
- Огромные массивы или другие структуры в программах.
Когда программа пытается разместить на стеке больше данных, чем он может вместить, система записывает эти данные памяти за пределами выделенного участка. Это может привести к непредсказуемому поведению программы или её аварийному завершению с потерей данных. А ещё переполнение стека могут использовать хакеры, чтобы внедрить в систему вредоносный код.
Стеки данных
Стеки данных работают подобно стекам вызовов — в них можно читать и удалять только последний элемент, остальные недоступны. Этот вид стеков часто используют для работы с разветвлёнными типами данных: деревьями, графами, XML-документами, JSON-объектами и другими.
Например, стеки данных хорошо подходят для обхода деревьев в глубину — когда мы посещаем все узлы дерева, чтобы сделать с ними что-то, скажем, вывести все значения. В этом случае мы начинаем с корня и идём так глубоко, как только можем, а потом возвращаемся, чтобы исследовать другую ветвь. И стеки подходят для этого как нельзя лучше:
- Когда мы посещаем узел, мы добавляем его в стек.
- Когда мы хотим вернуться к предыдущему узлу, мы достаём его из стека.
Другой кейс — вычисление выражений в обратной польской нотации (ОПН). Это такие выражения, в которых сначала пишутся числа, а потом знаки операций. Например, 2+ 3∗4 будет записано как 2 3 4 ∗ +. Идея в том, чтобы создать алгоритм, который будет сначала «откладывать» числа в стек, а как доберётся до знаков операций — достанет их оттуда и проведёт вычисления.
Для самых стойких — вот как это реализуется с помощью Python:
Что запомнить
Давайте подытожим всё, что мы узнали из этой статьи:
- Стек — это способ хранения данных, работающий по принципу «последним пришёл, первым вышел». Он применяется в разных областях программирования и алгоритмов.
- Две наиболее распространённые реализации стека: с использованием динамического массива и с помощью связанного списка.
- Есть множество видов стеков, которые применяются в разных областях. Основные из них — стек вызовов и стек данных.
- Стек вызовов — это структура данных, которая управляет вызовами функций во время выполнения программы.
- Если программа пытается разместить на стеке больше данных, чем он может вместить, происходит переполнение стека. Это приводит к аварийному завершению работы программы и другим непредсказуемым последствиям.
- Стеки данных используются при вычислении выражений, обратной польской нотации, для обхода деревьев, графов и других целей.
Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!