Haskell verwendet Lazy-Evaluation, um die Rekursion zu implementieren. Daher wird alles als Versprechen behandelt, bei Bedarf einen Wert bereitzustellen (dies wird als Thunk bezeichnet). Thunks werden nur so weit wie nötig reduziert, um fortzufahren, nicht mehr. Dies ähnelt der Art und Weise, wie Sie einen Ausdruck mathematisch vereinfachen. Daher ist es hilfreich, ihn so zu betrachten. Die Tatsache, dass die Auswertungsreihenfolge nicht in Ihrem Code angegeben ist, ermöglicht es dem Compiler, viele noch cleverere Optimierungen vorzunehmen, als Sie es bisher gewohnt waren. Kompilieren Sie mit, -O2
wenn Sie optimieren möchten!
Mal sehen, wie wir facSlow 5
als Fallstudie bewerten :
facSlow 5
5 * facSlow 4 -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3) -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2)) -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120
So wie Sie sich Sorgen, haben wir eine Ansammlung von Zahlen , bevor Berechnungen passieren, aber im Gegensatz zu Ihnen besorgt, gibt es keine Stapel von facSlow
Funktionsaufrufen rumhängen zu beenden warten - jede Reduktion angewandt wird , und geht weg, eine Abgangsstapelrahmen in seinem wake (das liegt daran, dass (*)
es streng ist und so die Bewertung seines zweiten Arguments auslöst).
Haskells rekursive Funktionen werden nicht sehr rekursiv ausgewertet! Der einzige Stapel von Anrufen, der herumhängt, sind die Multiplikationen selbst. Wenn dies (*)
als strikter Datenkonstruktor angesehen wird, wird dies als geschützte Rekursion bezeichnet (obwohl dies normalerweise bei nicht strengen Datenkonstruktoren als solche bezeichnet wird, bei denen die Datenkonstruktoren übrig bleiben - wenn sie durch weiteren Zugriff erzwungen werden).
Schauen wir uns nun die Schwanzrekursive an fac 5
:
fac 5
fac' 5 1
fac' 4 {5*1} -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}} -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}} -- the thunk "{...}"
(2*{3*{4*{5*1}}}) -- is retraced
(2*(3*{4*{5*1}})) -- to create
(2*(3*(4*{5*1}))) -- the computation
(2*(3*(4*(5*1)))) -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120
So können Sie sehen, dass die Schwanzrekursion selbst Ihnen weder Zeit noch Raum gespart hat. Insgesamt werden nicht nur mehr Schritte ausgeführt als facSlow 5
, sondern es wird auch ein verschachtelter Thunk (hier als dargestellt {...}
) erstellt, der zusätzlichen Platz benötigt , der die zukünftige Berechnung und die durchzuführenden verschachtelten Multiplikationen beschreibt.
Dieser Thunk wird dann entwirrt, indem er nach unten bewegt wird, wodurch die Berechnung auf dem Stapel neu erstellt wird. Hier besteht auch die Gefahr eines Stapelüberlaufs mit sehr langen Berechnungen für beide Versionen.
Wenn wir dies von Hand optimieren möchten, müssen wir es nur streng machen. Sie können den strengen Anwendungsoperator $!
zum Definieren verwenden
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Dies zwingt facS'
dazu, in seinem zweiten Argument streng zu sein. (Es ist bereits in seinem ersten Argument streng, da dies bewertet werden muss, um zu entscheiden, welche Definition facS'
angewendet werden soll.)
Manchmal kann Strenge enorm helfen, manchmal ist es ein großer Fehler, weil Faulheit effizienter ist. Hier ist es eine gute Idee:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Welches ist, was Sie erreichen wollten, denke ich.
Zusammenfassung
- Wenn Sie Ihren Code optimieren möchten, müssen Sie zunächst mit kompilieren
-O2
- Die Schwanzrekursion ist nur dann gut, wenn sich kein Thunk ansammelt, und das Hinzufügen von Strenge hilft normalerweise, dies gegebenenfalls zu verhindern. Dies geschieht, wenn Sie ein Ergebnis erstellen, das später auf einmal benötigt wird.
- Manchmal ist die Schwanzrekursion ein schlechter Plan, und eine vorsichtige Rekursion passt besser, dh wenn das Ergebnis, das Sie erstellen, Stück für Stück in Teilen benötigt wird. Sehen Sie diese Frage über
foldr
und foldl
zum Beispiel, und testen sie gegeneinander an.
Probieren Sie diese beiden aus:
length $ foldl1 (++) $ replicate 1000
"The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000
"The number of reductions performed is more important than tail recursion!!!"
foldl1
ist schwanzrekursiv, während foldr1
eine geschützte Rekursion durchgeführt wird, so dass das erste Element sofort zur weiteren Verarbeitung / zum weiteren Zugriff präsentiert wird. (Die erste "Klammer" auf der linken Seite wird sofort verwendet, (...((s+s)+s)+...)+s
wodurch die Eingabeliste vollständig auf das Ende gedrängt wird und viel früher als die vollständigen Ergebnisse erstellt werden. Die zweite Klammer wird nach und nach in Klammern gesetzt, wodurch die Eingabe s+(s+(...+(s+s)...))
verbraucht wird Liste Stück für Stück auf, damit das Ganze mit Optimierungen in konstantem Raum arbeiten kann).
Möglicherweise müssen Sie die Anzahl der Nullen anpassen, je nachdem, welche Hardware Sie verwenden.