I simulate lazy evaluation, i.e. evaluate only when needed and only once, in JS with Proxy
and run into a problem with Traversable (mapA
instead of traverse
at the term level):
const r = List.mapA({map: Opt.map, ap: Opt.ap, of: Opt.of})
(x => (x & 1) === 0 ? null : x)
(List.fromArr([1,3,5]));
Opt.map(Arr.fromList) (r); // yields [1,3,5]
Please note that Opt
isn't an ADT but based on null
. This works as expected: r
is a thunk. Arr.fromList
is strict and eventually forces evaluation of r
. The problem arises when I trigger short circuiting, i.e. pass List.fromArr([1,2,3])
to List.mapA
. When Opt.map
forces the evaluation of the outermost List
layer it yields the first elem 1
and the tail of the list, which is an unevaluated thunk again. Hence Opt.map
assumes continuous computation and applies r
to Arr.fromList
, which in turn forces the evaluation of the entire List
. 2
causes the short circuiting but not within Opt.map
but within List.fromArr
. Hence an error is thrown.
The problem is that due to lazy evaluation the transformation of r
from List
to null
is deferred and thus not handled by the Opt
functor anymore but by the pure functon invoked by it. How does a lazy language like Haskell tackle this issue? Or am I making a mistake in between?
Unfortunately, I cannot provide a running example because it would include a huge amount of library code. Here are implementations of the core functions, in case someone is interested:
List.mapA = ({map, ap, of}) => {
const liftA2_ = liftA2({map, ap}) (List.Cons);
return f => List.foldr(x => acc =>liftA2_(f(x)) (acc)) (of(List.Nil));
};
const liftA2 = ({map, ap}) => f => tx => ty => ap(map(f) (tx)) (ty);
List.foldr = f => acc => function go(tx) { // guarded rec if `f` non-strict in 2nd arg
return tx.run({
nil: acc,
cons: y => ty => f(y) (lazy(() => go(ty)))
// ^^^^^^^^^^^^^^^^^^ thunk
});
};
[EDIT]
Here is a more detailed description of the computation. A thunk is not just the usual nullary function () => ..
but guarded by a Proxy
, so that it can be used as a placeholder for any expression in most cases.
Constructing r
List.fromArr
creates a List{head: 1, tail}
with the tail fully evaluatedList.mapA
applies the head1
to itsf
, which yields1
and the overall op in turn returns{head: 1, tail: thunk}
, i.e. the tail is a thunk (since1
is odd there is no short circuiting)- The evaluation stops because
List.foldr
, of whichList.mapA
is derived of, is non-strict in the tail r
evaluated to{head: 1, tail: thunk}
, i.e. an expression that contains a thunk and is now in weak head normal form (WHNF) (and isn't a thunk itself as I claimed in my original question)- please note that there is no
Opt
layer because optional values are encoded usingnull
, not as an ADT
Convert the traversed list back to Array again
- Lifting the strict
Arr.fromList
withOpt.map
is necessary becauser
may benull
Opt.map
inspects the outermost layer of the list in WHNFr
, so no further evaluation takes place at this point since the expression is already in WHNF- it detects a
List
, which is equivalent toJust [a]
, and invokes the pure functionArr.fromList
to further processr
Arr.fromList
is strict, i.e. it recursively evaluates the tail until the base case is hit- during the second recursion it evaluates the tail, which is a thunk, to
{head: f(2), tail: thunk}
, wheref
is the one fromList.mapA
f(2)
evaluates tonull
and short circuits the original list traversalArr.fromList
expects another list layer, notnull
and throws an error
I have a hunch that List.mapA
must be strict in its list argument. I could easily do it, because there is a strict version of foldr
based on tail recursion modulo cons. However, I doesn't understand the underlying principle when to make an operation strict. If list traversals were strict, so would have to be monadic list chains. That would be a bummer.