Diese anderen Antworten sind etwas irreführend. Ich bin damit einverstanden, dass sie Implementierungsdetails angeben, die diese Ungleichheit erklären können, aber sie übertreiben den Fall. Wie richtig von jmite vorgeschlagen, sie sind umsetzungsorientiert in Richtung gebrochen Implementierungen von Funktionsaufrufen / Rekursion. Viele Sprachen implementieren Schleifen über Rekursion, so dass Schleifen in diesen Sprachen eindeutig nicht schneller sind. Die Rekursion ist theoretisch in keiner Weise weniger effizient als das Schleifen (wenn beide anwendbar sind). Lassen Sie mich den Auszug aus Guy Steeles 1977 erschienenen Aufsatz zitieren, in dem der Mythos "Expensive Procedure Call" entlarvt wird
Folklore besagt, dass GOTO-Anweisungen "billig" sind, während Prozeduraufrufe "teuer" sind. Dieser Mythos ist größtenteils auf schlecht gestaltete Sprachimplementierungen zurückzuführen. Das historische Wachstum dieses Mythos wird berücksichtigt. Es werden sowohl theoretische Ideen als auch eine bestehende Implementierung diskutiert, die diesen Mythos entlarven. Es zeigt sich, dass die uneingeschränkte Verwendung von Prozeduraufrufen eine große stilistische Freiheit erlaubt. Insbesondere kann jedes Flussdiagramm als "strukturiertes" Programm geschrieben werden, ohne zusätzliche Variablen einzuführen. Die Schwierigkeit mit der GOTO-Anweisung und dem Prozeduraufruf wird als Konflikt zwischen abstrakten Programmierkonzepten und konkreten Sprachkonstrukten charakterisiert.
Der "Konflikt zwischen abstrakten Programmierkonzepten und konkreten Sprachkonstrukten" zeigt sich daran, dass die meisten theoretischen Modelle, zum Beispiel der untypisierte Lambda-Kalkül , keinen Stapel haben . Natürlich ist dieser Konflikt nicht notwendig, wie das obige Papier zeigt und wie auch Sprachen zeigen, die keinen anderen Iterationsmechanismus als die Rekursion haben, wie Haskell.
Lass es mich demonstrieren. Der Einfachheit halber werde ich einen "angewendeten" Lambda-Kalkül mit Zahlen und und Booleschen Werten verwenden und davon ausgehen, dass wir einen Festkomma-Kombinator haben fix
, der befriedigt fix f x = f (fix f) x
. All dies kann auf die untypisierte Lambda-Rechnung reduziert werden, ohne mein Argument zu ändern. Der archetypische Weg zum Verständnis der Bewertung des Lambda-Kalküls ist das Umschreiben von Begriffen mit der zentralen Umschreiberegel der Beta-Reduktion, nämlich wobei "alles frei ersetzen" bedeutet Vorkommen von in mit "und( λ x . M) N⇝ M[ N/ x]x M N ≤[ N/ x]xMN⇝bedeutet "schreibt um". Dies ist nur die Formalisierung des Ersetzens der Argumente eines Funktionsaufrufs in den Funktionskörper.
Nun zum Beispiel. Definiere fact
als
fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1
Hier ist die Bewertung von fact 3
, wo für die Kompaktheit, ich g
als Synonym für fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1))
, dh verwenden werde fact = g 1
. Dies hat keinen Einfluss auf mein Argument.
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
Sie können an der Form erkennen, ohne die Details zu betrachten, dass es kein Wachstum gibt und jede Iteration dieselbe Menge an Platz benötigt. (Technisch gesehen wächst das numerische Ergebnis, was für eine while
Schleife unvermeidlich und genauso wahr ist.) Ich fordere Sie heraus, hier auf den grenzenlos wachsenden "Stapel" hinzuweisen.
Es scheint, dass die archetypische Semantik des Lambda-Kalküls bereits das tut, was gemeinhin als "Tail-Call-Optimierung" bezeichnet wird. Natürlich findet hier keine "Optimierung" statt. Es gibt hier keine speziellen Regeln für "Tail" -Anrufe im Gegensatz zu "normalen" Anrufen. Aus diesem Grund ist es schwierig, eine "abstrakte" Charakterisierung dessen zu geben, was die "Optimierung" des Endaufrufs bewirkt, da in vielen abstrakten Charakterisierungen der Funktionsaufrufsemantik nichts für die "Optimierung" des Endaufrufs zu tun ist!
Dass die analoge Definition von fact
in vielen Sprachen "Stack Overflows" ein Versagen dieser Sprachen ist, Funktionsaufrufsemantik korrekt zu implementieren. (Einige Sprachen haben eine Entschuldigung.) Die Situation ist ungefähr analog zu einer Sprachimplementierung, die Arrays mit verknüpften Listen implementiert. Das Indizieren in solche "Arrays" wäre dann eine O (n) -Operation, die die Erwartungen von Arrays nicht erfüllt. Wenn ich eine separate Implementierung der Sprache vornehmen würde, die echte Arrays anstelle von verknüpften Listen verwendet, würden Sie nicht sagen, dass ich "Array-Zugriffsoptimierung" implementiert habe, sondern, dass ich eine fehlerhafte Implementierung von Arrays behoben habe.
Also, auf die Antwort von Veedrac antworten. Stapel sind für eine Rekursion nicht "grundlegend" . Soweit im Verlauf der Auswertung ein "stapelartiges" Verhalten auftritt, kann dies nur in Fällen vorkommen, in denen Schleifen (ohne Hilfsdatenstruktur) überhaupt nicht anwendbar wären! Anders ausgedrückt, ich kann Schleifen mit Rekursion mit genau den gleichen Leistungsmerkmalen implementieren. Tatsächlich enthalten sowohl Scheme als auch SML Schleifenkonstrukte, aber beide definieren diese in Bezug auf Rekursion (und werden zumindest in Scheme do
häufig als Makro implementiert , das sich zu rekursiven Aufrufen erweitert.) In ähnlicher Weise sagt für Johans Antwort nichts a Der Compiler muss die von Johan beschriebene Assembly für die Rekursion ausgeben. Tatsächlich,genau die gleiche Assembly, ob Sie Schleifen oder Rekursion verwenden. Das einzige Mal, dass der Compiler (etwas) verpflichtet wäre , Assemblys wie das, was Johan beschreibt, zu senden, ist, wenn Sie etwas tun, das durch eine Schleife sowieso nicht ausgedrückt werden kann. Wie in Steeles Aufsatz dargelegt und anhand der tatsächlichen Praxis von Sprachen wie Haskell, Scheme und SML demonstriert, ist es nicht "außerordentlich selten", dass Tail Calls "optimiert" werden können, sondern immer"optimiert" werden. Ob eine bestimmte Verwendung der Rekursion in einem konstanten Raum ausgeführt wird, hängt davon ab, wie sie geschrieben wurde. Die Einschränkungen, die Sie anwenden müssen, um dies zu ermöglichen, sind jedoch die Einschränkungen, die Sie benötigen, um Ihr Problem in die Form einer Schleife zu bringen. (Tatsächlich sind sie weniger streng. Es gibt Probleme wie das Codieren von Zustandsautomaten, die über Tails-Aufrufe sauberer und effizienter gehandhabt werden als Schleifen, die Hilfsvariablen erfordern würden.) Auch hier ist die einzige Zeit, in der die Rekursion mehr Arbeit erfordert, die Durchführung wenn dein Code sowieso keine Schleife ist.
Ich vermute, Johan bezieht sich auf C-Compiler, die willkürliche Einschränkungen haben, wann sie die Tail-Call- "Optimierung" durchführen. Johan spricht vermutlich auch von Sprachen wie C ++ und Rust, wenn es um "Sprachen mit verwalteten Typen" geht. Das RAII- Idiom aus C ++ und auch in Rust macht Dinge, die oberflächlich wie Tail Calls aussehen, nicht wie Tail Calls (weil die "Destruktoren" noch aufgerufen werden müssen). Es hat Vorschläge gegeben, eine andere Syntax zu verwenden, um sich für eine etwas andere Semantik zu entscheiden, die eine Schwanzrekursion ermöglichen würde (d. H. Aufrufdestruktoren zuvor)der letzte Tail Call und offensichtlich nicht auf "zerstörte" Objekte zugreifen zu dürfen). (Bei der Garbage Collection gibt es kein solches Problem, und alle Sprachen von Haskell, SML und Scheme sind Garbage Collected-Sprachen.) In einer ganz anderen Art und Weise machen einige Sprachen, wie z. B. Smalltalk, den "Stapel" als erstklassiges Objekt in diesen Sprachen verfügbar In einigen Fällen ist der "Stapel" kein Implementierungsdetail mehr, was jedoch separate Aufruftypen mit unterschiedlicher Semantik nicht ausschließt. (Java sagt, dass dies aufgrund der Art und Weise, wie einige Sicherheitsaspekte behandelt werden, nicht möglich ist , aber dies ist tatsächlich falsch .)
In der Praxis kommt die Prävalenz fehlerhafter Implementierungen von Funktionsaufrufen von drei Hauptfaktoren. Erstens erben viele Sprachen die fehlerhafte Implementierung von ihrer Implementierungssprache (normalerweise C). Zweitens ist deterministisches Ressourcenmanagement nett und kompliziert das Thema, obwohl nur eine Handvoll Sprachen dies bieten. Drittens, und meiner Erfahrung nach interessieren sich die meisten Menschen dafür, dass sie Stack-Traces benötigen, wenn Fehler zu Debug-Zwecken auftreten. Nur der zweite Grund kann möglicherweise theoretisch motiviert sein.