如何在严格求值语言中实现受控递归?以JS列表拼接为例
Let's break down why your current implementation hits a stack overflow with large lists, how Haskell avoids this, and how we can adapt this behavior to strict JavaScript.
Why Your Current JS Implementation Overflows
Your append implementation for List makes immediate recursive calls when building each Cons node:
appendAdd("List/List", tx => ty => tx.runList({ Nil: ty, Cons: x => tx_ => Cons(x) (append(tx_) (ty)) // Immediate recursive call }));
For a large list (say 10,000 elements), this creates 10,000 nested recursive calls before returning the final list. JavaScript's call stack has limited depth, so this quickly leads to a stack overflow.
How Haskell's (++) Avoids This
Haskell's (++) relies on two critical features:
- Lazy Evaluation: The expression
xs ++ ysisn't computed until its value is actually needed (like when folding or iterating over the list). - Tail Recursion modulo Cons: While
(x:xs) ++ ys = x : (xs ++ ys)isn't strictly tail-recursive (the recursive call is wrapped in a constructor), laziness meansxs ++ ysis stored as an unevaluated thunk instead of being computed right away. We only evaluate one step at a time when accessing elements, keeping the call stack flat.
Implementing Lazy List Append in JavaScript
To replicate this behavior in strict JavaScript, we need to introduce explicit laziness by wrapping recursive calls in thunks (zero-argument functions). Here's how to adjust your implementation:
Step 1: Modify List Constructors for Laziness
Update Cons to accept a thunk for the tail (instead of an already-evaluated List), and add a helper to force thunks when needed:
const FORCE = Symbol("FORCE"); // Modified Cons: tail is a thunk const Cons = x => txThunk => List("Cons")(cases => cases.Cons(x)(txThunk)); const Nil = List("Nil")(cases => cases.Nil); // Helper to force a thunk (resolves nested thunks recursively) const force = tx => { while (typeof tx === "function" && FORCE in tx) { tx = tx(); } return tx; };
Step 2: Adjust Append to Use Lazy Recursion
Update the append implementation to return a thunk for the recursive part instead of calling it immediately:
appendAdd("List/List", tx => ty => { // Wrap append logic in a thunk to delay evaluation const appendThunk = () => tx.runList({ Nil: ty, Cons: x => tx_ => Cons(x)(() => append(tx_)(ty)) // Recursive call is a thunk }); // Mark the thunk so our force helper recognizes it appendThunk[FORCE] = true; return appendThunk; });
Step 3: Update Fold to Handle Lazy Thunks
Modify foldr to force thunks as it traverses the list:
const foldr = f => acc => { const aux = tx => { const forcedTx = force(tx); return forcedTx.runList({ Nil: acc, Cons: x => txThunk => f(x)(aux(txThunk)) }); }; return aux; };
Full Working Example
Here's the complete adjusted code, tested with large lists:
// type constructor const Type = name => { const Type = tag => Dcons => { const t = new Tcons(); Object.defineProperty(t, `run${name}`, { value: Dcons }); t[TAG] = tag; return t; }; const Tcons = Function(`return function ${name}() {}`)(); Tcons.prototype[Symbol.toStringTag] = name; return Type; }; const TAG = Symbol("TAG"); const FORCE = Symbol("FORCE"); const List = Type("List"); // Lazy data constructors const Cons = x => txThunk => List("Cons")(cases => cases.Cons(x)(txThunk)); const Nil = List("Nil")(cases => cases.Nil); // Helper to force lazy thunks const force = tx => { while (typeof tx === "function" && FORCE in tx) { tx = tx(); } return tx; }; // overload binary functions const overload2 = (name, dispatch) => { const pairs = new Map(); return { [`${name}Add`]: (k, v) => pairs.set(k, v), [`${name}Lookup`]: k => pairs.get(k), [name]: x => y => { if (typeof x === "function" && (VALUE in x)) x = x(y); else if (typeof y === "function" && (VALUE in y)) y = y(x); const r = pairs.get(dispatch(x, y)); if (r === undefined) throw new TypeError("No implementation found for the given types"); else if (typeof r === "function") return r(x)(y); else return r; } }; }; const dispatcher = (...args) => args.map(arg => { const tag = Object.prototype.toString.call(arg); return tag.slice(tag.lastIndexOf(" ") + 1, -1); }).join("/"); // Semigroup "typeclass" const { appendAdd, appendLookup, append } = overload2("append", dispatcher); // Lazy List instance for Semigroup appendAdd("List/List", tx => ty => { const appendThunk = () => tx.runList({ Nil: ty, Cons: x => tx_ => Cons(x)(() => append(tx_)(ty)) }); appendThunk[FORCE] = true; return appendThunk; }); // Lazy-aware foldr const foldr = f => acc => { const aux = tx => { const forcedTx = force(tx); return forcedTx.runList({ Nil: acc, Cons: x => txThunk => f(x)(aux(txThunk)) }); }; return aux; }; // Test with a large list (10,000 elements) const buildLargeList = n => { let list = Nil; for (let i = n; i > 0; i--) { list = Cons(i)(() => list); } return list; }; const tx = buildLargeList(10000); const ty = Cons(99999)(() => Nil); const tz = append(tx)(ty); // This won't stack overflow now console.log(foldr(x => acc => `${x}, ${acc}`)("end")(tz));
Key Takeaways
- Explicit Laziness: By wrapping recursive calls in thunks, we delay evaluation until the list is actually traversed (e.g., during folding), avoiding deep call stacks during append.
- Thunk Forcing: Our
forcehelper ensures we only evaluate thunks as needed, keeping the stack depth constant (O(1)) during traversal. - Tail Recursion Modulo Cons: This approach mimics Haskell's behavior—we build a structure that defers recursive work until later, rather than doing it all upfront.
内容的提问来源于stack exchange,提问作者user6445533

