diff --git a/.claude/skills/refactoring-entry/LESSONS.md b/.claude/skills/refactoring-entry/LESSONS.md index 0a36a59..68c4200 100644 --- a/.claude/skills/refactoring-entry/LESSONS.md +++ b/.claude/skills/refactoring-entry/LESSONS.md @@ -75,3 +75,76 @@ brief.md or workflow.js and deleted here. Keep the calibration table current. after the item) attach to the *enclosing* `
    ` at a break point and split the list. The working pattern is: a single `
      ` with raw `
    1. ` items, and hand-render inline markdown to ``/`` yourself. Folded into SKILL.md §3, workflow.js research prompt, and brief.md. + +## 2026-09-26 — 02 Replace mutable fields with lenses + +- **Scala/Haskell Spec must compare *result values*, not the Before/After records.** The two sides carry + different record types (they must — the point is that After's type can differ). Asserting + `Before.x(...) ==== After.x(...)` where the tuple/record types differ makes hedgehog upcast to `Any` + and report structural inequality, or fail to compile without a derived `Eq`. Compare projections: + `(before.field, ...) ==== (after.field, ...)`. The reference extract-method avoided this by returning + primitives; lens examples return records, so this bites immediately. → rule in brief. +- **A lens is a focus on one path; composition is for nested paths, not merging sibling fields.** A first + 02 tried `compose(x, y)` on two lenses with the same source to build a pair-lens — that is not a lens + (no single field focus), and it does not typecheck. The textbook "compose two lenses" example is a + nested record: `player . x`. When the refactoring is about *every element* (all sizes in a tree), a + single lens does not fit — that is a traversal; the coherent example uses a total per-node lens inside + the recursion. → design note for future lens-like entries. +- **A partial lens (entryFile on a sum) forces `error`/`sys.error` in the getter; a reviewer would flag + it and the property can only test the defined region.** Switched 03 to a total `Lens[Node, Int]` on a + plain record — partiality is a real pitfall to *write about*, not a good example shape. +- **Mutation-checking discipline.** A wrong-typed mutant is not a valid test; the compose-swap mutant + was a type error in both languages (itself evidence the lens types are sound). Use sign flips, + off-by-ones, dropped cases as mutants; drop mutants that fail to compile rather than reporting a pass. +- **Toolchain.** `scala-cli run --main-class spec` works; `docker run cp-hedgehog runghc -i + /Spec.hs` works. Width rule held at 72 chars for all sources. Time: examples+verify ≈ 45 min; + mutation+probes ≈ 15 min. +- **Workflow-runner gap.** This environment has no Claude Code `Workflow` batch runner; executed the + workflow phases directly (research → citation verify → examples → mutate/probes → diagrams). Recorded + findings here so the skill's workflow prompt matches when the runner is available. + +## 2026-09-26 — 02 Replace mutable fields with lenses (post-PR review rework) + +- **Reviewer (kryptt) asked for the optics framing, not the lens framing.** The entry should teach + *optics* (lens · prism · traversal) and the "how to reach vs what to do" separation, mention that + optics are lawful *by construction* (not per-example law properties in the page), reuse the eo + cookbook recipes, and justify the inverse by decoupling not paying for itself (no cross-domain + boundaries). Law-solvers (cats-eo-laws, monocle-law, genvalidity-hspec-optics) belong in + Verification, not as hand-written law properties. +- **Rule.** Example trios should escalate *nesting* (single node → every tree node → sparse walk over a + list) so the composed optic's value is visible; the setter-optic encoding (`(a -> a) -> (s -> s)`) + is compact but reads cryptic next to same-shaped Lens/Prism data types — prefer explicit + `Lens`/`Prism`/`Traversal` cases with `compose` (prism .andThen lens, each .andThen prism .andThen + lens) for the page. +- **Rule (spec comparison).** Keep comparing via projected tuples/`from*` converters (Before/After + have distinct record types), and add a *purpose* second property (hit/miss, only-X-changed) rather + than also the lens laws — laws are by construction, the purpose property is the page's real check. +- **Generators.** `Gen.unicode` yields control chars (`\NUL`) that `toUpper` leaves alone; for + "all names uppercased" style invariants use `Gen.alpha` (ASCII letters) or exclude non-letter + inputs explicitly. `Gen.list/Gen.string` argument order differs between Scala and Haskell hedgehog. +- **Time.** Rework cost ≈ 1h (examples+specs+diagrams+page). The eo cookbook itself is the source of + truth for optics recipes; reference it with anchors. + +## 2026-09-26 — 02 shared/ setup must not appear on the page (post-PR review round 2) + +- **Rule (blocking).** Setup shared between examples is *accidental complexity* if shown. A lens + entry redefining `Lens`/`Prism`/`PartialLens` in every `After` buries the motivation in the + definition of the tool. Move shared machinery to `pages/refactorings//shared/` — compiled + by `run.sh` (`scala-cli run "$d" --main-class spec "$SHARED"`, `runghc -i"$SHARED"`), never + `include_relative`'d. Executed in 02: `shared/Optics.scala|.hs` and `shared/SpecRunner.scala`; + the page says the shared files are hidden, each example is the move itself. + → folded into SKILL.md §3 and brief.md. +- **Skill must be agent-agnostic.** It lived in `.claude/skills/` and assumed the Claude Code + `Workflow(...)` runner. Rewrote §2 (the workflow) to describe the phases generically — research · + examples · review · diagrams — with the Claude `workflow.js`/`journal.jsonl` as one optional + orchestrator, and generalized the `gh`/ship steps. The `Workflow` script stays but nothing else + assumes Claude. +- **Rule (blocking).** Do not hand-write intermediate helper methods in an example — like the + hand-defined `everywhere` that used to sit in an "across a whole tree" example's After and + buries the point (the optic is the reusable bit, not the walk). If the example needs shared + walk machinery, add `Plated`/`everywhere` to `shared/` (a `trait`/`class` with a `descend` + instance per type; `everywhere f s = descend (everywhere f) (f s)`) and declare the type's + `Plated` instance in the example — the walk comes from the library, the example only says + which fields recurse. Same for any helper (a fold over the tree, a traversal builder): it + belongs in `shared/` or a library, not re-derived in the example. + → folded into SKILL.md §3 and brief.md ("Never hand-write helpers"). diff --git a/.claude/skills/refactoring-entry/SKILL.md b/.claude/skills/refactoring-entry/SKILL.md index a5f3bbe..f325fe5 100644 --- a/.claude/skills/refactoring-entry/SKILL.md +++ b/.claude/skills/refactoring-entry/SKILL.md @@ -26,34 +26,30 @@ the steps below and the `workflow.js` prompts; if you find one that has not, fol 1. Branch from `master`: `refactoring/`. 2. Pick the entry: the first item in `_data/refactorings.yml` without a `slug`, unless the user names one. Add `slug: ` to it (that is what links it on the index page). -3. Toolchain check (the workflow agents assume these work): +3. Toolchain check (the agents assume these work): - `scala-cli --version` (hedgehog `qa.hedgehog::hedgehog-core:0.14.0` + `hedgehog-runner:0.14.0`). - `docker image inspect cp-hedgehog` — if missing, `sh pages/refactorings/extract-method/run.sh` - builds it (about 2.5 minutes). GHC is not installed on the host; nix is read-only. + builds it (about 2.5 minutes). GHC is not installed here; nix is read-only. 4. Write the brief to the scratchpad: copy `brief.md` from this directory, fill the `<...>` fields. - The workflow agents read the brief, not this file. - -## 2. Run the workflow (research ∥ examples → verify loop → diagrams) - -``` -Workflow({ - scriptPath: ".claude/skills/refactoring-entry/workflow.js", - args: { - slug: "", name: "", number: , total: 35, - brief: "", scratch: "", - repo: "", - seeds: ["", "", ...], - examples: ["", "", ""] - } -}) -``` - -- The script runs two tracks in parallel: **research → per-citation skeptics → fix-up**, and - **examples → reviewer (mutation + adversarial probes) → fix, up to 3 rounds → diagrams**. -- If an agent dies (session limit, API error), relaunch with `resumeFromRunId`; finished agents replay - from cache. Read `journal.jsonl` before assuming a result is empty. -- While it runs: write `pages/refactorings/.md` from the extract-method page (next step), and - build the site in a scratch copy with stub SVGs to catch Liquid errors early. + The agents that build the entry read the brief, not this file. + +## 2. Run the workflow (research ∥ examples → review → diagrams) + +This skill describes a *workflow*, not a Claude-specific runner. The phases below are mandatory; +how you parallelise them depends on your agent runtime: + +- **Research** — with verified citations (one skeptic pass per reference), and +- **Examples** — build + run the 3 Before/After/Spec pairs in both languages, then +- **Review** — mutation-check + adversarial probes; fix, up to 3 rounds, then +- **Diagrams** — inline SVG koan + one per example. + +If your runtime provides a batch/orchestration runner (e.g. `workflow.js` in this directory is a +Claude Code `Workflow` script that runs both tracks in parallel and resumes crashed agents from +`journal.jsonl`), use it with `slug/name/number/total/brief/scratch/repo/seeds/examples` args — but +any agent can run the phases itself in order (research → citation checks → examples → review → +diagrams), which is exactly what `workflow.js` does under the hood. While research runs, write +`pages/refactorings/.md` from the extract-method page (next step) and build the site in a +scratch copy with stub SVGs to catch Liquid errors early. ## 3. Assemble the page @@ -82,9 +78,25 @@ Rules that came from review, keep them: markdown inside raw block HTML, and IALs on numbered items break the list). - Source lines ≤ 72 characters or the pane scrolls horizontally on a 1280px screen. - No `{{` `}}` `{%` `%}` in any included source (Liquid runs before highlighting). -- Copy `run.sh` from extract-method unchanged into the new sources directory. +- Copy `run.sh` from extract-method unchanged into the new sources directory (then adjust it only + if the entry has a real reason to, e.g. a `shared/` module — see next bullet). - Add the new sources directory to `exclude:` in `_config.yml` (sources are included via `include_relative`, not published as static files). +- **Setup shared across examples lives in `shared/` and is never shown on the page.** If the entry + needs shared machinery (an optic library, a hedgehog spec runner, a common data type), put it in + `pages/refactorings//shared/` and have `run.sh` compile it (`scala-cli run "$d" … "$SHARED"` + for Scala, `-i"$SHARED"` for Haskell) — but do NOT `include_relative` it. Each Example + `Before/After/Spec` on the page is then the *move itself*, with a one-line note in the page that + the shared setup is hidden. The reviewer considers inline setup (e.g. redefining a Lens type in + every After) accidental complexity that buries the motivation. +- **Never hand-write intermediate helpers in an example.** If the example needs a walk over a + recursive type, a fold, or a traversal builder, do not define it in the example's `After` — + add it to `shared/` (e.g. a `Plated` class with `descend`/`everywhere`, as in eo's "visit + across whole trees" recipe) or use a library, and have the example declare only the instance + (`which fields recurse`). A hand-rolled `everywhere` in an example buries the motivation: the + optic is the reusable thing, not the helper. This is the same rule as the `shared/` bullet + above — shared machinery stays off the page — extended to any intermediate helper the + example needs. - Do not claim totality or purity the code does not have (e.g. `Int` `div` overflow). ## 4. Verify before the PR @@ -100,7 +112,8 @@ Rules that came from review, keep them: ## 5. Ship Commit sources + page + data + config on the branch; push (SSH remote works; if `gh auth status` -fails ask the user to run `! gh auth login -h github.com -p ssh -w`); open the PR with the +fails ask the user to run `! gh auth login -h github.com -p ssh -w` — or use whichever GitHub +tool your environment provides); open the PR with the reviewer's mutation table and probe summary in the body. The PR preview URL is `https://www.constructive.dev/pr-preview/pr-/refactorings//`. diff --git a/.claude/skills/refactoring-entry/brief.md b/.claude/skills/refactoring-entry/brief.md index cb78550..61c8a9b 100644 --- a/.claude/skills/refactoring-entry/brief.md +++ b/.claude/skills/refactoring-entry/brief.md @@ -38,7 +38,9 @@ when it does not apply. ## Code layout (examples agent writes these; reviewer runs them) pages/refactorings// - run.sh # copied unchanged from extract-method; runs every NN-*/ dir in both languages + run.sh # copied from extract-method; runs every NN-*/ dir in both languages + shared/ # shared setup (optics, spec runner, common types) — compiled in, + # NEVER included on the page; each example is then the move itself NN-/Before.scala # object Before { ... } the pre-refactoring program NN-/After.scala # object After { ... } the refactored version (same public entry point signature) NN-/Spec.scala # hedgehog property: forAll generated inputs, Before.f(x) ==== After.f(x). `@main def spec()`. @@ -49,7 +51,12 @@ pages/refactorings// * every file self-contained, readable, SHORT (Before/After 8–25 lines each); lines ≤ 72 characters; * NO `{{`, `}}`, `{%`, `%}` sequences anywhere (Liquid would eat them); * one-line comment at the top of Before/After saying what the program does; no essay comments; - * the Scala and Haskell Befores must be parallel (same shape: if Scala has an inline lambda, so does Haskell). + * the Scala and Haskell Befores must be parallel (same shape: if Scala has an inline lambda, so does Haskell); + * **never hand-write intermediate helpers in an example** — if the example needs a walk over a + recursive type, a fold, or a traversal builder, put it in `shared/` (e.g. a `Plated` + class with `descend`/`everywhere`, as in eo's "visit across whole trees" recipe) or use a + library, and declare only the type's instance in the example (`which fields recurse`). A + hand-rolled helper in an example buries the move under the tool. - Scala: Scala 3.3.x, directives at the top of Spec.scala only: //> using scala 3.3.4 //> using dep qa.hedgehog::hedgehog-core:0.14.0 diff --git a/_config.yml b/_config.yml index 12eb19d..8a7b84b 100644 --- a/_config.yml +++ b/_config.yml @@ -42,3 +42,4 @@ plugins: [jekyll-paginate, jekyll-seo-tag, jekyll-feed, jekyll-remote-theme] # include_relative; do not also publish them as static files. exclude: - pages/refactorings/extract-method/ + - pages/refactorings/replace-mutable-fields-with-lenses/ diff --git a/_data/refactorings.yml b/_data/refactorings.yml index 264354c..bf61454 100644 --- a/_data/refactorings.yml +++ b/_data/refactorings.yml @@ -5,7 +5,7 @@ - group: "Refactorings" items: - { name: "Extract / Inline method", slug: extract-method } - - { name: "Replace mutable fields with lenses" } + - { name: "Replace mutable fields with lenses", slug: replace-mutable-fields-with-lenses } - { name: "Replace global state with parameter" } - { name: "Introduce ReaderT (Kleisli)" } - { name: "Replace loop with fold" } diff --git a/pages/refactorings/replace-mutable-fields-with-lenses.md b/pages/refactorings/replace-mutable-fields-with-lenses.md new file mode 100644 index 0000000..e10025d --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses.md @@ -0,0 +1,422 @@ +--- +layout: page +title: Replace Mutable Fields with Lenses +subtitle: "Replace mutable fields with lenses: package how to reach a field and what to do with it as one optic value — read becomes get, write becomes set or modify; inline the lens, and a projection no one composes becomes a field again" +permalink: /refactorings/replace-mutable-fields-with-lenses/ +tags: [refactorings, replace-mutable-fields-with-lenses] +hide: true # entry pages are reached from the catalogue, not the top-right nav +--- + +

      ← The refactoring catalogue · 2 of 35

      + +A mutable field is a place where a class's invariants can be broken: +any method can read it, any method can write it, and nothing stops a +write from putting the object into a state the other methods did not +anticipate. The object-oriented ladder up this smell is well worn — +Fowler's *Encapsulate Variable* (the catalogue entry that used to be +*Encapsulate Field*, and before that *Self-Encapsulate Field*) hides +the field behind accessor methods [[1](#ref-1)], [[2](#ref-2)], and *Remove Setting +Method* then deletes the setter once the field no longer needs to be +written from outside the class [[3](#ref-3)]. The field is still a field; it is +just reached through a corridor. + +The functional reading starts at the same smell and goes further out. +A field of an immutable record is a *projection* — a way to reach +into a value — and the useful thing about the world of optics is that +these projections *compose*. A lens reaches one field of a record, a +prism one branch of a sum, a traversal every element of a container, +and the projection you need for a job is built by chaining smaller +ones: `prism .andThen lens`, `each .andThen prism .andThen lens`. +That separation is the whole point — it splits *how do I get to field +A* from *what should I do once I have it* — so the same tiny optic is +reused on one node, on every node of a tree, and against a branch of +a sum inside a list, without being rewritten. Replacing a mutable +field with a lens means making the record immutable and routing every +read through `get` and every write through `set` (or `modify`, which +reads, applies a pure function and writes in one step). The state +transition is a value again, and the optic combinators are lawful by +construction — the contract that makes a rewrite a refactoring is +built in, not hoped for — so correctness comes for free; the +libraries ship law-solvers that confirm any optic you write by hand +[[4](#ref-4)], [[11](#ref-11)]. + +The inverse direction is justified when the decoupling does not earn +its keep: a lens that is never composed, a projection whose separation +of *how* from *what* no one exploits, or — the strongest smell — a +codebase with no cross-domain boundaries, where the whole record +travels everywhere and only the target of the projection would ever +need to pass across a seam. Inlining the lens — replacing `get`, +`set` and `modify` at their use sites with direct field access — +removes indirection that no longer names anything. The catalogue +presents the two directions as one equation, readable both ways; +which way you go records a judgement about whether the field is part +of a *path* or a *leaf*. The [eo cookbook](https://eo.constructive.dev/cookbook) +is a worked, tested reference for the optics side of this: three jobs +optics do best — navigate structures, decouple modules, thread +effects — each recipe runnable against the library. + +## Motivation + +Reach for the lens when access to a field needs to be *decoupled from +its manipulation*. The smell is multiple long methods that do too +much — the complexity of reaching the values is mixed up with the +code for the changes you want to make, and each operation re-derives +its own path. A lens separates the two: how you get to a value and +what you do once you have it stop being one entangled method. The +optics version of the corridor is a *value*, so it can be passed +around, stored, and composed into paths that reach several levels +down without ever repeating the intermediate records; and a function +that needs "every `Instant` in whatever you hand me" can call the +optic instead of the type. When the invariant itself matters — a +balance that must never go negative, a size that must bracket its +children — the pure writer makes the transition a value the type +system and the tests can see, and a *bad* write is a failed check +rather than a corrupted object. + +Reach for the inverse when the lens is speculative generality, or when +there is nothing for the decoupling to mediate. The smell that drives +Inline is the mirror image: a field whose updates are all one level +deep, a lens composed nowhere, a get/set pair whose writer is +`const`-like and whose reader is the identity — and no cross-domain +boundaries in sight, so the full structure may pass through every seam +and nothing is ever isolated to the target of the projection. The +abstraction costs a reader a detour — what is `set v₂ (set v₁ s)` +doing when the program only ever calls `set` once? — and it costs the +compiler nothing it can deduce. When all the code does with the field +is read it once, or write it once, a plain field or a pair of +functions is clearer. + +## The move + +Fowler's mechanics are short. *Encapsulate Variable*: create a +function that reads the field, create one that writes it, replace +every read with a call to the reader and every write with a call to +the writer, and test [[2](#ref-2)]; the precondition is that nothing reaches the +field directly. *Remove Setting Method*: once the field can be +initialised and never needs to be reassigned, delete the setter and +initialise at construction [[3](#ref-3)]. Read together they are the OO route to +what the lens packages: reads through a named getter, writes through a +named setter, and no bare `field = ...` anywhere. Stocker's Scala +refactoring catalogue has the same move — turning a mutable field into +a pure accessor pair moves the *writes*, and that is the whole +analysis [[10](#ref-10)]. + +In the functional reading the move is mechanical. Make the record +immutable. Define `get` as the field accessor and `set` as a function +returning a copy with the field replaced; package them as a lens +value. Replace reads with `get`, writes with `set`, read-modify-write +with `modify`. Where a field sits inside other records, compose the +lenses along the path; where it sits under a branch of a sum, compose +a prism first; where the field is one of many, compose a traversal. +The OO precondition — no direct access — is replaced by a type: the +only way to reach the field is through the optic, and the type +checker enforces it. In practice the optic for a field or branch is +often *auto-derivable* from the type — a library macro or generator +(`eo`'s `lens`/`prism`, Monocle's optics, `lens`'s Template Haskell) +writes the "how to reach" for you, and you keep the "what to do" +[[7](#ref-7)], [[11](#ref-11)]. + +## The functional reading + +An optic is a value that knows how to reach a *focus* inside a source +and how to rebuild the source around a new focus. The families differ +in how many foci they address: a lens reaches exactly one field of a +product, a prism one branch of a sum, a traversal every element of a +container. They share one shape — see, modify, rebuild — which is what +makes them *compose*: `prism .andThen lens` says "that branch, then +that field", and `each .andThen prism .andThen lens` says "every +element, that branch, that field". The rewrite itself is a pure +function on the focus: how you get there is a value, what you do there +is a function, and the two are independent. This is the optics view of +the same ladder Fowler climbs — the corridor of accessor methods +becomes a composable value, and the precondition becomes a type. + +The theoretical backbone is the same one that made lenses lawful, +not just convenient. Foster, Greenwald, Moore, Pierce and Schmitt +defined the well-behaved lens families and proved their combinators — +composition, map, recursion — preserve the laws, which is where +"lawful by construction" comes from [[4](#ref-4)]. O'Connor, and then Gibbons +and Johnson, gave the categorical reading — lenses are the coalgebras +for the store comonad — which is why the different encodings coincide +[[5](#ref-5)], [[6](#ref-6)]. Van Laarhoven's representation made the encoding +practical as a functor-polymorphic function, which is what Kmett's +*lens* library builds on [[7](#ref-7)], [[8](#ref-8)]; Pickering, Gibbons and Wu's +*profunctor optics* shows the same idea scales to prisms and +traversals [[9](#ref-9)]. The [eo library](https://eo.constructive.dev) is our +own working treatment: optics derived from the type with one +`.andThen` surface, and a [cookbook](https://eo.constructive.dev/cookbook) +of runnable recipes organised by the three jobs optics do best — +navigating structures, decoupling modules, and threading effects. The +three examples below follow its "contingent fields", "whole trees" +and "arbitrary structure" recipes. + +## To and from + +
      +{% include_relative replace-mutable-fields-with-lenses/diagrams/koan.svg %} +
      The koan. One equation, read in two directions: replace +(package the reader and pure writer of a field as an optic value — +the "how to reach" and the "what to do") to the right, inline (drop +the optic, access the field directly) to the left. The move is +lawful by construction: the optic families are derived from the types +and compose, and the libraries ship solvers that check the ones you +write by hand.
      +
      + +The catalogue lists each refactoring in both directions because the two +moves are one equation read left to right and right to left. Replace a +field with a lens: define `get` and `set` for the field, replace every +read with `get`, every write with `set` or `modify`, and where the +field is nested compose the lenses. Inline a lens: replace `get`, +`set` and `modify` at their use sites with direct access, and delete +the optic. Both directions are checked by the same property: for all +generated inputs, the before-program and the after-program agree on +the entry point. + +## Three examples + +Each example is the same program twice, `Before` and `After`, in Scala 3 +and in Haskell. The optic building blocks — a lens, a prism, a +traversal, and the compositions between them — and the hedgehog spec +runner live in the entry's `shared/` directory: compiled by `run.sh`, +never shown on the page, so each example is the move itself. The entry +point keeps its name and its type, the optic is the only difference, +and a hedgehog property verifies that both versions agree on every +generated input. The three are eo's "navigate structures" recipes, in +increasing depth of nesting. + +### 1 · A single node: prism and lens composed + +A variable's name in one node of an expression tree. The Before +version is a hand-written match that rebuilds the hit branch and lets +every other shape pass. The After version is one composed optic — +`prism .andThen lens`: the prism decides whether the value is a `Var` +(the hit), the lens edits the `name` inside it, and every miss passes +through untouched. The *how* and the *what* are separate values; the +second property checks that the hit is uppercased and every miss +passes through. + +
      +{% include_relative replace-mutable-fields-with-lenses/01-rename-var/diagram.svg %} +
      + +
      +

      Before · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/Before.scala %}{% endhighlight %} +
      +

      Before · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/Before.hs %}{% endhighlight %} +
      +
      + +
      +

      After · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/After.scala %}{% endhighlight %} +
      +

      After · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/After.hs %}{% endhighlight %} +
      +
      + +
      +The property: Before.upperVarName == After.upperVarName on generated trees, and the hit/miss behaviour +
      +

      Spec · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/Spec.scala %}{% endhighlight %} +
      +

      Spec · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/01-rename-var/Spec.hs %}{% endhighlight %} +
      +
      +
      + +### 2 · Every node of a tree: the same optic, deeper nesting + +Now the same edit is applied at *every* node of the tree — the nesting +is what makes the hand-written version hurt. The Before version is a +recursive walk that rebuilds a hit by hand at each level of the +recursion; add a level to the tree and the rebuild appears again. The +The After version reuses the *same* `varName` optic from example 1, +and the walk comes from `Plated` — the recursion of the type as a +value, declared once (which fields are the sub-terms) and reused +`everywhere`. The lens says "how to reach a variable name"; the +`Plated` instance says "where the tree recurses"; and the walk applies +the optic at every node. This is eo's "visit across whole trees" +recipe — one `Plated` instance and one `everywhere`, rather than a +hand-written recursive rebuild. + +
      +{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/diagram.svg %} +
      + +
      +

      Before · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/Before.scala %}{% endhighlight %} +
      +

      Before · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/Before.hs %}{% endhighlight %} +
      +
      + +
      +

      After · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/After.scala %}{% endhighlight %} +
      +

      After · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/After.hs %}{% endhighlight %} +
      +
      + +
      +The property: Before.renameAll == After.renameAll on generated trees, and every name is uppercased afterwards +
      +

      Spec · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/Spec.scala %}{% endhighlight %} +
      +

      Spec · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/02-rename-tree/Spec.hs %}{% endhighlight %} +
      +
      +
      + +### 3 · A sparse walk over a list: traversal, prism and lens + +A batch of results, some succeeded and some failed; bump only the +successes. The Before version is a `map` carrying the branch test and +the rebuild in the same step. The After version is one composed optic +— `each .andThen prism .andThen lens`: the traversal reaches every +element, the prism selects the succeeded branch, the lens edits its +value, and every failed element passes through untouched. This is eo's +[cookbook recipe "visit through arbitrary structure"](https://eo.constructive.dev/cookbook#visit-through-arbitrary-structure), +which notes that this sparse walk is the shape a hand-rolled loop gets +wrong — the container and the branch test fight over who owns the +loop; composed optics keep the two apart. + +
      +{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/diagram.svg %} +
      + +
      +

      Before · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/Before.scala %}{% endhighlight %} +
      +

      Before · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/Before.hs %}{% endhighlight %} +
      +
      + +
      +

      After · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/After.scala %}{% endhighlight %} +
      +

      After · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/After.hs %}{% endhighlight %} +
      +
      + +
      +The property: Before.bumpSucceeded == After.bumpSucceeded on generated batches, and only successes are bumped +
      +

      Spec · Scala

      +{% highlight scala %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/Spec.scala %}{% endhighlight %} +
      +

      Spec · Haskell

      +{% highlight haskell %}{% include_relative replace-mutable-fields-with-lenses/03-bump-oks/Spec.hs %}{% endhighlight %} +
      +
      +
      + +## Pitfalls + +The real pitfalls of this move are the ones that come from carrying +machinery that does not earn its keep, or from rebuilding more than +the focus. + +- **Accidental complexity.** An optic is worth its indirection when + the path composes or varies; a lens for a leaf field that only one + caller reads and one writes is a detour — a plain field says the + same thing. The same for a traversal where a plain `map` with an + explicit match would do: if the container and the branch never + change, the two-in-one loop is clearer. This is the inverse side of + the equation; it is also the most common way the move goes wrong. +- **Rebuilding more than the focus.** When you write an optic by + hand, the writer must rebuild only what was matched or selected. A + prism's `review` that reconstructs a value with the wrong fields, + or a `set` that touches a neighbouring field, silently changes the + program — the composition is only as good as the instances you + compose, which is why the law-solvers in Verification matter. +- **Laziness and evaluation count.** `modify` reads the focus, + applies a function and writes it back. In Haskell the setter is + lazy in the new value, and rewriting one element of a traversal + must not force the others; in Scala a `def` recomputes and a `val` + shares, so where a lens is stored and how it is applied changes how + often its functions run. +- **Choosing the wrong family.** A lens addresses exactly one field, + a prism one branch, a traversal many. "Focus a pair of fields" or + "edit a field that may be absent" are different families (an affine + traversal, a prism), and composing optics whose foci do not line up + is a type error, not a runtime bug — the types reject the onesided + composition, so the mistake shows up at compile time rather than in + production. + +In each case the fix is the same: choose the smallest optic that says +the path, derive it from the type when you can, and check the ones you +write by hand. + +## Verification + +Because the move is an equation, its correctness is a property: for all +inputs *x* in the domain of the entry point, `Before x == After x`. That +is a one-line property in the sense Claessen and Hughes introduced with +QuickCheck, where a generator produces inputs and the framework searches +for a counterexample and shrinks it to a minimal one [[13](#ref-13)]. The catalogue +states every entry this way, in Scala and in Haskell, with hedgehog on +both sides [[14](#ref-14)]. Hedgehog is used because its shrinking is integrated +into the generator, so a shrunk counterexample obeys the same invariants +as a generated one and the minimal failing input it reports is a real +input of the program, not an artefact of a separate shrinker. + +A property is only worth having if it can fail, so each spec above was +mutation-checked: change `After` so it is no longer equivalent — a sign +flip, a wrong target — confirm the property reports and shrinks a +counterexample, then restore `After`. A property that does not fail +under mutation is testing the generator, not the refactoring. + +The optics themselves need no per-example law properties, because they +are lawful by construction — but the libraries ship *law-solvers* for +the instances you do write by hand, so you do not have to re-derive +the rules. [eo](https://eo.constructive.dev) ships `cats-eo-laws` with +`FooTests`/`FooLaws` for every optic family, Monocle ships +`monocle-law` with `LensLaws` and `PrismLaws` [[11](#ref-11)], and for Haskell +`genvalidity-hspec-optics` provides `lensSpec` and `prismSpec` +one-liners [[12](#ref-12)]. The property checks the refactoring; the solvers +check the optics you wrote to do it. + +To run everything on this page yourself, from a checkout of +[the site repository](https://github.com/Constructive-Programming/website): + +```sh +sh pages/refactorings/replace-mutable-fields-with-lenses/run.sh +``` + +It needs [scala-cli](https://scala-cli.virtuslab.org/) and either GHC +with hedgehog installed or Docker, and ends with `all properties passed`. + +## References + +
        +
      1. Martin Fowler. Refactoring: Improving the Design of Existing Code. Addison-Wesley, 1999. https://martinfowler.com/books/refactoring.html
      2. +
      3. Martin Fowler. Encapsulate Variable (formerly Encapsulate Field, before that Self-Encapsulate Field). Refactoring.com, online edition. https://refactoring.com/catalog/encapsulateField.html
      4. +
      5. Martin Fowler. Remove Setting Method. Refactoring.com, online edition. https://refactoring.com/catalog/removeSettingMethod.html
      6. +
      7. J. Nathan Foster, Michael B. Greenwald, Jonathan T. Moore, Benjamin C. Pierce and Alan Schmitt. “Combinators for Bidirectional Tree Transformations: A Linguistic Approach to the View-Update Problem”. ACM Transactions on Programming Languages and Systems 29(3):17, 2007. https://doi.org/10.1145/1232420.1232424
      8. +
      9. Russell O’Connor. “Functor is to Lens as Applicative is to Biplate: Introducing Multiplate”. In Proceedings of the ACM SIGPLAN Workshop on Generic Programming (WGP 2011), pp. 25–36. https://arxiv.org/abs/1103.2841
      10. +
      11. Jeremy Gibbons and Michael Johnson. “Relating Algebraic and Coalgebraic Descriptions of Lenses”. Electronic Communications of the EASST 49:1–16, 2012 (Workshop on Bidirectional Transformations 2012). https://doi.org/10.14279/tuj.eceasst.49.726
      12. +
      13. Twan van Laarhoven. “Talk on Lenses”. Slides, Radboud University Nijmegen, 17 May 2011. https://www.twanvl.nl/blog/news/2011-05-19-lenses-talk
      14. +
      15. Edward Kmett. lens: Lenses, Folds and Traversals, and Control.Lens documentation. https://hackage.haskell.org/package/lens
      16. +
      17. Matthew Pickering, Jeremy Gibbons and Nicolas Wu. “Profunctor Optics: Modular Data Accessors”. The Art, Science, and Engineering of Programming 1(2):7, 2017. https://doi.org/10.22152/programming-journal.org/2017/1/7
      18. +
      19. Mirko Stocker. Scala Refactoring. Master’s thesis, HSR Hochschule für Technik Rapperswil, 2010. https://eprints.ost.ch/id/eprint/286/
      20. +
      21. Julien Truffaut and contributors. Monocle: Optics Library for Scala (including monocle-law). https://www.optics.dev/Monocle/
      22. +
      23. Constructive Programming. eo: optics library and cookbook for Scala 3. https://eo.constructive.dev (cookbook)
      24. +
      25. Koen Claessen and John Hughes. “QuickCheck: a lightweight tool for random testing of Haskell programs”. In Proceedings of the ACM SIGPLAN International Conference on Functional Programming (ICFP 2000), pp. 268–279. https://doi.org/10.1145/351240.351266
      26. +
      27. Jacob Stanley and contributors. Hedgehog: release with confidence, state-of-the-art property testing. https://github.com/hedgehogqa/haskell-hedgehog and https://github.com/hedgehogqa/scala-hedgehog
      28. +
      diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.hs b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.hs new file mode 100644 index 0000000..f6aa34c --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.hs @@ -0,0 +1,26 @@ +-- Same, as one composed optic: the prism matches the Var branch, +-- the lens edits its name, every other shape passes through. +module After where + +import Data.Char (toUpper) +import Optics (Lens(..), Prism(..), PartialLens(..), composeO, over) + +data Var = Var { vName :: String, vRef :: Int } + deriving (Eq, Show) +data Expr = EVar Var | EApp Expr Expr | ELam String Expr + deriving (Eq, Show) + +varP :: Prism Expr Var +varP = Prism + { preview = \e -> case e of EVar v -> Just v; _ -> Nothing + , review = EVar + } + +nameL :: Lens Var String +nameL = Lens { view = vName, set = \(v, n) -> v { vName = n } } + +varName :: PartialLens Expr String +varName = composeO varP nameL + +upperVarName :: Expr -> Expr +upperVarName = over varName (map toUpper) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.scala b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.scala new file mode 100644 index 0000000..ec14d6d --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/After.scala @@ -0,0 +1,23 @@ +// Same, as one composed optic: the prism matches the Var branch, +// the lens edits its name, every other shape passes through. +object After: + import Optics.* + + case class Var(name: String, ref: Int) + enum Expr: + case EVar(v: Var) + case EApp(f: Expr, x: Expr) + case ELam(bind: String, body: Expr) + + import Expr.* + + val varP: Prism[Expr, Var] = + Prism[Expr, Var]( + { case EVar(v) => Some(v); case _ => None }, + EVar(_), + ) + val nameL: Lens[Var, String] = + Lens[Var, String](_.name, (v, n) => v.copy(name = n)) + val varName: PartialLens[Expr, String] = compose(varP, nameL) + + def upperVarName(e: Expr): Expr = over(varName, _.toUpperCase)(e) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.hs b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.hs new file mode 100644 index 0000000..d3870b7 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.hs @@ -0,0 +1,14 @@ +-- Uppercase the variable of one Var node: a hand-written match +-- that rebuilds the hit branch and lets every other shape pass. +module Before where + +import Data.Char (toUpper) + +data Var = Var { vName :: String, vRef :: Int } + deriving (Eq, Show) +data Expr = EVar Var | EApp Expr Expr | ELam String Expr + deriving (Eq, Show) + +upperVarName :: Expr -> Expr +upperVarName (EVar v) = EVar v { vName = map toUpper (vName v) } +upperVarName e = e diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.scala b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.scala new file mode 100644 index 0000000..e590fce --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Before.scala @@ -0,0 +1,14 @@ +// Uppercase the variable of one Var node: a hand-written match +// that rebuilds the hit branch and lets every other shape pass. +object Before: + case class Var(name: String, ref: Int) + enum Expr: + case EVar(v: Var) + case EApp(f: Expr, x: Expr) + case ELam(bind: String, body: Expr) + + import Expr.* + + def upperVarName(e: Expr): Expr = e match + case EVar(v) => EVar(v.copy(name = v.name.toUpperCase)) + case other => other diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.hs b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.hs new file mode 100644 index 0000000..e2c0dfa --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.hs @@ -0,0 +1,75 @@ +{-# LANGUAGE OverloadedStrings #-} +module Main where + +import Control.Monad (unless) +import System.Exit (exitFailure) +import Data.Char (toUpper) +import Hedgehog +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import qualified Before +import qualified After + +-- A neutral tree, so one generated input feeds both Expr types. +data T = TVar String Int | TApp T T | TLam String T + deriving (Eq, Show) + +genName :: Gen String +genName = Gen.string (Range.linear 0 4) Gen.alpha + +genT :: Int -> Gen T +genT 0 = TVar <$> genName <*> Gen.int (Range.linear (-10) 10) +genT depth = Gen.choice + [ TVar <$> genName <*> Gen.int (Range.linear (-10) 10) + , TApp <$> genT (depth - 1) <*> genT (depth - 1) + , TLam <$> genName <*> genT (depth - 1) + ] + +toBefore :: T -> Before.Expr +toBefore (TVar n r) = Before.EVar (Before.Var n r) +toBefore (TApp f x) = Before.EApp (toBefore f) (toBefore x) +toBefore (TLam b e) = Before.ELam b (toBefore e) + +fromBefore :: Before.Expr -> T +fromBefore (Before.EVar v) = TVar (Before.vName v) (Before.vRef v) +fromBefore (Before.EApp f x) = TApp (fromBefore f) (fromBefore x) +fromBefore (Before.ELam b e) = TLam b (fromBefore e) + +toAfter :: T -> After.Expr +toAfter (TVar n r) = After.EVar (After.Var n r) +toAfter (TApp f x) = After.EApp (toAfter f) (toAfter x) +toAfter (TLam b e) = After.ELam b (toAfter e) + +fromAfter :: After.Expr -> T +fromAfter (After.EVar v) = TVar (After.vName v) (After.vRef v) +fromAfter (After.EApp f x) = TApp (fromAfter f) (fromAfter x) +fromAfter (After.ELam b e) = TLam b (fromAfter e) + +prop_agrees :: Property +prop_agrees = property $ do + t <- forAll (genT 4) + fromBefore (Before.upperVarName (toBefore t)) + === fromAfter (After.upperVarName (toAfter t)) + +noVars :: T -> Bool +noVars (TVar _ _) = False +noVars (TApp f x) = noVars f && noVars x +noVars (TLam _ e) = noVars e + +prop_hit_and_miss :: Property +prop_hit_and_miss = property $ do + n <- forAll genName + t <- forAll (genT 3) + After.upperVarName (After.EVar (After.Var n 0)) + === After.EVar (After.Var (map toUpper n) 0) + if noVars t + then fromAfter (After.upperVarName (toAfter t)) === t + else success + +main :: IO () +main = do + ok <- checkParallel $ Group "Props" + [ ("upperVarName: Before == After", prop_agrees) + , ("the hit is uppercased, misses pass through", prop_hit_and_miss) + ] + unless ok exitFailure diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.scala b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.scala new file mode 100644 index 0000000..849a47a --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/Spec.scala @@ -0,0 +1,76 @@ +//> using scala 3.3.4 +//> using dep qa.hedgehog::hedgehog-core:0.14.0 +//> using dep qa.hedgehog::hedgehog-runner:0.14.0 +import hedgehog.*, hedgehog.core.*, hedgehog.runner.* + +object Props extends Properties: + def tests: List[Test] = List( + property("upperVarName: Before == After", agrees), + property("the hit is uppercased, misses pass through", hitAndMiss), + ) + + // A neutral tree, so one generated input feeds both Expr types. + enum T: + case TVar(name: String, ref: Int) + case TApp(f: T, x: T) + case TLam(bind: String, body: T) + + val genName: Gen[String] = + Gen.alpha.list(Range.linear(0, 4)).map(_.mkString) + def genT(depth: Int): Gen[T] = + val leaf = + for n <- genName; r <- Gen.int(Range.linear(-10, 10)) + yield T.TVar(n, r) + if depth == 0 then leaf + else + Gen.choice1( + leaf, + for + f <- genT(depth - 1) + x <- genT(depth - 1) + yield T.TApp(f, x), + for b <- genName; body <- genT(depth - 1) yield T.TLam(b, body), + ) + + def toBefore(t: T): Before.Expr = t match + case T.TVar(n, r) => Before.Expr.EVar(Before.Var(n, r)) + case T.TApp(f, x) => Before.Expr.EApp(toBefore(f), toBefore(x)) + case T.TLam(b, e) => Before.Expr.ELam(b, toBefore(e)) + def fromBefore(e: Before.Expr): T = e match + case Before.Expr.EVar(v) => T.TVar(v.name, v.ref) + case Before.Expr.EApp(f, x) => + T.TApp(fromBefore(f), fromBefore(x)) + case Before.Expr.ELam(b, e) => T.TLam(b, fromBefore(e)) + def toAfter(t: T): After.Expr = t match + case T.TVar(n, r) => After.Expr.EVar(After.Var(n, r)) + case T.TApp(f, x) => After.Expr.EApp(toAfter(f), toAfter(x)) + case T.TLam(b, e) => After.Expr.ELam(b, toAfter(e)) + def fromAfter(e: After.Expr): T = e match + case After.Expr.EVar(v) => T.TVar(v.name, v.ref) + case After.Expr.EApp(f, x) => T.TApp(fromAfter(f), fromAfter(x)) + case After.Expr.ELam(b, e) => T.TLam(b, fromAfter(e)) + + def agrees: Property = + for t <- genT(4).forAll + yield + fromBefore(Before.upperVarName(toBefore(t))) + ==== fromAfter(After.upperVarName(toAfter(t))) + + def hitAndMiss: Property = + for + n <- genName.forAll + t <- genT(3).forAll + yield + val hit = After.upperVarName(After.Expr.EVar(After.Var(n, 0))) + ==== After.Expr.EVar(After.Var(n.toUpperCase, 0)) + val miss = + if tNoVars(t) then + fromAfter(After.upperVarName(toAfter(t))) ==== t + else Result.success + hit and miss + + def tNoVars(t: T): Boolean = t match + case T.TVar(_, _) => false + case T.TApp(f, x) => tNoVars(f) && tNoVars(x) + case T.TLam(_, b) => tNoVars(b) +@main def spec(): Unit = SpecRunner.run(Props.tests) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/diagram.svg b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/diagram.svg new file mode 100644 index 0000000..d501ace --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/01-rename-var/diagram.svg @@ -0,0 +1,41 @@ + + Replacing a hand-written match with a composed prism and lens. Before: upperVarName(e) matches Var(n, r), rebuilds it, and passes every other shape through; the dotted region is the match and rebuild. After: varName = prism(varP).andThen(lens(nameL)) — the prism decides whether the value is a Var, the lens edits its name, and one over(varName, upper) applies the rewrite; the miss passes through untouched. + + before + after + + + + + + + + + + + upperVarName(e) = e match + case Var(n, r) => Var(n.toUpper, r) + case other => other + match + rebuild + pass-through + varName = varP .andThen nameL + over(varName, _.toUpperCase): + prism decides the hit (Var) + lens edits the name + miss passes through + + + + + + + 1 + 1 + + + + + + + + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.hs b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.hs new file mode 100644 index 0000000..eda071a --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.hs @@ -0,0 +1,34 @@ +-- Same, with the same varName optic applied at every node: the walk +-- comes from Plated (which fields recurse), not from the example. +module After where + +import Data.Char (toUpper) +import Optics + ( Lens(..), Prism(..), PartialLens(..), Plated(..) + , composeO, over, everywhere + ) + +data Var = Var { vName :: String, vRef :: Int } + deriving (Eq, Show) +data Expr = EVar Var | EApp Expr Expr | ELam String Expr + deriving (Eq, Show) + +instance Plated Expr where + descend f (EApp a b) = EApp (f a) (f b) + descend f (ELam b bd) = ELam b (f bd) + descend _ e = e + +varP :: Prism Expr Var +varP = Prism + { preview = \e -> case e of EVar v -> Just v; _ -> Nothing + , review = EVar + } + +nameL :: Lens Var String +nameL = Lens { view = vName, set = \(v, n) -> v { vName = n } } + +varName :: PartialLens Expr String +varName = composeO varP nameL + +renameAll :: Expr -> Expr +renameAll = everywhere (over varName (map toUpper)) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.scala b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.scala new file mode 100644 index 0000000..89ac098 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/After.scala @@ -0,0 +1,30 @@ +// Same, with the single varName optic applied at every node: the walk +// comes from Plated (which fields recurse), not from the example. +object After: + import Optics.* + + case class Var(name: String, ref: Int) + enum Expr: + case EVar(v: Var) + case EApp(f: Expr, x: Expr) + case ELam(bind: String, body: Expr) + + import Expr.* + + given Plated[Expr] with + def descend(f: Expr => Expr)(e: Expr): Expr = e match + case EApp(a, b) => EApp(f(a), f(b)) + case ELam(b, bd) => ELam(b, f(bd)) + case e => e + + val varP: Prism[Expr, Var] = + Prism[Expr, Var]( + { case EVar(v) => Some(v); case _ => None }, + EVar(_), + ) + val nameL: Lens[Var, String] = + Lens[Var, String](_.name, (v, n) => v.copy(name = n)) + val varName: PartialLens[Expr, String] = compose(varP, nameL) + + def renameAll(e: Expr): Expr = + summon[Plated[Expr]].everywhere(over(varName, _.toUpperCase))(e) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.hs b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.hs new file mode 100644 index 0000000..a8fa0fa --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.hs @@ -0,0 +1,15 @@ +-- Uppercase the variable of every Var node: a recursive walk that +-- rebuilds each hit by hand. +module Before where + +import Data.Char (toUpper) + +data Var = Var { vName :: String, vRef :: Int } + deriving (Eq, Show) +data Expr = EVar Var | EApp Expr Expr | ELam String Expr + deriving (Eq, Show) + +renameAll :: Expr -> Expr +renameAll (EVar v) = EVar v { vName = map toUpper (vName v) } +renameAll (EApp f x) = EApp (renameAll f) (renameAll x) +renameAll (ELam b bd) = ELam b (renameAll bd) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.scala b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.scala new file mode 100644 index 0000000..f7ea6c0 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Before.scala @@ -0,0 +1,15 @@ +// Uppercase the variable of every Var node: a recursive walk that +// rebuilds each hit by hand. +object Before: + case class Var(name: String, ref: Int) + enum Expr: + case EVar(v: Var) + case EApp(f: Expr, x: Expr) + case ELam(bind: String, body: Expr) + + import Expr.* + + def renameAll(e: Expr): Expr = e match + case EVar(v) => EVar(v.copy(name = v.name.toUpperCase)) + case EApp(f, x) => EApp(renameAll(f), renameAll(x)) + case ELam(b, bd) => ELam(b, renameAll(bd)) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.hs b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.hs new file mode 100644 index 0000000..96092da --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.hs @@ -0,0 +1,81 @@ +{-# LANGUAGE OverloadedStrings #-} +module Main where + +import Control.Monad (unless) +import System.Exit (exitFailure) +import Data.Char (isUpper) +import Hedgehog +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import qualified Before +import qualified After + +-- A neutral tree, so one generated input feeds both Expr types. +data T = TVar String Int | TApp T T | TLam String T + deriving (Eq, Show) + +genName :: Gen String +genName = Gen.string (Range.linear 0 4) Gen.alpha + +genT :: Int -> Gen T +genT depth = + let leaf = TVar <$> genName <*> Gen.int (Range.linear (-10) 10) + in if depth == 0 then leaf + else Gen.choice + [ leaf + , TApp <$> genT (depth - 1) <*> genT (depth - 1) + , TLam <$> genName <*> genT (depth - 1) + ] + +toBefore :: T -> Before.Expr +toBefore (TVar n r) = Before.EVar (Before.Var n r) +toBefore (TApp f x) = Before.EApp (toBefore f) (toBefore x) +toBefore (TLam b e) = Before.ELam b (toBefore e) + +fromBefore :: Before.Expr -> T +fromBefore (Before.EVar v) = TVar (Before.vName v) (Before.vRef v) +fromBefore (Before.EApp f x) = TApp (fromBefore f) (fromBefore x) +fromBefore (Before.ELam b e) = TLam b (fromBefore e) + +toAfter :: T -> After.Expr +toAfter (TVar n r) = After.EVar (After.Var n r) +toAfter (TApp f x) = After.EApp (toAfter f) (toAfter x) +toAfter (TLam b e) = After.ELam b (toAfter e) + +fromAfter :: After.Expr -> T +fromAfter (After.EVar v) = TVar (After.vName v) (After.vRef v) +fromAfter (After.EApp f x) = TApp (fromAfter f) (fromAfter x) +fromAfter (After.ELam b e) = TLam b (fromAfter e) + +prop_agrees :: Property +prop_agrees = property $ do + t <- forAll (genT 4) + fromBefore (Before.renameAll (toBefore t)) + === fromAfter (After.renameAll (toAfter t)) + +allNamesUpper :: T -> Bool +allNamesUpper (TVar n _) = all isUpper n +allNamesUpper (TApp f x) = allNamesUpper f && allNamesUpper x +allNamesUpper (TLam _ b) = allNamesUpper b + +refsUnchanged :: T -> T -> Bool +refsUnchanged (TVar _ r1) (TVar _ r2) = r1 == r2 +refsUnchanged (TApp f1 x1) (TApp f2 x2) = + refsUnchanged f1 f2 && refsUnchanged x1 x2 +refsUnchanged (TLam _ b1) (TLam _ b2) = refsUnchanged b1 b2 +refsUnchanged _ _ = False + +prop_all_upper :: Property +prop_all_upper = property $ do + t <- forAll (genT 4) + let renamed = fromAfter (After.renameAll (toAfter t)) + allNamesUpper renamed === True + refsUnchanged renamed t === True + +main :: IO () +main = do + ok <- checkParallel $ Group "Props" + [ ("renameAll: Before == After", prop_agrees) + , ("every Var name is uppercased, refs unchanged", prop_all_upper) + ] + unless ok exitFailure diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.scala b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.scala new file mode 100644 index 0000000..feccdac --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/Spec.scala @@ -0,0 +1,74 @@ +//> using scala 3.3.4 +//> using dep qa.hedgehog::hedgehog-core:0.14.0 +//> using dep qa.hedgehog::hedgehog-runner:0.14.0 +import hedgehog.*, hedgehog.core.*, hedgehog.runner.* + +object Props extends Properties: + def tests: List[Test] = List( + property("renameAll: Before == After", agrees), + property("every Var name is uppercased after the walk", allUpper), + ) + + // A neutral tree, so one generated input feeds both Expr types. + enum T: + case TVar(name: String, ref: Int) + case TApp(f: T, x: T) + case TLam(bind: String, body: T) + + val genName: Gen[String] = + Gen.alpha.list(Range.linear(0, 4)).map(_.mkString) + def genT(depth: Int): Gen[T] = + val leaf = + for n <- genName; r <- Gen.int(Range.linear(-10, 10)) + yield T.TVar(n, r) + if depth == 0 then leaf + else + Gen.choice1( + leaf, + for + f <- genT(depth - 1) + x <- genT(depth - 1) + yield T.TApp(f, x), + for b <- genName; body <- genT(depth - 1) yield T.TLam(b, body), + ) + + def toBefore(t: T): Before.Expr = t match + case T.TVar(n, r) => Before.Expr.EVar(Before.Var(n, r)) + case T.TApp(f, x) => Before.Expr.EApp(toBefore(f), toBefore(x)) + case T.TLam(b, e) => Before.Expr.ELam(b, toBefore(e)) + def fromBefore(e: Before.Expr): T = e match + case Before.Expr.EVar(v) => T.TVar(v.name, v.ref) + case Before.Expr.EApp(f, x) => T.TApp(fromBefore(f), fromBefore(x)) + case Before.Expr.ELam(b, e) => T.TLam(b, fromBefore(e)) + def toAfter(t: T): After.Expr = t match + case T.TVar(n, r) => After.Expr.EVar(After.Var(n, r)) + case T.TApp(f, x) => After.Expr.EApp(toAfter(f), toAfter(x)) + case T.TLam(b, e) => After.Expr.ELam(b, toAfter(e)) + def fromAfter(e: After.Expr): T = e match + case After.Expr.EVar(v) => T.TVar(v.name, v.ref) + case After.Expr.EApp(f, x) => T.TApp(fromAfter(f), fromAfter(x)) + case After.Expr.ELam(b, e) => T.TLam(b, fromAfter(e)) + + def agrees: Property = + for t <- genT(4).forAll + yield + fromBefore(Before.renameAll(toBefore(t))) + ==== fromAfter(After.renameAll(toAfter(t))) + + def allUpper: Property = + for t <- genT(4).forAll + yield + val renamed = fromAfter(After.renameAll(toAfter(t))) + (allNamesUpper(renamed) && refsUnchanged(renamed, t)) ==== true + + def allNamesUpper(t: T): Boolean = t match + case T.TVar(n, _) => n == n.toUpperCase + case T.TApp(f, x) => allNamesUpper(f) && allNamesUpper(x) + case T.TLam(_, b) => allNamesUpper(b) + def refsUnchanged(a: T, b: T): Boolean = (a, b) match + case (T.TVar(_, r1), T.TVar(_, r2)) => r1 == r2 + case (T.TApp(f1, x1), T.TApp(f2, x2)) => + refsUnchanged(f1, f2) && refsUnchanged(x1, x2) + case (T.TLam(_, b1), T.TLam(_, b2)) => refsUnchanged(b1, b2) + case _ => false +@main def spec(): Unit = SpecRunner.run(Props.tests) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/diagram.svg b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/diagram.svg new file mode 100644 index 0000000..196eeb9 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/02-rename-tree/diagram.svg @@ -0,0 +1,46 @@ + + Replacing a recursive match with one optic reused at every node. Before: renameAll recurses over the tree and matches+rebuilds Var at each node by hand. After: everywhere(over(varName, upper)) — the same varName optic from example 1 applied at every node, bottom-up; the walk decides where, the optic decides how. + + before + after + + + + + + + + + + + renameAll(e) = + e match + EVar(v) => EVar(v.copy(name = upper)) + EApp(f, x) => EApp(renameAll(f), + renameAll(x)) + ELam(b, bd) => ELam(b, renameAll(bd)) + the rebuild appears at + every depth + everywhere(over(varName, upper)) + walk decides where: + every node, bottom-up + varName decides how: + prism then lens, from ex. 1 + one optic, reused + at every depth + + + + + + + 1 + 1 + + + + + + + + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.hs b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.hs new file mode 100644 index 0000000..a0517d4 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.hs @@ -0,0 +1,28 @@ +-- Same, with a small traversal composed with a prism and a lens: +-- reach every element, match the succeeded branch, edit its value. +module After where + +import Optics (Lens(..), Prism(..), PartialLens(..), composeO, each, over) + +data Ok = Ok { okValue :: Int } + deriving (Eq, Show) +data Result = Succeeded Ok | Failed String + deriving (Eq, Show) + +succeededP :: Prism Result Ok +succeededP = Prism + { preview = \r -> case r of Succeeded ok -> Just ok; _ -> Nothing + , review = Succeeded + } + +valueL :: Lens Ok Int +valueL = Lens { view = okValue, set = \(ok, v) -> ok { okValue = v } } + +okVal :: PartialLens Result Int +okVal = composeO succeededP valueL + +eachSucceeded :: PartialLens [Result] Int +eachSucceeded = each okVal + +bumpSucceeded :: [Result] -> [Result] +bumpSucceeded = over eachSucceeded (+ 1) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.scala b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.scala new file mode 100644 index 0000000..1159d9b --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/After.scala @@ -0,0 +1,25 @@ +// Same, with a small traversal composed with a prism and a lens: +// reach every element, match the succeeded branch, edit its value. +object After: + import Optics.* + + case class Ok(value: Int) + enum Result: + case Succeeded(v: Ok) + case Failed(msg: String) + + import Result.* + + val succeededP: Prism[Result, Ok] = + Prism[Result, Ok]( + { case Succeeded(v) => Some(v); case _ => None }, + Succeeded(_), + ) + val valueL: Lens[Ok, Int] = + Lens[Ok, Int](_.value, (ok, v) => ok.copy(value = v)) + val okVal: PartialLens[Result, Int] = compose(succeededP, valueL) + + val eachSucceeded: PartialLens[List[Result], Int] = each(okVal) + + def bumpSucceeded(xs: List[Result]): List[Result] = + over(eachSucceeded, _ + 1)(xs) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.hs b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.hs new file mode 100644 index 0000000..082926d --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.hs @@ -0,0 +1,14 @@ +-- Bump only the successes of a batch: a hand-written map carrying +-- the branch test and the rebuild in the same step. +module Before where + +data Ok = Ok { okValue :: Int } + deriving (Eq, Show) +data Result = Succeeded Ok | Failed String + deriving (Eq, Show) + +bumpSucceeded :: [Result] -> [Result] +bumpSucceeded = map step + where + step (Succeeded ok) = Succeeded ok { okValue = okValue ok + 1 } + step f@(Failed _) = f diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.scala b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.scala new file mode 100644 index 0000000..b5bf0d3 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Before.scala @@ -0,0 +1,15 @@ +// Bump only the successes of a batch: a hand-written map carrying +// the branch test and the rebuild in the same step. +object Before: + case class Ok(value: Int) + enum Result: + case Succeeded(v: Ok) + case Failed(msg: String) + + import Result.* + + def bumpSucceeded(xs: List[Result]): List[Result] = + xs.map { + case Succeeded(ok) => Succeeded(ok.copy(value = ok.value + 1)) + case f @ Failed(_) => f + } diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.hs b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.hs new file mode 100644 index 0000000..c64f6d4 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.hs @@ -0,0 +1,67 @@ +{-# LANGUAGE OverloadedStrings #-} +module Main where + +import Control.Monad (unless) +import System.Exit (exitFailure) +import Hedgehog +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import qualified Before +import qualified After + +-- A neutral list element, so one generated input feeds both types. +data R = Succeeded Int | Failed String + deriving (Eq, Show) + +genValue :: Gen Int +genValue = Gen.int (Range.linear (-100) 100) + +genMsg :: Gen String +genMsg = Gen.string (Range.linear 0 6) Gen.alpha + +genR :: Gen R +genR = Gen.choice + [ Succeeded <$> genValue + , Failed <$> genMsg + ] + +toBefore :: R -> Before.Result +toBefore (Succeeded v) = Before.Succeeded (Before.Ok v) +toBefore (Failed m) = Before.Failed m + +fromBefore :: Before.Result -> R +fromBefore (Before.Succeeded ok) = Succeeded (Before.okValue ok) +fromBefore (Before.Failed m) = Failed m + +toAfter :: R -> After.Result +toAfter (Succeeded v) = After.Succeeded (After.Ok v) +toAfter (Failed m) = After.Failed m + +fromAfter :: After.Result -> R +fromAfter (After.Succeeded ok) = Succeeded (After.okValue ok) +fromAfter (After.Failed m) = Failed m + +prop_agrees :: Property +prop_agrees = property $ do + xs <- forAll (Gen.list (Range.linear 0 10) genR) + map fromBefore (Before.bumpSucceeded (map toBefore xs)) + === map fromAfter (After.bumpSucceeded (map toAfter xs)) + +prop_only_succeeded :: Property +prop_only_succeeded = property $ do + xs <- forAll (Gen.list (Range.linear 0 10) genR) + let bumped = map fromAfter (After.bumpSucceeded (map toAfter xs)) + and (zipWith check bumped xs) === True + where + check (Succeeded v1) (Succeeded v0) = v1 == v0 + 1 + check (Failed m1) (Failed m0) = m1 == m0 + check _ _ = False + +main :: IO () +main = do + ok <- checkParallel $ Group "Props" + [ ("bumpSucceeded: Before == After", prop_agrees) + , ("only successes bumped, messages pass through", + prop_only_succeeded) + ] + unless ok exitFailure diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.scala b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.scala new file mode 100644 index 0000000..7b6e323 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/Spec.scala @@ -0,0 +1,56 @@ +//> using scala 3.3.4 +//> using dep qa.hedgehog::hedgehog-core:0.14.0 +//> using dep qa.hedgehog::hedgehog-runner:0.14.0 +import hedgehog.*, hedgehog.core.*, hedgehog.runner.* + +object Props extends Properties: + def tests: List[Test] = List( + property("bumpSucceeded: Before == After", agrees), + property("only successes are bumped, messages pass through", + onlySucceeded), + ) + + // A neutral list, so one generated input feeds both Result types. + enum R: + case Succeeded(value: Int) + case Failed(msg: String) + + val genValue: Gen[Int] = Gen.int(Range.linear(-100, 100)) + val genMsg: Gen[String] = + Gen.alpha.list(Range.linear(0, 6)).map(_.mkString) + def genR: Gen[R] = + Gen.choice1( + genValue.map(R.Succeeded(_)), + genMsg.map(R.Failed(_)), + ) + + def toBefore(r: R): Before.Result = r match + case R.Succeeded(v) => Before.Result.Succeeded(Before.Ok(v)) + case R.Failed(m) => Before.Result.Failed(m) + def fromBefore(r: Before.Result): R = r match + case Before.Result.Succeeded(ok) => R.Succeeded(ok.value) + case Before.Result.Failed(m) => R.Failed(m) + def toAfter(r: R): After.Result = r match + case R.Succeeded(v) => After.Result.Succeeded(After.Ok(v)) + case R.Failed(m) => After.Result.Failed(m) + def fromAfter(r: After.Result): R = r match + case After.Result.Succeeded(ok) => R.Succeeded(ok.value) + case After.Result.Failed(m) => R.Failed(m) + + def agrees: Property = + for xs <- genR.list(Range.linear(0, 10)).forAll + yield + Before.bumpSucceeded(xs.map(toBefore)).map(fromBefore) + ==== After.bumpSucceeded(xs.map(toAfter)).map(fromAfter) + + def onlySucceeded: Property = + for xs <- genR.list(Range.linear(0, 10)).forAll + yield + val bumped = + After.bumpSucceeded(xs.map(toAfter)).map(fromAfter) + bumped.zip(xs).forall { + case (R.Succeeded(v1), R.Succeeded(v0)) => v1 == v0 + 1 + case (R.Failed(m1), R.Failed(m0)) => m1 == m0 + case _ => false + } ==== true +@main def spec(): Unit = SpecRunner.run(Props.tests) diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/diagram.svg b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/diagram.svg new file mode 100644 index 0000000..217a73f --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/03-bump-oks/diagram.svg @@ -0,0 +1,32 @@ + + Replacing a map-with-match with a composed traversal, prism and lens. Before: bumpSucceeded maps with a branch test and rebuild in the same step. After: each .andThen succeeded .andThen value — the traversal reaches every element, the prism selects the succeeded branch, the lens edits its value; failures pass through untouched. + + before + after + + + + + + + + + + bumpSucceeded(xs) = + xs.map { case Succeeded(ok) => + Succeeded(ok.copy(value = ok.value + 1)) + case f @ Failed(_) => f } + branch test and rebuild + in the same step + each .andThen succeeded + .andThen value + every element, + succeeded branch, + edit its value + failures pass through + + + traversal over the list + composed optic + + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/diagrams/koan.svg b/pages/refactorings/replace-mutable-fields-with-lenses/diagrams/koan.svg new file mode 100644 index 0000000..77bf53c --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/diagrams/koan.svg @@ -0,0 +1,38 @@ + + Replace mutable fields with lenses / replace lenses with mutable fields. Left: a record field read and written directly, spelled out at every call site. Right: the same field as a lens value packaging get :: s -> a and set :: a -> s -> s, so every read is get, every write is set or modify. The arrow to the right is replace (package get and set as one composable value); the arrow to the left is inline (drop the lens and access the field directly). The three lens laws — get (set v s) = v, set (get s) s = s, set v2 (set v1 s) = set v2 s — are the equation the move is checked against. + + before + after + + + + + + + field balance + balanceLens = + Lens { get, set } + + + read: a.balance + write: copy with balance + every call site spells the + field out again + read: get balanceLens + write: set / modify + lens composes along paths + + + + + + + + + + + replace (package get + set into a lens) + inline (drop the lens, access the field) + + get (set v s) = v · set (get s) s = s · set v₂ (set v₁ s) = set v₂ s + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/run.sh b/pages/refactorings/replace-mutable-fields-with-lenses/run.sh new file mode 100755 index 0000000..39d133a --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Runs every hedgehog property for this refactoring, in both languages. +# Each NN-*/ directory holds Before + After + Spec in Scala 3 and Haskell; +# Spec generates inputs and asserts Before and After agree on all of them. +# The shared/ directory (Scala object Optics, Haskell module Optics) holds +# the optic building blocks; it is compiled in, never shown on the page. +# +# Needs: scala-cli (https://scala-cli.virtuslab.org) and either ghc with +# hedgehog on the package path, or docker (the image is built on first use). +set -eu +cd "$(dirname "$0")" +SHARED="$(pwd)/shared" + +if command -v ghc >/dev/null 2>&1 && ghc-pkg list hedgehog 2>/dev/null | grep -q hedgehog; then + hs() { runghc -i"$1" -i"$SHARED" "$1/Spec.hs"; } +else + docker image inspect cp-hedgehog >/dev/null 2>&1 || \ + printf 'FROM haskell:9.8-slim\nRUN cabal update && cabal install --lib hedgehog\n' | docker build -t cp-hedgehog - + hs() { docker run --rm -v "$PWD:/w" -v "$SHARED:/shared" -w /w cp-hedgehog runghc -i"$1" -i/shared "$1/Spec.hs"; } +fi + +for d in [0-9][0-9]-*/; do + d=${d%/} + echo "== $d (scala)" + scala-cli run "$d" --main-class spec "$SHARED" + echo "== $d (haskell)"; hs "$d" +done +echo "all properties passed" diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.hs b/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.hs new file mode 100644 index 0000000..809409f --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.hs @@ -0,0 +1,49 @@ +-- Shared optic building blocks for the examples on this page: a Lens, +-- a Prism, a composed PartialLens, and a traversal `each`. Compiled by +-- run.sh but not shown on the page, so each example is the move itself. +module Optics (Lens(..), Prism(..), PartialLens(..), Plated(..), everywhere, composeO, each, over, modify) where + +import Data.Maybe (listToMaybe) + +data Lens s a = Lens { view :: s -> a, set :: (s, a) -> s } + +modify :: Lens s a -> (a -> a) -> s -> s +modify l f s = set l (s, f (view l s)) + +data Prism s a = Prism + { preview :: s -> Maybe a + , review :: a -> s + } + +-- A prism followed by a lens: hit -> edit the field; miss -> pass through. +data PartialLens s a = PartialLens + { plPreview :: s -> Maybe a + , plModify :: (a -> a) -> s -> s + } + +composeO :: Prism s m -> Lens m a -> PartialLens s a +composeO p l = PartialLens + ( \s -> view l <$> preview p s + ) ( \f s -> case preview p s of + Nothing -> s + Just m -> review p (modify l f m) + ) + +-- A traversal: reach every element, apply the partial lens to each. +each :: PartialLens s a -> PartialLens [s] a +each pl = PartialLens + (\xs -> listToMaybe xs >>= plPreview pl) + (\f xs -> map (plModify pl f) xs) + +over :: PartialLens s a -> (a -> a) -> s -> s +over pl = plModify pl + +-- Plated: the recursion of a recursive type, as a value. An instance +-- says which fields are the sub-terms (the "plate"); `everywhere` +-- then applies a rewrite at every node, bottom-up. +class Plated s where + descend :: (s -> s) -> s -> s + +everywhere :: Plated s => (s -> s) -> s -> s +everywhere f s = descend (everywhere f) (f s) + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.scala b/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.scala new file mode 100644 index 0000000..14084d9 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/shared/Optics.scala @@ -0,0 +1,42 @@ +// Shared optic building blocks for the examples on this page: a Lens, +// a Prism, a composed PartialLens, and a traversal `each`. Compiled by +// run.sh but not shown on the page, so each example is the move itself. +object Optics: + case class Lens[S, A](view: S => A, set: (S, A) => S): + def modify(f: A => A): S => S = s => set(s, f(view(s))) + + case class Prism[S, A](preview: S => Option[A], review: A => S): + def modify(f: A => A): S => S = + s => preview(s).map(a => review(f(a))).getOrElse(s) + + // A prism followed by a lens: hit -> edit the field; miss -> pass through. + case class PartialLens[S, A](preview: S => Option[A], + modify: (A => A) => S => S) + + def compose[S, M, A](p: Prism[S, M], l: Lens[M, A]): PartialLens[S, A] = + PartialLens( + preview = s => p.preview(s).map(l.view), + modify = f => s => p.preview(s) match + case Some(m) => p.review(l.modify(f)(m)) + case None => s, + ) + + // A traversal: reach every element, apply the partial lens to each. + def each[S, A](pl: PartialLens[S, A]): PartialLens[List[S], A] = + PartialLens( + preview = _.headOption.flatMap(pl.preview), + modify = f => _.map(pl.modify(f)), + ) + + def over[S, A](pl: PartialLens[S, A], f: A => A): S => S = + pl.modify(f) + + // Plated: the recursion of a recursive type, as a value. An instance + // says which fields are the sub-terms (the "plate"); `everywhere` + // then applies a rewrite at every node, bottom-up, without the type + // knowing how to walk itself. + trait Plated[S]: + def descend(f: S => S)(s: S): S + def everywhere(f: S => S): S => S = + s => descend(everywhere(f))(f(s)) + diff --git a/pages/refactorings/replace-mutable-fields-with-lenses/shared/SpecRunner.scala b/pages/refactorings/replace-mutable-fields-with-lenses/shared/SpecRunner.scala new file mode 100644 index 0000000..8aeb4f8 --- /dev/null +++ b/pages/refactorings/replace-mutable-fields-with-lenses/shared/SpecRunner.scala @@ -0,0 +1,15 @@ +// Shared runner body for every example's hedgehog suite. Each example +// ends with `@main def spec(): Unit = SpecRunner.run(Props.tests)`; +// this file holds the boilerplate so the page does not show it. +import hedgehog.*, hedgehog.core.*, hedgehog.runner.* + +object SpecRunner: + def run(tests: List[Test]): Unit = + val results = tests.map { t => + val r = Property.check(t.withConfig(PropertyConfig.default), + t.result, Seed.fromTime()) + println(Test.renderReport( + "Props", t, r, ansiCodesSupported = false)) + r.status + } + if !results.forall(_ == Status.ok) then sys.exit(1)