Haskell usa la evaluación diferida para implementar la recursividad, por lo que trata cualquier cosa como una promesa de proporcionar un valor cuando sea necesario (esto se llama procesador). Los thunks se reducen solo lo necesario para continuar, no más. Esto se parece a la forma en que simplifica una expresión matemáticamente, por lo que es útil pensar en ello de esa manera. El hecho de que el código no especifique el orden de evaluación le permite al compilador realizar muchas optimizaciones aún más inteligentes que las eliminaciones de llamadas finales a las que está acostumbrado. Compile con -O2
si desea optimización.
Veamos cómo evaluamos facSlow 5
como caso de estudio:
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
Así que, tal como le preocupaba, tenemos una acumulación de números antes de que ocurra cualquier cálculo, pero a diferencia de lo que le preocupaba, no hay una pila de facSlow
llamadas a funciones esperando para terminar: cada reducción se aplica y desaparece, dejando un marco de pila en su wake (eso se debe a que (*)
es estricto y, por lo tanto, desencadena la evaluación de su segundo argumento).
¡Las funciones recursivas de Haskell no se evalúan de una manera muy recursiva! La única pila de llamadas pendientes son las propias multiplicaciones. Si (*)
se ve como un constructor de datos estricto, esto es lo que se conoce como recursividad protegida (aunque generalmente se la conoce como tal con los constructores de datos no estrictos, donde lo que queda a su paso son los constructores de datos, cuando son forzados por un acceso adicional).
Ahora echemos un vistazo a la cola recursiva 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
Entonces puede ver cómo la recursividad de cola por sí sola no le ha ahorrado tiempo ni espacio. No solo toma más pasos en general facSlow 5
, sino que también crea un procesador anidado (que se muestra aquí como {...}
), que necesita un espacio adicional para él, que describe el cálculo futuro, las multiplicaciones anidadas que se realizarán.
Este golpe seco y luego se deshizo por la que atraviesa que a la parte inferior, la recreación de la computación en la pila. Aquí también existe el peligro de provocar un desbordamiento de pila con cálculos muy largos, para ambas versiones.
Si queremos optimizar esto manualmente, todo lo que tenemos que hacer es hacerlo estricto. Puede utilizar el operador de aplicación estricto $!
para definir
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Esto obliga facS'
a ser estricto en su segundo argumento. (Ya es estricto en su primer argumento porque debe evaluarse para decidir qué definición facS'
aplicar).
A veces, el rigor puede ayudar enormemente, a veces es un gran error porque la pereza es más eficiente. Aquí es una buena idea:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Que es lo que querías lograr, creo.
Resumen
- Si desea optimizar su código, el primer paso es compilar con
-O2
- La recursividad de cola solo es buena cuando no hay acumulación de thunk, y agregar rigor generalmente ayuda a prevenirla, si es apropiado. Esto sucede cuando está creando un resultado que se necesita más adelante de una vez.
- A veces, la recursividad de cola es un mal plan y la recursividad protegida es una mejor opción, es decir, cuando el resultado que está generando será necesario poco a poco, en porciones. Vea esta pregunta sobre
foldr
y, foldl
por ejemplo, y compárelos entre sí.
Prueba estos dos:
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
es tail recursive, mientras que foldr1
realiza una recursividad protegida para que el primer elemento se presente inmediatamente para su posterior procesamiento / acceso. (El primero "entre paréntesis" a la izquierda a la vez, (...((s+s)+s)+...)+s
forzando su lista de entrada por completo hasta el final y construyendo un gran número de cálculos futuros mucho antes de que se necesiten sus resultados completos; el segundo entre paréntesis a la derecha gradualmente s+(s+(...+(s+s)...))
, consumiendo la entrada lista poco a poco, por lo que todo puede funcionar en un espacio constante, con optimizaciones).
Es posible que deba ajustar el número de ceros según el hardware que esté utilizando.