Эти другие ответы несколько вводят в заблуждение. Я согласен, что они заявляют детали реализации, которые могут объяснить это несоответствие, но они преувеличивают случай. Как правильно предложено jmite, они ориентированы на реализацию для неработающих реализаций вызовов функций / рекурсии. Многие языки реализуют циклы с помощью рекурсии, поэтому циклы явно не будут быстрее в этих языках. Рекурсия ни в коем случае не менее эффективна, чем зацикливание (если применимо оба) в теории. Позвольте мне процитировать тезис к статье Гая Стила 1977 года « Разоблачение мифа о« дорогостоящем вызове процедуры »или, реализация процедур, считающихся вредными, или, Лямбда: окончательный вариант GOTO
Фольклор утверждает, что операторы GOTO «дешевы», а вызовы процедур «дороги». Этот миф в значительной степени является результатом плохо разработанных языковых реализаций. Исторический рост этого мифа считается. Обсуждаются как теоретические идеи, так и существующая реализация, которые развенчивают этот миф. Показано, что неограниченное использование процедурных вызовов обеспечивает большую стилистическую свободу. В частности, любая блок-схема может быть написана как «структурированная» программа без введения дополнительных переменных. Сложность с оператором GOTO и вызовом процедуры характеризуется как конфликт между абстрактными концепциями программирования и конкретными языковыми конструкциями.
«Конфликт между абстрактными концепциями программирования и конкретными языковыми конструкциями» можно увидеть из того факта, что большинство теоретических моделей, например, нетипизированного лямбда-исчисления , не имеют стека . Конечно, этот конфликт не является необходимым, как показано в вышеприведенном документе, и как это демонстрируют языки, у которых нет итерационного механизма, кроме рекурсии, такие как Haskell.
fix
fix f x = f (fix f) x
( λ х . М) N⇝ М[ N/ х][ N/ х]ИксMN⇝
Теперь для примера. Определить fact
как
fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1
Вот оценка fact 3
, где, для компактности, я буду использовать в g
качестве синонима fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1))
, то есть fact = g 1
. Это не влияет на мой аргумент.
fact 3
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6
Вы можете увидеть из фигуры, даже не глядя на детали, что рост отсутствует, и каждой итерации требуется одинаковое количество места. (Технически, числовой результат растет, что неизбежно и так же верно для while
цикла.) Я не хочу, чтобы вы указывали на безгранично растущий «стек» здесь.
Кажется, что архетипическая семантика лямбда-исчисления уже выполняет то, что обычно называют «оптимизацией хвостового вызова». Конечно, никакой «оптимизации» здесь не происходит. Здесь нет особых правил для «хвостовых» вызовов, в отличие от «обычных» вызовов. По этой причине трудно дать «абстрактную» характеристику того, что делает хвостовой вызов «оптимизация», так как во многих абстрактных характеристиках семантики вызова функций нет ничего, что хвостовой вызов «оптимизации» мог бы сделать!
То, что аналогичное определение fact
во многих языках «переполнение стека», является неспособностью этих языков правильно реализовать семантику вызова функций. (У некоторых языков есть оправдание.) Ситуация примерно аналогична языковой реализации, в которой реализованы массивы со связанными списками. Индексирование в такие «массивы» будет тогда операцией O (n), которая не соответствует ожиданиям массивов. Если бы я сделал отдельную реализацию языка, которая использовала бы реальные массивы вместо связанных списков, вы бы не сказали, что я реализовал «оптимизацию доступа к массивам», вы бы сказали, что я исправил неправильную реализацию массивов.
Итак, отвечая на ответ Ведрака. Стеки не являются "фундаментальными" для рекурсии . В той степени, в которой «подобное стеку» поведение происходит в ходе оценки, это может происходить только в тех случаях, когда циклы (без вспомогательной структуры данных) не будут применимы в первую очередь! Иными словами, я могу реализовать циклы с рекурсией с точно такими же характеристиками производительности. Действительно, и Scheme, и SML содержат циклические конструкции, но обе они определяют их с точки зрения рекурсии (и, по крайней мере в Scheme, do
часто реализуются как макрос, который расширяется до рекурсивных вызовов.) Точно так же, для ответа Йохана ничего не говорит Компилятор должен выдать сборку, описанную Йоханом для рекурсии. В самом деле,точно такая же сборка, используете ли вы циклы или рекурсию. Единственный раз, когда компилятор будет (в некоторой степени) обязан генерировать ассемблер, как то, что описывает Йохан, это когда вы делаете что-то, что в любом случае не выражается циклом. Как показано в статье Стила и продемонстрировано практикой использования таких языков, как Haskell, Scheme и SML, не так уж и редко бывает, что хвостовые вызовы можно «оптимизировать», они всегда могутбыть "оптимизированным". Будет ли конкретное использование рекурсии выполняться в постоянном пространстве, зависит от того, как она написана, но ограничения, которые необходимо применить, чтобы сделать это возможным, - это ограничения, которые вам понадобятся, чтобы привести вашу проблему в форму цикла. (На самом деле они менее строгие. Существуют проблемы, такие как конечные автоматы кодирования, которые более аккуратно и эффективно обрабатываются с помощью вызовов tails, а не циклов, для которых потребуются вспомогательные переменные.) Опять же, единственная рекурсия времени требует выполнения большей работы: когда ваш код в любом случае не является циклом.
Я предполагаю, что Йохан имеет в виду компиляторы C, которые имеют произвольные ограничения на то, когда он будет выполнять «оптимизацию» хвостового вызова. Йохан также, по-видимому, ссылается на такие языки, как C ++ и Rust, когда говорит о «языках с управляемыми типами». RAII идиома от C ++ и присутствует в ржавчине , а также делают вещи , которые внешне выглядят как хвостовые вызовы, а не хвост вызовов (потому что «разрушители» еще нужно назвать). Были предложения использовать другой синтаксис для включения немного другой семантики, которая позволила бы хвостовую рекурсию (а именно вызывать деструкторы допоследний вызов хвоста и, очевидно, запретить доступ к "уничтоженным" объектам). (Сборка мусора не имеет такой проблемы, и все Haskell, SML и Scheme являются языками сборки мусора.) В некоторых отношениях некоторые языки, такие как Smalltalk, представляют «стек» как объект первого класса, в этих В таких случаях «стек» больше не является деталью реализации, хотя это не исключает наличия отдельных типов вызовов с различной семантикой. (Java говорит, что не может из-за того, как обрабатывает некоторые аспекты безопасности, но на самом деле это неверно .)
На практике распространенность нарушенных реализаций вызовов функций обусловлена тремя основными факторами. Во-первых, многие языки наследуют сломанную реализацию от языка реализации (обычно C). Во-вторых, детерминистическое управление ресурсами приятно и делает проблему более сложной, хотя это предлагают только несколько языков. В-третьих, и, по моему опыту, причина, по которой большинство людей волнуется, заключается в том, что им нужны трассировки стека при возникновении ошибок в целях отладки. Только вторая причина может быть теоретически мотивированной.