The slow fix
calls f
on each step of recursion, while the fast one calls f
exactly once. It can be visualized with tracing:
import Debug.Trace
fix f = f (fix f)
fix' f = let x = f x in x
facf :: (Int -> Int) -> Int -> Int
facf f 0 = 1
facf f n = n * f (n - 1)
tracedFacf x = trace "called" facf x
fac = fix tracedFacf
fac' = fix' tracedFacf
Now try some running:
> fac 3
called
called
called
called
6
> fac' 3
called
6
In more detail, let x = f x in x
results in a lazy thunk being allocated for x
, and a pointer to this thunk is passed to f
. On first evaluating fix' f
, the thunk is evaluated and all references to it (here specifically: the one we pass to f
) are redirected to the resulting value. It just happens that x
is given a value that also contains a reference to x
.
I admit this can be rather mind-bending. It's something that one should get used to when working with laziness.