feat(fjl): #121-generators add lazy replicateIter/unfoldrIter siblings - #130
Open
elycruz wants to merge 1 commit into
Open
feat(fjl): #121-generators add lazy replicateIter/unfoldrIter siblings#130elycruz wants to merge 1 commit into
elycruz wants to merge 1 commit into
Conversation
Adds generator siblings for the two remaining eager `list/` producers, per #121's rule that a generator which would break existing consumers ships as a sibling rather than as a changed return type. Both follow the established `cycle`/`iterate`/`repeat` shape (yield the growing list, `Generator<T[], void>`) and the `range`/`rangeIter` naming precedent. - `replicateIter` / `$replicateIter` - `replicate` stays eager (bounded by `n`, so laziness buys no correctness); the sibling exists because it is the bounded form of `repeat` and `replicateIter(Infinity, x)` is expressible where `replicate` throws. - `unfoldrIter` / `$unfoldrIter` - `unfoldr` stays eager because `unzipN` and `tests/object/index_test.ts` consume its `A[]`; the sibling makes `op`s that never return `undefined` (which hang the eager version) consumable. Eager `unfoldr` is now implemented by draining the generator, so the two cannot drift. - Resolves `UnfoldrOp`'s `@todo` (part of #116) by deciding *not* to flip the generics and documenting why: `<A, B>` is ordered by role, matching `unfoldr<A, B>`, the op's return tuple, and `ScanlOp`/`ScanrOp`. - Documents the `scan*`/`zip*`/`unzip*` audit in `md/issues/GENERATOR_AUDIT.md`: `scanr*` and `unzip*` stay eager permanently, `scanl*`/`zip*` are deferred behind `Iterable` inputs. No scan/zip code changed. No breaking changes - every existing signature and return type is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Finishes the
iterator→ generator tail forlist/.replicateandunfoldrwere the two remaining eager producers; both now have lazy
*Itersiblings, andthe
scan*/zip*/unzip*families have been audited with the verdicts recordedin
md/issues/GENERATOR_AUDIT.md.No existing signature or return type changed. Per #121's own rule — "where a
generator would break existing consumers, keep the eager version and add a
*-suffixed generator sibling rather than changing the return type" — bothconversions took the sibling route.
Closes #121
Contributes to #116
Work-unit id:
121-generatorsNaming
*isn't a legal identifier character, so "*-suffixed" is realized as therepo's existing convention:
range.tsalready ships eagerrangenext to thegenerator
rangeIter, the only eager/lazy sibling pair in the codebase. Newpairs follow it —
replicateIter,unfoldrIter,$replicateIter,$unfoldrIter.rangeItercarries an@todo Rename to 'range', so the eventual v2 intent isgenerator-primary naming. That rename should move
range,replicate, andunfoldrtogether as one deliberate breaking change, not one function at a time.Yield shape
cycle/iterate/repeatdon't yield elements — they yield the list built sofar, growing by one per iteration (
Generator<T[], void>). The new siblingsfollow that exactly, which gives a law that both test files assert:
As in
iterate/repeat, the yielded array is the same growing reference eachtime; callers wanting a snapshot must
sliceit. Documented on both functions.Conversion decisions
replicate→ keep eager, addreplicateIterreplicateis bounded by construction.nis finite, so there is nothing tostop early from and no non-termination to avoid — the motivation that justifies
laziness elsewhere simply doesn't apply. Converting the return type outright
would break every consumer that indexes or
.lengths the result, in exchange fornothing.
The sibling is still worth shipping:
replicateis the bounded form ofrepeat,and
replicateIter(Infinity, x)degenerates torepeat(x)— something eagerreplicatecannot express at all, sinceArray(Infinity)throws. It alsocomposes with the generator-friendly
take/takeWhilefrom #87.Eager
replicatekeeps itsArray(n).fill(...)fast path; a generator is not afaster way to fill a fixed-size array.
unfoldr→ keep eager, addunfoldrIterLaziness is a real fix here, not a nicety: an
opthat never returnsundefinedmakes eagerunfoldrloop forever, andunfoldrItermakes that sameopconsumable.But
unfoldrhas a live in-repo array consumer —src/list/unzipN.tsuses itsresult as a
T[][]seed forfoldl— plustests/object/index_test.ts. Changingthe return type is a breaking change with an in-tree casualty, so: sibling.
Eager
unfoldris now implemented in terms ofunfoldrIter(it drains thegenerator and returns the last yield), so the two cannot drift apart. Behaviour is
identical, including the accumulated-list third argument handed to
op, which nowhas its own regression test.
UnfoldrOp@todo(#116)Resolved as "do not flip", and replaced with an
@noterecording why:UnfoldrOp<A, B>matchesunfoldr<A, B>'s own generic order. Flipping thetype but not the function creates a mismatch; flipping both is a public API
break for zero benefit.
[A, B].in this module.
ScanlOp<A, B> = (b: B, a: A, ...) => Bhas exactly the sameshape: its first generic is not its first parameter. Flipping
UnfoldrOpalone would make it the odd one out.
Generics here are ordered by role (element type first, seed/accumulator
second), not by parameter position. The
@todois gone either way, as required.Scan/zip audit — documentation only, no scan/zip code touched
scanlidepends only on inputs0..i), but input is an already-materializedA[], so a sibling buys nothing until list fns acceptIterable.scanl1scanlscanl.scanrnext()— a misleading laziness contract.scanr1scanr; also callslast/init, both whole-list ops.ziptoShortest, which measures both lists up front, and both params are typedT[].zip3,zip4,zipNzipzipN/toShortest.zipWith,zipWith3,zipWithNzipzip, op applied per pair.unzipunzipNunzip, generalized; folds over the entire input in one pass.Everything marked "deferred" is blocked on the same prerequisite: list functions
accepting
Iterable<T>instead ofT[].take/takeWhilealready do (#87);scanlandzip*do not. ShippingscanlIter/zipItertoday would deliver thelazy half of a pipeline whose eager half still forces the whole input. That
widening touches
toShortest/reduce/lengthand deserves its own issue.Incidental findings — recorded, not fixed
Found while auditing; all pre-existing and out of scope for a laziness ticket.
Verified by running the functions:
scanr's doc-comment is wronghead(scanr(fn, z, xs)) === foldr(fn, z, xs). Actual:scanr(add, 0, [1,2,3])→[3,5,6],foldr→6. It'slast, nothead— the impl pushes right-to-left, so output is reversed vs. Haskell.scanlomits the seedscanl (+) 0 [1,2,3]=[0,1,3,6]; fjl returns[1,3,6]. Doc-comment reproduces the Haskell form. (last(scanl f z xs) === foldl f z xsdoes hold.)scanl1drops the head[1,3,6]; fjl returns[3,6].scanr1drops the last[5,6], symmetric toscanl1.These four are one decision, not four: either
scan*emits the seed (Haskellsemantics, breaking) or the doc-comments are corrected to describe the code
(non-breaking). Belongs with #122's per-module implementation review.
Testing
pnpm test→ 133 suites, 1083 tests, all passing (baseline onmain:133 / 1066 — +17 tests, no suite added since both test files already existed).
pnpm build→ exits 0.npx eslinton all four changed source/test files → clean, no new errors orwarnings. (Repo-wide
pnpm lintstill fails on pre-existing issues, unchanged.)pre-commit,commit-msg, andpre-pushall ran clean.Beyond value correctness, the new tests assert the laziness property that
motivated the change, not just outputs:
replicateIter(Infinity, 'x')yields prefixes and staysdone: false— anon-lazy implementation would never return, and eager
replicatethrows here.unfoldrItercallsopexactly once pernext(), and zero times before thefirst
next()(asserted with a call counter).unfoldrIterwith a never-terminatingop(x => [x, x + 1]) is consumed andbroken out of — the exact case that hangs eager
unfoldr.unfoldr'sopis now covered.Breaking changes
None, by design. Every existing export keeps its signature and return type:
replicate,$replicate,unfoldr,$unfoldr, andUnfoldrOpare unchanged aspublic API. Four new exports are added (
replicateIter,$replicateIter,unfoldrIter,$unfoldrIter), picked up automatically bylist/index.ts'sexisting
export *.unfoldr's internals were rewritten to delegate to thegenerator, with behaviour held identical and covered by tests.
🤖 Generated with Claude Code