Вызовы функций, стек, куча и продолжения. Часть 1

Для многих людей, занимающихся программированием, вызов функции воспринимается практически как синоним передачи управления с сохранением адреса возврата и выделением фрейма памяти в стеке. Это однако, не всегда верно и в практическом, и в теоретическом отношении. О семантике и прагматике вызова функций мы и поговорим в этой статье.

Общая семантика применения функции

Сначала давайте предварительно обсудим, какую семантику мы вообще подразумеваем под записью вызова (или, говоря более общо, применения) функции f(x,y).

Как правило, в языках программирования используются три основные семантические интерпретации применения функции:

1) Вызов функции в императивных языках. Вычисляется каким-то образом значение фактических параметров, запоминается динамический контекст вызова (которым может являться просто адрес точки вызова в программе или какая-то более подробная информация), значения фактических параметров ассоциируются с именами формальных, управление передаётся в начало функции, выполняется тело функции, после этого происходит переход обратно в запомненный контекст вызова (адрес возврата) и выполнение программы продолжается с использованием возвращённого значения функции. Часто к этому подключается управление локальной памятью, при котором память для локальных переменных функции распределяется в момент входа в неё и освобождается в момент выхода.

Заметим, что такая интерпретация всегда приводит к так называемому аппликативному порядку вычислений, когда сначала вычисляются аргументы, а затем к ним применяется функция:

def 𝑠𝑞(𝑥)=𝑥∗𝑥 ; def 𝑓(𝑥,𝑦)=𝑠𝑞(𝑥+𝑦) 

𝑓(3,2) → 𝑠𝑞(3+2) → 𝑠𝑞(5) → 5∗5 → 25.

Передача параметров при этой интерпретации обычно осуществляется по значению или по ссылке.

2) Подстановка значения функции в макроязыках. В таком случае вызов функции текстуально заменяется подстановкой её тела с заменой формальных параметров на фактические и затем вычислением полученного текста, так до тех пор, пока всё не вычислили. В зависимости от порядка вычисления i-го результата по отношению к (i+1)-й макроподстановке (то есть от решения о том, проводить макроподстановки и вычисления по очереди, или сначала все макроподстановки, а потом все вычисления), такая интепретация может приводить либо к аппликативному, либо к нормальному порядку вычислений. Нормальный порядок вычислений (применение функции снаружи выражения внутрь) иллюстрируется следующим примером:

def 𝑠𝑞(𝑥)=𝑥∗𝑥 ; def 𝑓(𝑥,𝑦)=𝑠𝑞(𝑥+𝑦) 

𝑓(3,2) → 𝑠𝑞(3+2) → (3+2)∗(3+2) → 5∗(3+2) → 5∗5 → 25. 

Отметим, что нормальный порядок имеет проблемы с рекурсивным применением функции, так как подстановка производится вперёд вычисления условия выхода.

На практике, например, макроподстановка #define в языке C вычисляется нормально, а define-syntax в языке Scheme - аппликативно (имея здесь в виду под аппликацией именно вычисления макрофазы, то есть применение синтаксических правил).

Передача параметров в данной интерпретации по необходимости осуществляется по имени, то есть текстуальной заменой имени параметра на его значение.

3) Применение функции в функциональных языках. В таком случае запись f(x,y) означает просто саму по себе функциональную зависимость значения функции от её параметров. В функциональных языках функции обычно не имеют побочных эффектов, поэтому могут вычисляться с применением не только аппликативного (или нормального) но и ленивого порядка вычислений. Ленивый порядок вычислений заключается в том, что значение функции вычисляется только в тот момент, когда фактически потребовался её результат. Ленивый порядок вычислений позволяет свободно записывать бесконечные рекурсивные последовательности, так как фактически всегда будет востребовано ограниченное количество их первых членов. Это возможно именно за счёт того, что применение функции в ленивом порядке не является её "вызовом" в том смысле, как мы его понимаем в императивных языках:

def 𝑠𝑞(𝑥)=𝑥∗𝑥 ; def 𝑓(𝑥,𝑦)=𝑠𝑞(𝑥+𝑦) 

𝑓(3,2).

Более строго функциональные языки, такие как Haskell, часто ограничиваются ленивым порядком вычислений. Более универсальные языки, такие как Lisp и Scheme, позволяют использовать по выбору программиста и аппликативный, и нормальный, и ленивый порядок (хотя исходно вычисление функций в Лисп-подобных языках аппликативно).

Передача параметров в данной интерпретации обычно осуществляется по значению.

Теперь давайте, рассмотрев эти три возможных случая, сосредоточимся на первом, то есть императивном вызове функции, близком большинству программистов.

Реализация императивного вызова функции в машинном коде

"Стек! Стек!" – захлопали татары ©

Как уже было отмечено выше, часто программисты представляют себе императивный вызов функции как синоним использования стека. Адрес возврата и значения фактических параметров (или часть из них) проталкиваются в стек, там же размещается стековый фрейм для локальных переменных, выполняется функция, затем стековый фрейм уничтожается и выполняется возврат по запомненному в стеке адресу.

Такой способ вызова функции, принятый в реализации многих языков на распространённых процессорных архитектурах, однако, не является единственным. Почему же так нельзя делать всегда?

Прежде всего, в конкретной архитектуре процессора может вообще отсутствовать стек. Самым известным примером такого рода являются мейнфреймы IBM (IBM S/360 ... IBM z).

Как происходит императивный вызов нерекурсивной нереентерабельной (т.е. не способной вызываться несколько раз параллельно) функции на IBM z? Очень просто. Адрес возврата сохраняется в регистре общего назначения (по соглашению о связях OS - конкретно в 14-м регистре), параметры передаются через регистры, локальные переменные размещаются в статически выделенной в объектном коде области локальной памяти функции. Всё. Никаких накладных расходов.

Плохая новость состоит в том, что для рекурсивного вызова такое не сработает. Нам нужно использовать регистр адреса возврата и локальную память несколько раз для нескольких инстансов функции. Статически этого не сделать. Поэтому для рекурсивных вызовов необходимо выделять локальную память из кучи при каждом вызове функции и освобождать её при каждом возврате. А это довольно дорогостоящая операция.

Хорошая (для мейнфреймовцев) новость состоит в том, что такая реализация, подходящая для рекурсивных функций, автоматически делает их и реентерабельными тоже, то есть мы бесплатно оказываемся готовы к параллелизму. В то время как в стековой модели нам необходимо предусматривать свой стек для каждого параллельного процесса.

В стековой же модели встаёт неприятный (особенно для маленького адресного пространства или невиртуализированной памяти) вопрос о фиксированном максимальном размере стека, который не актуален при выделении памяти из кучи.

Таким образом, стековая модель в общем случае себя показывает более эффективной для последовательных рекурсивных вызовов с ограниченной глубиной, а бесстековая – для нерекурсивных вызовов, рекурсивных вызовов большой глубины или параллельных вызовов. Реально используемые программы, однако, по своему устройству и прагматике применения чаще всего соответствуют именно первому случаю.

Реализация императивного вызова функции в модели продолжений

Вопрос бесстековой реализации императивного вызова функций имеет, однако, гораздо более глубокие приложения, чем просто упражнения с машинным кодом железок IBM.

Теоретической моделью для такой бесстековой реализации в программировании является концепция продолжений (continuations), впервые реализованная в языке Scheme, представляющая собой универсальный механизм представления семантики различных управляющих конструкций. По сути, так же как лямбда-выражение представляет собой базовую абстракцию операций с данными, также и продолжение представляет собой базовую абстракцию операций с управлением. В некоторых современных оптимизирующих компиляторах используется специальная техника CPS-преобразования, позволяющая автоматически преобразовывать функциональные вызовы в последовательности продолжений, а это, в свою очередь, позволяет выполнять сквозные оптимизирующие преобразования через несколько вложенных вызовов функций.

Детально семантику и прагматику продолжений мы рассматриваем в следующей части статьи, в которой будет меньше общих рассуждений и больше кода.

@vadimr
04.04.2025 19:39 UTC
Первоисточник

Комментарии

@Alex283
05.04.2025 07:02 UTC
-3

Столько много букв, инструкция CALL общая для всех "абстрактных" названий функций и методов в языках высокого уровня

@vadimr
05.04.2025 07:07 UTC
+4

Инструкция CALL соответствует первой из трёх рассмотренных семантических интерпретаций, и статья как раз о том, что она сама по себе далеко не является общей, а уж её реализация - вообще тонкое дело.

Собственно, вы в своём комментарии озвучили как раз именно то распространённое заблуждение, для борьбы с которым и предназначена статья.

05.04.2025 14:13 UTC
0

Экзотическа, в чем приемущества сохранения обратного вызова в регистре?

Откройте любой сишный исходник на git ... openssh, gnome и другие, чтобы понять что делает конкретно одна функция, нужно не менее 3-5 локальных функциях "провалится", чтобы до браться до функционала... так и регистров не хватит

05.04.2025 14:18 UTC
0

Пожалуйста, дайте себе труд прочесть статью, так как на ваши реплики в ней содержатся подробные ответы (которые вы назвали "много букв"). А регистр в данном случае нужен только один.

05.04.2025 15:00 UTC
0

я прочитал, и не понял, где "заблуждение"... Хорошо, регистр один, а если вызов процедуры происходит внутри другой процедуры, а если таких вызовов еще несколько, получается вызов новой процедуры затирает старый, где мы должны сохранить старый?

05.04.2025 15:08 UTC
0

А где вообще мы должны сохранять регистры при вызове другой процедуры? В области сохранения. Которая при отсутствии рекурсии и реентерабельности может быть статической.

05.04.2025 15:16 UTC
0

Я правильно понимаю, у каждой функции есть область сохранения, где она хранит все регистры и в том числе регистр в котором был сохранен обратный адрес?!

05.04.2025 15:37 UTC
0

Да.

05.04.2025 15:54 UTC
0
  1. Ну это дополнительные накладные расходы

  2. Рекурсивный вызов можно добиться на том же х86, то есть первый вызов CALL, а последующие JMP, с небольшими танцами с бубном можно использовать теже самые локальные переменные в стеке...

05.04.2025 16:00 UTC
0

На x86 нет команд группового сохранения и восстановления всех регистров в области сохранения, как STM/LM у мейнфрейма. Поэтому издержки будут больше. Но всё равно же регистры сохранять как-то надо. Даже если соглашение о связях этого не требует, то логика пользовательской программы всё равно никуда не девается.

28.02.2026 14:09 UTC
0

Насколько я помню, эта область хранения называется "кадр стека".

Подробности:

https://ru.wikipedia.org/wiki/Стековый_кадр

https://habr.com/ru/companies/smart_soft/articles/234239/

28.02.2026 14:20 UTC
0

Если в машинной архитектуре есть стек.

@SIISII
05.04.2025 09:36 UTC
+2

Насчёт бесстекового способа вставлю свои пять копеек.

  1. Исторически в OS/360 каждое динамическое выделение памяти производилось путём обращения к супервизору (грубо говоря, к ядру ОС): ассемблерная программа, желающая получить память, использовала макрокоманду GETMAIN ("main" -- от названия "основная память", main storage, использовавшейся и использующейся поныне для обозначения ОЗУ в IBMовских мэйнфреймах), ну а эта макрокоманда разворачивалась в загрузку параметров (в первом приближении -- требуемого объёма памяти) в регистры процессора и выдачу команды SVC, приводящей к прерыванию по вызову супервизора. Сейчас такой способ кажется диким: прерывание на современных процессорах -- очень долгий процесс по сравнению с простым выполнением команд, но тогда, в середине 1960-х и в 1970-х, особой разницы во времени выполнения не было.

  2. Такая достаточно современная архитектура, как ARM, хотя и имеет стек, позволяет реализовать вызов подпрограмм без его использования: команда BL, вызывающая подпрограмму, сохраняет адрес возврата в регистре LR, а не записывает его в стек, как это делает, скажем, CALL на IA-32 (x86). Соответственно, потенциально можно реализовать ту же схему вызова, что и в IBMовских мэйнфреймах. Правда, в ARM адрес возврата заносится всегда в один и тот же регистр, а в мэйнфреймах можно использовать любой из 16 регистров общего назначения, но это уже технические детали.

@Alex283
05.04.2025 14:27 UTC
-1

Система инструкции ARM - это старая система (1983) и это пример, как её не нужно делать! В настоящих современных архитектурах производиться разделение пользовательского стека и стека для хранения обратных вызовов и состояний.

05.04.2025 14:53 UTC
0

Это разделение ещё в 6502 было.

05.04.2025 15:24 UTC
+1

увы, не реализованов 6502
регистры 6502: A - аккумулятор, 8 бит; X, Y - индексные регистры, 8 бит; PC - счетчик команд, 16 бит; S - указатель стека, 8 бит; P - регистр состояния. Вызов процедуры и прерывания сохраняют счетчик команд в стек.

05.04.2025 15:38 UTC
+1

Ну да. А пользовательские данные там в стек возвратов не влезали.

10.11.2025 11:52 UTC
0

Как же не влезали, там что pla клало аккумулятор в $0100-$01FF что адреса для возвратов из jsr и прерываний шли туда же, ну и напрямую к этим адресам тоже вовсю обращались исходя из расчета что глубже скажем 64 байтов стек данной конкретной программы зайти не должен, значит $0100-$01BF можно использовать как обычную RAM (для надежности конечно поближе к $0100)

10.11.2025 11:55 UTC
0

Я к тому, что в 256 байтах особо не разгуляешься даже на 8-разрядной машине.

05.04.2025 22:34 UTC
0

Не было. Там самый что ни на есть обычный стек, только объём всего 256 байт.

06.04.2025 04:05 UTC
+1

Ну да, поэтому для контекста пользовательской программы там не было места. Вот локальные переменные в нём и не содержали.

06.04.2025 11:15 UTC
+1

Плюс, там были короткие команды для доступа к нулевой странице памяти (0000-00FF), которые работали быстрей обычных, длинных. Плюс, типичное применение машинок на процах такого уровня -- выполнение всего одной задачи, а значит, вся память -- её, повторная входимость обычно не нужна и т.д. и т.п. Так что вызовы подпрограмм нередко выполнялись по "гибридной модели", так сказать: сам вызов технически использовал стек для адреса возврата (команды JSR и RTS), а параметры нередко лежали в предопределённых ячейках памяти.

Лично я предпочитаю иметь модель примерно как в ARM: где я сам могу либо традиционным "стековым" образом вызывать подпрограммы, либо сделать, как в Системе 360, либо некое промежуточное решение. В общем, когда есть гибкость. Правда, она востребована, только если пишешь на ассемблере -- а сейчас это редкость.

05.04.2025 22:34 UTC
+1

Система инструкции ARM - это старая система (1983)

Но она новее и мэйнфреймов (анонс в 1964 году, продажи -- с 1965), и 8086/88, из коего выросла IA-32 (1977-й, если память не изменяет).

В настоящих современных архитектурах производиться разделение пользовательского стека и стека для хранения обратных вызовов и состояний

Спорная вещь. Но, замечу, на ARMе отдельные стеки для адресов возврата и для, скажем, данных сделать элементарно как раз благодаря его системе команд.

@kmatveev
06.04.2025 13:13 UTC
0

Я пишу этот комментарий ещё не прочитав вторую часть, но уже сейчас я со многим не согласен. Память для кадра локальных переменных функции можно выделять на стеке или в куче, оба этих региона ограничены, не только стек. На нём сильно проще выделить память (константное время) и проще вернуть.

Но есть ещё один момент. В функциональных языках поддерживаются значения-функции, которые запомнили контекст, в котором были определены, включая локальные переменные этого контекста. Такое хранилище контекста функции называется "замыкание"/"closure", и, поскольку для него порядок создания не соответствует обратному порядку удаления, оно создаётся на куче. А раз уж там живут локальные переменные, то имеет смысл его использовать в качестве кадра локальных переменных функции.

@vadimr
06.04.2025 13:30 UTC
+1

А с чем именно Вы не согласны?

Конечно, правильно реализованные замыкания требуют размещения связанных переменных в куче. Но это относится не только к замыканиям, но и к многим другим динамическим структурам данных – без кучи вообще жить тяжело. Обсуждаемый вопрос же состоит не в том, можно ли вообще в программе обойтись без кучи, а в том, всегда ли нужен стек для вызова функций. На замыканиях же не сошёлся свет клином, даже в функциональных языках.

Что касается ограничений. Память для локальных переменных можно выделить тремя способами: в статическом объектном коде, на стеке и в куче. Все эти способы имеют свои плюсы и свои минусы. Однако, говоря об ограничениях, я имею в виду вот что. Без кучи вообще обойтись в большинстве случаев нельзя, поэтому память для неё так и так придётся резервировать. Обычно это вся доступная программе память компьютера, за вычетом сегмента кода, сегмента статических данных и стеков. И если с сегментами кода и статических данных всё ясно, то стеки (по числу параллельных процессов) вы должны резервировать по их максимально возможному размеру, и этот – только потенциальный – размер каждого из них будет отбираться у фактического размера кучи. Хорошо, когда у вас для этого есть виртуальная память и 64-разрядное адресное пространство, и плохо, когда этого нет. Хотя замечу, что даже в 64-разрядных архитектурах в силу традиции выделение программистом больших сегментов под стеки на практике является исключительной редкостью. Не так просто будет найти реальную программу, в которой при создании нитки ей отводится хотя бы пара гигабайтов стека.

А в качестве казуистического примера ещё можно вспомнить упоминавшийся здесь процессор 6502, в котором стек был ограничен 256 байтами и находился в фиксированных адресах памяти. Я слышал, что 6502 до сих пор работает в каких-то устройствах в качестве части микроконтроллера. Так что случаи разные бывают.

На самом деле многие (в том числе наиболее распространённые) реализации Common Lisp на x86 используют стек при вызове функций, и там проблема переполнения стека проявляется только в путь. Особенно учитывая, что SBCL умеет оптимизацию хвостовой рекурсии только по запросу, а GNU Common Lisp вообще не умеет. Это одна из причин, по которым я предпочитаю Scheme.