Haskell utilizza la valutazione lazy per implementare la ricorsione, quindi considera qualsiasi cosa come una promessa per fornire un valore quando necessario (questo è chiamato thunk). I thunk vengono ridotti solo quanto necessario per procedere, non di più. Questo assomiglia al modo in cui semplifichi matematicamente un'espressione, quindi è utile pensarla in questo modo. Il fatto che l'ordine di valutazione non sia specificato dal codice consente al compilatore di eseguire molte ottimizzazioni ancora più intelligenti rispetto alla semplice eliminazione della chiamata di coda a cui sei abituato. Compila con -O2
se vuoi l'ottimizzazione!
Vediamo come valutiamo facSlow 5
come caso di studio:
facSlow 5
5 * facSlow 4
5 * (4 * facSlow 3)
5 * (4 * (3 * facSlow 2))
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120
Quindi, proprio come ti preoccupavi, abbiamo un accumulo di numeri prima che avvenga qualsiasi calcolo, ma a differenza di te preoccupato, non ci sono stack di facSlow
chiamate di funzione in attesa di essere terminate - ogni riduzione viene applicata e scompare, lasciando uno stack frame nel suo wake (questo perché (*)
è rigoroso e quindi attiva la valutazione del suo secondo argomento).
Le funzioni ricorsive di Haskell non vengono valutate in modo molto ricorsivo! L'unica pila di chiamate in giro sono le moltiplicazioni stesse. Se (*)
è visto come un costruttore di dati rigoroso, questo è ciò che è noto come ricorsione protetta (anche se di solito viene indicato come tale con i costruttori di dati non rigidi, dove ciò che rimane sulla sua scia sono i costruttori di dati, se forzato da un ulteriore accesso).
Ora diamo un'occhiata alla coda ricorsiva fac 5
:
fac 5
fac' 5 1
fac' 4 {5*1}
fac' 3 {4*{5*1}}
fac' 2 {3*{4*{5*1}}}
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}
(2*{3*{4*{5*1}}})
(2*(3*{4*{5*1}}))
(2*(3*(4*{5*1})))
(2*(3*(4*(5*1))))
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120
Quindi puoi vedere come la ricorsione della coda da sola non ti abbia salvato tempo o spazio. Non solo richiede più passaggi complessivi facSlow 5
, ma crea anche un thunk annidato (mostrato qui come {...}
) - che richiede uno spazio aggiuntivo per esso - che descrive il calcolo futuro, le moltiplicazioni annidate da eseguire.
Questo thunk viene poi dipana attraversando essa verso il basso, ricreando il calcolo in pila. C'è anche il pericolo di causare un overflow dello stack con calcoli molto lunghi, per entrambe le versioni.
Se vogliamo ottimizzarlo manualmente, tutto ciò che dobbiamo fare è renderlo rigoroso. È possibile utilizzare l'operatore dell'applicazione rigorosa $!
per definire
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Questo costringe facS'
ad essere rigoroso nel suo secondo argomento. (È già rigoroso nel suo primo argomento perché deve essere valutato per decidere quale definizione facS'
applicare.)
A volte il rigore può aiutare enormemente, a volte è un grosso errore perché la pigrizia è più efficiente. Ecco una buona idea:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Che è quello che volevi ottenere, credo.
Sommario
- Se vuoi ottimizzare il tuo codice, il primo passo è compilare con
-O2
- La ricorsione della coda è utile solo quando non c'è accumulo di thunk e l'aggiunta di rigidità di solito aiuta a prevenirlo, se e dove appropriato. Questo accade quando stai costruendo un risultato che è necessario in seguito tutto in una volta.
- A volte la ricorsione della coda è un cattivo piano e la ricorsione sorvegliata è più adatta, cioè quando il risultato che stai costruendo sarà necessario poco a poco, in porzioni. Vedi questa domanda su
foldr
e foldl
per esempio, e mettili alla prova l'uno contro l'altro.
Prova questi due:
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
è ricorsivo di coda, mentre foldr1
esegue la ricorsione protetta in modo che il primo elemento venga immediatamente presentato per ulteriori elaborazioni / accessi. (La prima "parentesi" a sinistra immediatamente, (...((s+s)+s)+...)+s
forzando la sua lista di input completamente alla sua fine e costruendo un grande thunk di calcoli futuri molto prima che i suoi risultati completi siano necessari; la seconda parentesi a destra gradualmente s+(s+(...+(s+s)...))
,, consumando l'input list un po 'alla volta, così il tutto è in grado di operare in spazio costante, con ottimizzazioni).
Potrebbe essere necessario regolare il numero di zeri a seconda dell'hardware in uso.