refactor: use <+ template writing in library code - #4110
Conversation
There was a problem hiding this comment.
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
Showimpls and tests to use<+template writing for clearer, more compact output construction. - Adds streaming writer helpers:
json.escape_to(...),BytesView::escape_to(...), andv128.u64_hex_to(...)to avoid building intermediate strings. - Adds
json/stringify_bench_test.mbtto 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.
Coverage Report for CI Build 6201Coverage decreased (-0.02%) to 90.698%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
a855e44 to
6c094f6
Compare
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>
6c094f6 to
077ed2d
Compare
<+ template writing instead of write_string sequences<+ template writing in library code
|
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. |
Part 2 of 2. Library code only — the test-file half is #4111, which can
land independently.
Sweep replacing
write_string/write_char/write_objectsequences withthe
<+template-writing operator, continuing what 1c47134 ("Useinterpolation for fixed output builders") started in
show.mbt,json/types.mbtandref.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 periteration, native and js:
write_string("(") ; write_object(a) ; …(baseline)logger <+ "(\{a}, \{b})"logger <+ "(\{l => l.write_object(a)}, …)"write_string("x=\{a}, y=\{b}")(baseline)logger <+ "x=\{a}, y=\{b}"logger <+ "x=\{l => …}, y=\{l => …}"write_string("aaaa"); write_string("bbbb"); write_string("cccc")logger <+ "aaaabbbbcccc"The reason is in the desugaring.
\{x}becomeswrite_string_interpolation(x), whose parameter is&Show, so everyinterpolation coerces its argument into a trait object — one allocation per
hole, on every backend:
The inline-writer form
\{l => …}hits the compiler'smake_inline_writepath instead and desugars to
writer |> (l => …), which emits byteidentical 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 formbuilt a throwaway
StringBuilder+Stringand then copied it in.write_object(x)→\{l => l.write_object(x)}, not\{x}, somonomorphic
Showdispatch is preserved. (refactor(test): use<+template writing in test files #4111 uses plain\{x}in testbodies, 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~) -> String→escape_to(buf, str, escape_slash~).stringifynow escapes straight into the output builderrather than allocating one escaped
Stringper key and per string value.v128: addedu64_hex_to(logger, value);u64_hexkeeps itsStringsignature for the
Debugimpl.builtin:BytesView::escape_to— this also de-duplicates the escapingloop, which
ShowandToJsoneach had their own copy of. It is genericover the logger (
fn[L : Logger]) rather than taking&Logger, soToJson's concreteStringBuilderkeeps direct dispatch; taking&Loggerthere 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.mbtis added here so the claim is reproducible.Escape-heavy document, n = 200:
indent=2indent=2Deliberately not touched
builtin/tuple_show.mbt(150write_stringcalls, the single biggestcandidate). Keeping it allocation-free needs the inline-writer form, and
the 16-tuple impl would become one ~450 character string literal that
moon fmtcannot break. The five-line-per-impl status quo reads better.write_string("literal")calls.x <+ "lit"desugars toexactly
x.write_string("lit"), so converting the ~250 standalone oneswould be pure diff noise. They are converted only inside
Showimpls thatthis PR was already rewriting.
write_string(x.to_string()). For types whoseShowonlyimplements
to_string(Int,Byte, …), the defaultoutputiswrite_string(self.to_string())— so<+ "\{x}"would allocate the samestring and add a box on top.
Follow-up worth considering (not in this PR)
The pre-existing
<+ "\{x}"sites inbuiltin/show.mbt(Show for X?,Show for Result),ref.mbtandcmp.mbtpay that ~40% trait-object costtoday. Either switching them to the inline-writer form, or teaching the
desugarer to emit a monomorphic
write_objectwhen the interpolatedexpression's
Showinstance is statically known, would recover it — thelatter would make plain
\{x}the right answer everywhere and is the betterfix.
Verification
moon check --deny-warn --target allmoon teston wasm / wasm-gc / js / native — 7458 / 7459 / 7403 / 7375passed, 0 failed (re-run after the split)
moon info --target wasm,wasm-gc,js,native— no.mbtidrift (every newhelper is package-private)
moon fmtcleanReview
Reviewed by Codex CLI (
codex exec, read-only sandbox) against the desugaringin
parsing_util.ml. Verdict: approve, no correctness or shipped-pathperformance blocker. It independently confirmed output equivalence for
Show for Json(including therepr=Some(...)case), bothstringifyescapesites, the
json_pathrecursion moved into interpolation holes, theBytesViewquote split betweenShowandToJson,diff/hunk'swrite_string(x.to_string())→write_object(x),V128's padding and wordorder, and the literal-brace vs
\{escaping in the\u{...}rewrites.Its two findings are addressed: the
&Loggerdispatch onBytesView::to_jsonis 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.