Чому програми використовують стеки викликів, якщо вкладені виклики функцій можуть бути вбудовані?


33

Чому б не запропонувати компілятору взяти таку програму:

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

і перетворити його в таку програму:

function c(b) { return b^2 + 5 };

тим самим усуваючи необхідність комп’ютера запам'ятати зворотну адресу c (b)?

Я вважаю, що збільшення місця на жорсткому диску та оперативної пам’яті, необхідних для зберігання програми та підтримки її компіляції (відповідно), є причиною, чому ми використовуємо стеки викликів. Це правильно?


30
Подивіться, що станеться, якщо ви робите це в програмі будь-якого змістовного розміру. Зокрема, функції викликаються з більш ніж одного місця.
користувач253751

10
Крім того, іноді компілятор не знає, яка функція викликається! Дурний приклад:window[prompt("Enter function name","")]()
користувач253751

26
Як ви реалізуєте function(a)b { if(b>0) return a(b-1); }без стека?
pjc50

8
Де відношення до функціонального програмування?
мастов

14
@ pjc50: хвостовий рекурсивний, тому компілятор переводить його у цикл із змінним b. Але якщо взяти до уваги, не всі рекурсивні функції можуть усунути рекурсію, і навіть коли функція в принципі може, компілятор може бути недостатньо розумним для цього.
Стів Джессоп

Відповіді:


75

Це називається "вбудоване", і багато компіляторів роблять це як стратегію оптимізації у випадках, коли це має сенс.

У вашому конкретному прикладі ця оптимізація дозволить заощадити як простір, так і час виконання. Але якщо функція викликалася в декількох місцях програми (не рідкість!), Це збільшило б розмір коду, тому стратегія стає більш сумнівною. (І звичайно, якщо функція називала себе безпосередньо чи опосередковано, вбудувати її було б неможливо, оскільки тоді код набув нескінченного розміру.)

І очевидно, що це можливо лише для "приватних" функцій. Функції, доступні для зовнішніх абонентів, неможливо оптимізувати, принаймні, не мовами з динамічним зв’язком.


7
@Blrfl: Сучасним компіляторам фактично більше не потрібні визначення у заголовку; вони можуть вбудовуватися через переклади. Однак для цього потрібен гідний лінкер. Визначення у заголовкових файлах є вирішенням для німих посилань.
MSalters

3
"Функції, які піддаються зовнішнім абонентам, неможливо оптимізувати" - функція повинна існувати, але будь-який даний сайт виклику до нього (або у власному коді, або якщо у них є джерело, зовнішні абоненти) можна вказувати.
Випадково832

14
Нічого собі, 28 заявок на відповідь, яка навіть не згадує причину, чому вкладати все неможливо: рекурсія.
мастов

3
@R ..: LTO - це оптимізація часу LINK, а не оптимізація часу завантаження.
MSalters

2
@immibis: Але якщо цей явний стек введений компілятором, то цей стек є стеком викликів.
user2357112 підтримує Моніку

51

У вашому запитанні є дві частини: навіщо взагалі мати кілька функцій (замість заміни функціональних викликів своїм визначенням) і чому реалізовувати ці функції зі стеками викликів, а не статично розподіляти свої дані деінде?

Перша причина - рекурсія. Не тільки "о, давайте зробимо новий виклик функції для кожного елемента цього списку", також і скромний вид, де у вас є, можливо, два виклики функції, активної одночасно, з багатьма іншими функціями між ними. Вам потрібно помістити локальні змінні в стек, щоб це підтримати, і взагалі не можна вбудовувати рекурсивні функції.

Тоді виникає проблема з бібліотеками: ви не знаєте, які функції будуть викликатися звідки і як часто, тому "бібліотека" ніколи насправді не може бути скомпільована, а лише відправляється всім клієнтам у зручному форматі високого рівня, який потім буде вкладений у додаток. Окрім інших проблем з цим, ви повністю втрачаєте динамічну зв'язок із усіма її перевагами.

Крім того, є багато причин не вбудовувати функції, навіть коли ви могли:

  1. Це не обов'язково швидше. Налаштування кадру стека та зривання його - це, можливо, з десяток інструкцій на один цикл, для багатьох великих або циклічних функцій, що не становить навіть 0,1% часу їх виконання.
  2. Це може бути повільніше. Дублювання коду має витрати, наприклад, це призведе до більшого тиску в кеш-пам'яті інструкцій.
  3. Деякі функції дуже великі і викликаються з багатьох місць, вбудовування їх скрізь збільшує двійкові дані далеко за рамки розумного.
  4. Укладачам часто важко працювати з дуже великими функціями. За інших рівних функцій розміром 2 * N займає більше 2 * T часу, коли функція розміру N займає T час.

1
Я здивований пунктом 4. Яка причина цього?
ЖакБ

12
@JacquesB Багато алгоритмів оптимізації є квадратичними, кубічними або навіть технічно неповними. Канонічним прикладом є розподіл реєстру, який є NP-завершеним за аналогією з кольоровим забарвленням. (Зазвичай компілятори не намагаються вирішити точне рішення, але лише пара дуже поганих евристик працює в лінійний час.) Для багатьох простих оптимізацій в один прохід спочатку потрібен суперлінійний аналіз, наприклад, все, що залежить від домінування в контрольних потоках (як правило n журнал n часу з п базовими блоками).

2
"У вас тут справді два питання" Ні, я не знаю. Лише одне - чому б не трактувати виклик функції просто як заповнювач, який компілятор може, скажімо, замінити кодом викликаної функції?
moonman239

4
@ moonman239 Тоді ваше формулювання мене відкинуло. Тим не менш, ваше питання може бути розкладене, як я роблю у своїй відповіді, і я думаю, що це корисна перспектива.

16

Стеки дозволяють елегантно обійти межі, накладені кінцевою кількістю регістрів.

Уявіть, що ви маєте рівно 26 глобальних «регістрів az» (або навіть майте лише 7 байтових регістрів чіпа 8080). І кожна функція, яку ви пишете в цьому додатку, поділяє цей плоский список.

Наївним початком буде виділення перших кількох регістрів на першу функцію, і знаючи, що це знадобилося лише 3, почніть з "d" для другої функції ... Ви швидко закінчитеся.

Натомість, якщо у вас є метафорична стрічка, як, наприклад, машина Тьюрінга, ви могли б змусити кожну функцію запустити "виклик іншої функції", зберігаючи всі змінні, якими вона користується і пересувати () стрічку, і тоді функція callee може заплутатися з якоюсь кількістю реєструється як хоче. Після закінчення виклику він повертає контроль до батьківської функції, яка знає, куди потрібно зафіксувати висновок виклику, а потім відтворює стрічку назад, щоб відновити її стан.

Ваш базовий кадр виклику саме такий, і вони створюються та скидаються за допомогою стандартизованих послідовностей машинного коду, який компілятор здійснює навколо переходів від однієї функції до іншої. (Минуло давно, коли мені довелося запам’ятати свої кадри стека C, але ви можете прочитати на різних способах обов'язки того, хто скидає те, що на X86_calling_conventions .)

(рекурсія є приголомшливою, але якби вам коли-небудь довелося жонглювати регістри без стека, ви б дуже цінували стеки.)


Я вважаю, що збільшення місця на жорсткому диску та оперативної пам’яті, необхідних для зберігання програми та підтримки її компіляції (відповідно), є причиною, чому ми використовуємо стеки викликів. Це правильно?

Хоча в наші дні ми можемо вкласти більше, ("більша швидкість" завжди хороша; "менше кб збірки" означає дуже мало у світі відеопотоків) Основне обмеження полягає у здатності компілятора згладжувати певні типи моделей коду.

Наприклад, поліморфні об'єкти - якщо ви не знаєте одного і єдиного типу об'єкта, який вам передадуть, ви не можете згладити; ви повинні подивитися на vtable-функції об'єктів і зателефонувати через цей вказівник ... тривіально робити під час виконання, неможливо вбудувати в час компіляції.

Сучасний інструментальний ланцюг може радісно вбудовувати певну поліморфно функцію, коли він достатньо сплюндрував абонента, щоб точно знати , який аромат об'єкта:

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

у вищевикладеному, компілятор може вибрати право підтримувати статичне вбудовування від InlineMe за допомогою внутрішнього акта (), а також не потрібно торкатися будь-яких vtables під час виконання.

Але будь-яка невизначеність в який аромат об'єкта залишить його як заклик до дискретної функції, навіть якщо деякі інші виклики тієї ж функції є вбудованими.


11

Випадки, з якими цей підхід не може впоратися:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

Там є мови і платформа з обмеженими чи ні викликів стеків. Мікропроцесори PIC мають апаратний стек, обмежений між 2 та 32 записами . Це створює дизайнерські обмеження.

COBOL забороняє рекурсію: https://stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

Накладення заборони на рекурсію означає, що ви можете статично представляти всю програму викликів програми як DAG. Потім ваш компілятор може видати одну копію функції для кожного місця, з якого вона викликається фіксованим стрибком, а не поверненням. Ніякий стек не потрібен, просто більше програмного простору, потенційно досить багато для складних систем. Але для невеликих вбудованих систем це означає, що ви можете гарантувати, що під час виконання не буде переповнення стека, що буде поганою новиною для управління ядерним реактором / реактивною турбіною / дроселем автомобіля тощо.


12
Ваш перший приклад - це основна рекурсія, і ви там правильно. Але ваш другий приклад здається циклом для виклику іншої функції. Функція вкладок відрізняється від розгортання циклу; функція може бути вбудована без розгортання циклу. Або я пропустив якусь тонку деталь?
jpmc26

1
Якщо ваш перший приклад має на меті визначити ряд Фібоначчі, це неправильно. (Відсутній fibдзвінок.)
Paŭlo Ebermann

1
Хоча заборона рекурсії означає, що весь графік виклику може бути представлений як DAG, це не означає, що можна було перерахувати повний список вкладених послідовностей викликів у достатній кількості місця. В одному з моїх проектів для мікроконтролера з кодовим простором 128 КБ я помилився, попросивши графік виклику, який включав усі функції, які могли впливати на максимальну вимогу RAM-параметр, і графік виклику перевищував концерт. Повний графік виклику був би ще довший, і це було для програми, яка вміщувалась у 128 Кб простору коду.
supercat

8

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

Зауважте, що для вбудовування потрібна відома викликана функція (і вона ефективна лише в тому випадку, якщо названа функція не надто велика), оскільки концептуально вона замінює виклик перезаписом викликаної функції. Таким чином, ви, як правило, не можете вбудувати невідому функцію (наприклад, покажчик функції - і що включає функції з динамічно пов'язаних спільних бібліотек -, що, можливо, видно як віртуальний метод у деяких vtable ; але деякі компілятори іноді можуть оптимізувати через девіартуалізацію методи ). Звичайно, не завжди можливо вбудовувати рекурсивні функції (деякі розумні компілятори можуть використовувати часткову оцінку, а в деяких випадках мати можливість вбудовувати рекурсивні функції).

Зауважте також, що вбудовування, навіть коли це легко можливо, не завжди ефективно: ви (власне ваш компілятор) можете настільки збільшити розмір коду, що кеші процесора (або передбачувача гілки ) будуть працювати менш ефективно, і це змусить вашу програму працювати повільніше.

Я трохи зосереджуюся на цьому функціональному стилі програмування , оскільки ви позначили qestion як такий.

Зауважте це вам не потрібно мати стек викликів (принаймні, в машинному розумінні виразу "стек виклику"). Ви могли використовувати лише купу.

Отже, погляньте на продовження та прочитайте докладніше про стиль проходження продовження (CPS) та трансформацію CPS (інтуїтивно, ви можете використовувати продовження закриття як перероблені "кадри викликів", виділені в купі, і вони наче імітують стек викликів; тоді вам потрібен ефективний збирач сміття ).

Ендрю Аппел написав книгу " Компіляція з продовженнями", і старий збір сміття з паперу може бути швидшим, ніж розподіл стека . Див. Також статтю А.Кеннеді (ICFP2007) Збір з продовженнями, продовження

Я також рекомендую прочитати книгу Lisp In Small Pieces Queinnec , яка містить кілька глав, пов'язаних із продовженням та складанням.

Зауважте також, що деякі мови (наприклад, Brainfuck ) або абстрактні машини (наприклад, OISC , RAM ) не мають жодних засобів виклику, але все ще є повним Turing , тому вам (теоретично) навіть не потрібен механізм виклику функцій, навіть якщо це надзвичайно зручно. BTW, деякі старі архітектури наборів інструкцій (наприклад, IBM / 370 ) навіть не мають апаратного стека викликів або інструкції на машині натискання на виклик (IBM / 370 мав лише інструкцію на машині Branch and Link )

Нарешті, якщо у всій вашій програмі (включаючи всі необхідні бібліотеки) немає рекурсії, ви можете зберігати зворотну адресу (та "локальні" змінні, які фактично стають статичними) кожної функції в статичних місцях. Старі компілятори Fortran77 зробили це на початку 1980-х (тому компільовані програми тоді не використовували жодного стека викликів).


2
Це дуже дискусійно: CPS не має "стека викликів". Це не на стеці , містична область звичайної оперативної пам’яті, яка має трохи апаратної підтримки через %espтощо, але вона все ще зберігає еквівалентну бухгалтерію на влучно названому стеці спагетті в іншій області оперативної пам’яті. Зокрема, зворотна адреса, по суті, закодована у продовженні. І, звичайно, продовження не швидше (і мені здається, це те, до чого ставився ОП), ніж взагалі не робити дзвінків за допомогою вбудовування.

Старі документи Аппеля стверджували (і демонстрували тестування), що CPS може бути настільки ж швидким, як і стек викликів.
Базиль Старинкевич

Я скептично ставлюсь до цього, але незалежно від того, про що я не стверджував.

1
Власне, це було на робочій станції MIPS кінця 1980-х. Ймовірно, ієрархія кешу на поточних ПК зробить продуктивність дещо іншою. Було кілька робіт, що аналізують претензії Апеля (і справді, на сучасних машинах розподіл стіків може бути дещо швидшим - на кілька відсотків - ніж ретельно продуманий збір сміття)
Базиль Старинкевич,

1
@Gilles: Багато нових ядер ARM, такі як Cortex M0 та M3 (і, мабуть, інші, як M4), мають підтримку апаратних стеків для таких речей, як обробка переривань. Крім того, набір інструкцій Thumb включає обмежений набір інструкцій STRM / STRM, який включає STRMDB R13 з будь-якою комбінацією R0-R7 з / без LR та LDRMIA R13 будь-якої комбінації R0-R7 з / без ПК, що ефективно лікує R13 як покажчик стека.
supercat

8

Вбудовування (заміна викликів функцій на еквівалентну функціональність) добре працює як стратегія оптимізації для невеликих простих функцій. Накладні витрати функціонального дзвінка можна ефективно відшкодувати за невелику пеню в додатковому розмірі програми (або в деяких випадках - без штрафних санкцій).

Однак великі функції, які в свою чергу викликають інші функції, можуть призвести до величезного вибуху в розмірі програми, якби все було накреслено.

Вся суть функції дзвінка в тому, щоб сприяти ефективному повторному використанню не лише програмістом, але й самим апаратом, що включає такі властивості, як розумна пам'ять або слід на диску.

Для чого це варто: ви можете мати функції дзвінка без стеку викликів. Наприклад: IBM System / 360. При програмуванні на таких мовах, як FORTRAN на цьому апаратному забезпеченні, лічильник програми (повернення адреси) буде збережений у невеликий розділ пам'яті, зарезервований безпосередньо перед точкою введення функції. Це дозволяє повторно використовувати функції, але не допускає рекурсії або багатопотокового коду (спроба рекурсивного або повторного виклику призведе до того, що раніше збережена зворотна адреса буде перезаписана).

Як пояснюють інші відповіді, стеки - це хороші речі. Вони полегшують рекурсію та багатопотокові дзвінки. Хоча будь-який алгоритм, кодований для використання рекурсії, можна кодувати, не покладаючись на рекурсію, результат може бути складнішим, складнішим у обслуговуванні та може бути менш ефективним. Я не впевнений, що архітектура, що не має стеків, може взагалі підтримувати багатопотоковість.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.