Skip to content

refactor: use <+ template writing in library code - #4110

Open
bobzhang wants to merge 1 commit into
mainfrom
simplify-template-writing
Open

refactor: use <+ template writing in library code#4110
bobzhang wants to merge 1 commit into
mainfrom
simplify-template-writing

Conversation

@bobzhang

@bobzhang bobzhang commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Part 2 of 2. Library code only — the test-file half is #4111, which can
land independently.

Sweep replacing write_string / write_char / write_object sequences with
the <+ template-writing operator, continuing what 1c47134 ("Use
interpolation for fixed output builders") started in show.mbt,
json/types.mbt and ref.mbt.

15 files, -130 / +126 lines (+44 of which is the new benchmark, so the
library code itself is a net -48).

Which form, and why

I benchmarked the three candidate spellings before sweeping, because they are
not equivalent. All numbers below are moon bench, 2000 writes per
iteration, native and js:

spelling native js
write_string("(") ; write_object(a) ; … (baseline) 50.1 µs 71.4 µs
logger <+ "(\{a}, \{b})" 70.7 µs (+41%) 100.3 µs (+40%)
logger <+ "(\{l => l.write_object(a)}, …)" 49.1 µs (par) 75.3 µs (par)
write_string("x=\{a}, y=\{b}") (baseline) 155.5 µs 95.1 µs
logger <+ "x=\{a}, y=\{b}" 66.3 µs (2.3x) 98.9 µs (par)
logger <+ "x=\{l => …}, y=\{l => …}" 45.8 µs (3.4x) 64.5 µs (1.5x)
write_string("aaaa"); write_string("bbbb"); write_string("cccc") 28.9 µs 27.3 µs
logger <+ "aaaabbbbcccc" 13.6 µs (2.1x) 6.7 µs (4.1x)

The reason is in the desugaring. \{x} becomes
write_string_interpolation(x), whose parameter is &Show, so every
interpolation coerces its argument into a trait object — one allocation per
hole, on every backend:

logger.method_table.method_4(logger.self,
  { self: a, method_table: Int_as_Show });   // <- allocation

The inline-writer form \{l => …} hits the compiler's make_inline_write
path instead and desugars to writer |> (l => …), which emits byte
identical
code to the direct call it replaces. And adjacent literal chunks
are concatenated at compile time, so a run of literal writes collapses into
one write_string.

So the sweep uses:

  • buf.write_string("...\{x}...")buf <+ "...\{x}..." — the old form
    built a throwaway StringBuilder + String and then copied it in.
  • runs of adjacent literal writes → one template.
  • write_object(x)\{l => l.write_object(x)}, not \{x}, so
    monomorphic Show dispatch is preserved. (refactor(test): use <+ template writing in test files #4111 uses plain \{x} in test
    bodies, where readability wins and one box per element in a three-element
    assertion is irrelevant.)

Streaming instead of materializing

Three helpers grew writer variants so callers stop building intermediate
strings:

  • json: escape(str, escape_slash~) -> Stringescape_to(buf, str, escape_slash~). stringify now escapes straight into the output builder
    rather than allocating one escaped String per key and per string value.
  • v128: added u64_hex_to(logger, value); u64_hex keeps its String
    signature for the Debug impl.
  • builtin: BytesView::escape_to — this also de-duplicates the escaping
    loop, which Show and ToJson each had their own copy of. It is generic
    over the logger (fn[L : Logger]) rather than taking &Logger, so
    ToJson's concrete StringBuilder keeps direct dispatch; taking &Logger
    there measured ~5% slower on Bytes::to_json (92.8 µs → 98.1 µs native,
    4 KiB input), and the generic version is back at 93.3 µs.

json/stringify_bench_test.mbt is added here so the claim is reproducible.
Escape-heavy document, n = 200:

before after
native 152.0 µs 122.8 µs 1.24x
native, indent=2 158.4 µs 131.5 µs 1.20x
js 193.6 µs 175.8 µs 1.10x
js, indent=2 206.3 µs 186.2 µs 1.11x

Deliberately not touched

  • builtin/tuple_show.mbt (150 write_string calls, the single biggest
    candidate). Keeping it allocation-free needs the inline-writer form, and
    the 16-tuple impl would become one ~450 character string literal that
    moon fmt cannot break. The five-line-per-impl status quo reads better.
  • Isolated write_string("literal") calls. x <+ "lit" desugars to
    exactly x.write_string("lit"), so converting the ~250 standalone ones
    would be pure diff noise. They are converted only inside Show impls that
    this PR was already rewriting.
  • Isolated write_string(x.to_string()). For types whose Show only
    implements to_string (Int, Byte, …), the default output is
    write_string(self.to_string()) — so <+ "\{x}" would allocate the same
    string and add a box on top.

Follow-up worth considering (not in this PR)

The pre-existing <+ "\{x}" sites in builtin/show.mbt (Show for X?,
Show for Result), ref.mbt and cmp.mbt pay that ~40% trait-object cost
today. Either switching them to the inline-writer form, or teaching the
desugarer to emit a monomorphic write_object when the interpolated
expression's Show instance is statically known, would recover it — the
latter would make plain \{x} the right answer everywhere and is the better
fix.

Verification

  • moon check --deny-warn --target all
  • moon test on wasm / wasm-gc / js / native — 7458 / 7459 / 7403 / 7375
    passed, 0 failed (re-run after the split)
  • moon info --target wasm,wasm-gc,js,native — no .mbti drift (every new
    helper is package-private)
  • moon fmt clean

Review

Reviewed by Codex CLI (codex exec, read-only sandbox) against the desugaring
in parsing_util.ml. Verdict: approve, no correctness or shipped-path
performance blocker. It independently confirmed output equivalence for
Show for Json (including the repr=Some(...) case), both stringify escape
sites, the json_path recursion moved into interpolation holes, the
BytesView quote split between Show and ToJson, diff/hunk's
write_string(x.to_string())write_object(x), V128's padding and word
order, and the literal-brace vs \{ escaping in the \u{...} rewrites.

Its two findings are addressed: the &Logger dispatch on
BytesView::to_json is fixed by the generic signature, and the plain-\{x}
holes it flagged were all in test code, which now lives in #4111 with that
choice called out as deliberate.

Note that the review ran against the pre-split commit (all 33 files); the
library hunks here are unchanged from what it read.

Copilot AI lite review requested due to automatic review settings August 19, 2026 10:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request continues a project-wide refactor to replace sequences of write_string / write_char / write_object calls with the <+ template-writing operator, while preserving monomorphic Show dispatch via the inline-writer interpolation form (\{l => ...}) where appropriate. It also introduces a few writer-oriented helpers (notably for JSON escaping and V128 hex formatting) to reduce intermediate string materialization during streaming output.

Changes:

  • Refactors many Show impls and tests to use <+ template writing for clearer, more compact output construction.
  • Adds streaming writer helpers: json.escape_to(...), BytesView::escape_to(...), and v128.u64_hex_to(...) to avoid building intermediate strings.
  • Adds json/stringify_bench_test.mbt to make the JSON stringify performance claim reproducible.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated no comments.

Show a summary per file
File Description
v128/simd_basic.mbt Adds u64_hex_to writer helper and updates Show for V128 to stream hex formatting via <+.
strconv/quickcheck_test.mbt Uses <+ templates when building structured numeric strings in quickcheck.
strconv/double_differential_test.mbt Collapses repeated StringBuilder writes into <+ templates for snapshot/test output.
sorted_set/set_test.mbt Updates iteration formatting in tests to use <+.
sorted_map/map_test.mbt Updates map iteration formatting in tests to use <+.
set/linked_hash_set.mbt Refactors Show output punctuation to use <+ in the set formatter.
set/linked_hash_set_test.mbt Updates test string building to use <+.
priority_queue/priority_queue_test.mbt Updates test string building to use <+.
option/option_test.mbt Refactors iterator test output building to use <+.
json/types.mbt Refactors ParseError and deprecated Json Show output sites to use <+.
json/stringify_bench_test.mbt Adds new benchmark tests for escape-heavy JSON stringify scenarios.
json/number_bench_test.mbt Refactors number JSON generation to use <+ templates.
json/json.mbt Introduces escape_to(buf, ...) and updates stringify to stream escaping directly into the output builder.
json/json_path.mbt Refactors JSON Pointer (JsonPath) rendering to use <+ templates and small literal writes.
immut/vector/vector_test.mbt Updates test iteration output to use <+.
immut/sorted_map/utils_test.mbt Refactors multiple test output constructions to use <+ templates.
immut/sorted_map/map_test.mbt Updates iterator test output to use <+.
immut/priority_queue/priority_queue_test.mbt Updates test iteration output to use <+.
hashmap/utils.mbt Refactors HashMap Show output to use <+ with inline writer holes for write_object.
diff/hunk.mbt Streams range formatting via write_object / <+ templates in diff hunk header rendering.
deque/deque_test.mbt Refactors test output string building to use <+.
builtin/stringview_test.mbt Updates StringView test output formatting to use <+.
builtin/show.mbt Refactors an escape-path write sequence into a single <+ template write.
builtin/linked_hash_map.mbt Refactors map Show output punctuation/object formatting to use <+ templates.
builtin/linked_hash_map_wbtest.mbt Updates whitebox tests’ string building to use <+.
builtin/iterator.mbt Refactors iterator Show formatting to use <+ templates.
builtin/iter_test.mbt Updates iterator tests to use <+ templates for output building.
builtin/fixedarray.mbt Refactors test output construction to use <+.
builtin/console.mbt Collapses two write_char('=') calls into a single <+ "==" write.
builtin/char.mbt Refactors unicode escape write sequence into a <+ template write.
builtin/bytesview.mbt Introduces BytesView::escape_to helper and reuses it from both Show and ToJson.
builtin/array_test.mbt Updates array iteration test output building to use <+.
bigint/bigint_nonjs_wbtest.mbt Refactors a Show output into a <+ template write.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@coveralls

coveralls commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6201

Coverage decreased (-0.02%) to 90.698%

Details

  • Coverage decreased (-0.02%) from the base build.
  • Patch coverage: 5 uncovered changes across 2 files (42 of 47 lines covered, 89.36%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
set/linked_hash_set.mbt 3 0 0.0%
builtin/iterator.mbt 2 0 0.0%
Total (11 files) 47 42 89.36%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 17899
Covered Lines: 16234
Line Coverage: 90.7%
Coverage Strength: 334076.69 hits per line

💛 - Coveralls

Replaces `write_string`/`write_char`/`write_object` sequences and
`write_string("...\{x}...")` calls with the `<+` template-writing operator
in library code. The test-file half of this sweep is #4111.

Three shapes appear, chosen by what the old code did:

* `buf.write_string("...\{x}...")` -> `buf <+ "...\{x}..."`. The old form
  built a throwaway `StringBuilder` + `String` for the interpolation and
  then copied it into `buf`; the template writes the pieces straight through.
* Adjacent literal writes -> one template. The desugarer concatenates
  adjacent literal chunks at compile time, so `write_string("a")` +
  `write_string("b")` becomes a single `write_string("ab")`.
* `write_object(x)` -> `\{l => l.write_object(x)}`, not `\{x}`. Plain
  `\{x}` desugars to `write_string_interpolation(x)`, which coerces `x` to
  `&Show` and allocates a trait object per interpolation (~40% slower on
  both native and js). The inline-writer form compiles to exactly the same
  code as the `write_object` call it replaces.

`json`'s `escape`, `v128`'s `u64_hex` and `BytesView`'s byte escaping grew
writer variants (`escape_to` / `u64_hex_to`) so that their callers stream
into the target logger instead of materializing an intermediate `String`.
`BytesView::escape_to` also de-duplicates the escaping loop that `Show` and
`ToJson` each had a copy of; it is generic over the logger rather than
taking `&Logger`, so `ToJson`'s concrete `StringBuilder` keeps direct
dispatch (taking `&Logger` there costs ~5% on `Bytes::to_json`).

Measured on `json/stringify_bench_test.mbt` (added here), escape-heavy
document, n=200:

    native  152.0 us -> 122.8 us (1.24x), indent=2 158.4 us -> 131.5 us
    js      193.6 us -> 175.8 us (1.10x), indent=2 206.3 us -> 186.2 us

`builtin/tuple_show.mbt` is deliberately left alone: its `write_object`
runs would need the inline-writer form to stay allocation-free, and a
16-element tuple would become a single ~450 character string literal that
`moon fmt` cannot break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bobzhang
bobzhang force-pushed the simplify-template-writing branch from 6c094f6 to 077ed2d Compare August 19, 2026 14:51
@bobzhang bobzhang changed the title refactor: use <+ template writing instead of write_string sequences refactor: use <+ template writing in library code Aug 19, 2026
@bobzhang

Copy link
Copy Markdown
Contributor Author

Split: the test-file half moved to #4111. This PR is now library code only (15 files), which is the part that wants review. The Codex review quoted in the description ran against the pre-split commit; the library hunks here are byte-for-byte what it read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants