Skip to content

Speed up binary writing: value-type output sink + byte-swap number encoding - #5286

Merged
nlohmann merged 6 commits into
developfrom
claude/binary-writer-output-sinks
Sep 23, 2026
Merged

nlohmann merged 6 commits into
developfrom
claude/binary-writer-output-sinks

Conversation

@nlohmann

@nlohmann nlohmann commented Jul 21, 2026 •

Copy link
Copy Markdown
Owner

What & why

Two related, output-preserving changes that speed up to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson:

  1. Devirtualize the writer. Binary output wrote every byte through output_adapter_t, a shared_ptr whose write_character/write_characters are virtual — plus a make_shared per call. Unlike the lexer (templated on a concrete InputAdapterType, no per-byte indirection), the writer never got that treatment. This templates binary_writer on a concrete OutputSinkType, mirroring the input side's "concrete template fast path + optional virtual interface" split.
  2. Byte-swap number encoding instead of a per-byte std::reverse.

Output is byte-for-byte unchanged on every input; only the path taken to produce it changes.

1. Value-type output sink (devirtualization)

  • output_vector_sink (new, output_adapters.hpp) — appends straight into a std::vector (push_back / insert). No vtable, no shared_ptr; the writes inline. Used by the vector-returning to_* convenience functions.
  • output_adapter_sink (new) — forwards to the type-erased output_adapter_t, so the existing to_*(j, output_adapter) overloads (streams, strings, user adapters) keep working exactly as before: one virtual call each, unchanged.
  • binary_writer gains a defaulted OutputSinkType parameter and writes through oa.write_* instead of oa->write_*; a convenience constructor taking output_adapter_t keeps the adapter overloads untouched. The friend declaration and the binary_writer alias are updated accordingly.

The type-erased output_adapter is retained as the fallback sink — nothing is removed from the public API. (Scope note: the vector-returning convenience path is devirtualized; the to_*(j, target) overloads, streams, and text dump() still use the virtual adapter by design.)

2. Byte-swap number encoding

write_number() reordered multi-byte numbers for the big-endian formats (CBOR/MsgPack/UBJSON) with std::reverse over the byte array. GCC lowered only some sizes to a bswap; clang kept a scalar byte shuffle (0 bswap in the CBOR number path). Replaced with size-dispatched __builtin_bswap16/32/64 helpers (portable shift fallback for other compilers; std::reverse retained for exotic sizes such as a long double number_float_t). The CBOR number path now emits bswap on both compilers (gcc 2→16, clang 0→4).

Portability

Included fixes so the change is warning/sanitizer-clean across the full matrix:

  • GCC -Wduplicated-branches in write_compact_float: for number_float_t == float the two branches are legitimately identical once the concrete sink inlines; silenced for GCC only (clang has no such warning).
  • clang UBSan nonnull-attribute: binary_writer passes (null, 0) for empty string/binary; dropped JSON_HEDLEY_NON_NULL from the sinks so they tolerate the zero-length write exactly as the pre-existing virtual base did.
  • cpplint: added #include <utility> for std::move.
  • nvcc 11.8: reverted the alias-template default argument its front end rejects (the class keeps the defaulted parameter).

Measured gains

-O3, best-of-N, low-noise runner:

workload devirtualization (vs develop) + byte swap (isolated)
scalar-dense binary (integer arrays) ~1.4× gcc +7% / clang +10% (int64); gcc +27% (uint16)
many small to_cbor calls ~1.04× (DOM-traversal bound) —
string / blob-heavy output unchanged (bulk-bound) unchanged

No workload regressed. Both wins are concentrated on number/array-heavy encoding; object/string-heavy binary output is bound elsewhere (the std::map DOM walk, string handling), which no writer change touches.

Verification

  • Differential: binary output is byte-for-byte identical to develop across ~3000 randomized values plus curated edge cases (all scalar widths, strings incl. invalid UTF-8, binary, nested arrays/objects) for CBOR, MessagePack, UBJSON (both size/type settings), BJData, and BSON, plus the output_adapter path, in C++11/17/20. All formats round-trip. The byte-swap change is separately verified byte-identical.
  • New sink lines are exercised by the existing binary-format suites (to_cbor(j) → vector sink; to_cbor(j, adapter)/stream → adapter sink; both constructors).
  • Warning-clean under clang -Weverything and the gcc pedantic set (incl. -Wduplicated-branches); clang-tidy clean on the changed headers; cpplint clean; make check-amalgamation clean. __builtin_bswap16 is available on the CI's oldest gcc (4.8).

  • The changes are described in detail, both the what and why.
  • If applicable, an existing issue is referenced. (none)
  • The code coverage remained at 100%. A test case for every new line of code. (new sink/constructor/number-path lines are covered by the existing binary-format suites)
  • If applicable, the documentation is updated. (no public API or option changes)
  • The source code is amalgamated by running make amalgamate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG

@github-actions github-actions Bot added the L label Jul 21, 2026
Comment thread include/nlohmann/detail/output/binary_writer.hpp Fixed
Comment thread include/nlohmann/detail/output/binary_writer.hpp Fixed
Comment thread include/nlohmann/detail/output/binary_writer.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
@nlohmann nlohmann changed the title Devirtualize binary_writer with a value-type output sink Speed up binary writing: value-type output sink + byte-swap number encoding Jul 22, 2026
@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch from 487f905 to 9ea2d28 Compare July 22, 2026 11:01
@github-actions

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated and/or formatted correctly.

📎 A ready-to-apply patch is attached to the failed workflow run as the amalgamation-patch artifact. Download it, then apply it locally from the repository root with:

git apply amalgamation.patch

This does not require installing astyle yourself.

@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch 2 times, most recently from 895dfbe to 8895cfb Compare August 4, 2026 06:58
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated and/or formatted correctly.

📎 A ready-to-apply patch is attached to the failed workflow run as the amalgamation-patch artifact. Download it, then apply it locally from the repository root with:

git apply amalgamation.patch

This does not require installing astyle yourself.

@nlohmann

Copy link
Copy Markdown
Owner Author

Review

Reviewed at 8895cfb. The mechanical part of the rewrite checks out: diffing develop's binary_writer.hpp with oa-> normalised to oa. against this branch, at the true merge base, the only removals are the old constructor, the std::reverse call and the old member — no dropped guards, no behaviour hiding in the 400-line diff. The three byte-swap fallbacks are correct on inspection for all widths. Findings below.

Blocking

1. CI is red. All seven Windows/clang jobs fail (one real failure, six fail-fast cancellations). test-regression2_cpp20 fails to link:

CMakeFiles\test-regression2_cpp20.dir/objects.a(unit-regression2.cpp.obj):unit-regression2.c:(.debug_info+0x16):
relocation truncated to fit: IMAGE_REL_AMD64_SECREL against `.debug_line'

develop's latest Windows run is green, so this does not look like a pre-existing flake. Plausible cause is this PR: unit-regression2.cpp now instantiates binary_writer<basic_json, uint8_t, output_vector_sink<uint8_t>> in addition to binary_writer<basic_json, uint8_t, output_adapter_sink<uint8_t>> (plus the char variants), roughly doubling the writer's debug info in every TU that uses both to_cbor(j) and to_cbor(j, adapter), which pushes the MinGW Debug build past the 32-bit SECREL relocation limit. The same mechanism doubles emitted code for the binary formats in release builds — a cost the description doesn't mention.

2. -Wduplicated-branches pragma is not version-guarded (binary_writer.hpp:1934). The guard is only defined(__GNUC__) && !defined(__clang__), but the warning is GCC 7+. On g++-4.8/4.9/5/6 — which ci_test_compilers_gcc_old builds and which the library supports — GCC emits warning: unknown option after '#pragma GCC diagnostic' kind [-Wpragmas], on by default, for every TU including the header. Header-only + downstream -Werror = broken build. Every other version-sensitive pragma in the tree goes through Hedley's JSON_HEDLEY_GCC_VERSION_CHECK; this one bypasses it.

Undocumented third change

The head commit ("Reserve output capacity up front for binary serialization") adds binary_reserve_hint() and result.reserve(...) to all five vector-returning to_* functions. This appears nowhere in the description, which enumerates exactly two changes with "The changes are described in detail" ticked. Two consequences:

3. Up to 4× over-reservation (json.hpp:4330 and the four siblings). The hint is 4 bytes per top-level element, but arrays of small scalars encode to ~1 byte each, and the vector is returned by value with the inflated capacity intact. to_cbor(json(std::vector<bool>(200000, true))) produces ~200 KB and reserves 800,002 bytes — the caller holds 800 KB to store 200 KB, indefinitely. Same for arrays of small integers (CBOR encodes 0..23 in one byte). Trading a ~1.4× speedup for a 4× memory blowup on precisely the workload being optimised seems like the wrong deal; 1–2 bytes per element, or sizing from the element type, would avoid it.

Also worth noting: the benchmark table attributes the ~1.4× scalar-dense win to devirtualization alone, when part of it is plausibly the new reserve.

4. Coverage. binary_reserve_hint's clamp branch (binary_writer.hpp:64, return max_hint;) fires only above 262144 elements. The largest container in any binary-format test is json j(65793, nullptr) (unit-cbor.cpp:1310), so that line is unreachable and the clamp arithmetic ships unverified. No tests were added, while "code coverage remained at 100%" is ticked.

Design / maintainability

5. The to_*(j) overloads no longer delegate to to_*(j, o). Each of the five open-codes the writer construction, so the pairs can drift silently — to_bjdata(j) and to_bjdata(j, o) now independently spell out write_ubjson(j, use_size, use_type, true, true, version), and no test compares the two paths. A private helper templated on the sink would keep one source of truth and collapse the five near-identical detail::binary_writer<basic_json, std::uint8_t, detail::output_vector_sink<std::uint8_t>>(...) spellings.

6. output_vector_sink duplicates output_vector_adapter line for line (output_adapters.hpp:130) — same reference member, same push_back, same insert(v.end(), s, s + length). Having the adapter hold a sink and forward to it would leave one implementation to fix.

7. Constructor comment claims a SFINAE constraint that isn't there (binary_writer.hpp:97): "Only participates in overload resolution when the sink can be built from an adapter". The constructor is unconstrained; it merely isn't instantiated for non-adapter sinks. is_constructible reports true and a generic caller gets a hard error in the mem-initializer instead of clean overload removal. Either add the enable_if or fix the comment.

8. reverse_bytes round-trips through memcpy three times (binary_writer.hpp:1876): write_number memcpys into the array, then each overload memcpys out and back. Byte-swapping n before the single store — dispatching on sizeof(NumberType) in write_number, keeping std::reverse for exotic sizes — is one memcpy instead of three, and wouldn't have added the six new Flawfinder CWE-120 alerts the security bot filed here (498–503), each of which now needs a dismissal.

9. MSVC keeps the scalar shuffle (binary_writer.hpp:1843). __builtin_bswap* is GCC/clang only; the #else shift-and-mask is exactly the shape the PR exists to eliminate, and MSVC doesn't reliably recognise it. _byteswap_ushort/_byteswap_ulong/_byteswap_uint64 under _MSC_VER would close the gap for the one remaining major toolchain.

Minor

10. Missing the public-API section (detail::binary_writer gained a third template parameter, which breaks anyone naming or forward-declaring it with two).

11. output_adapter_t<CharType> oa = nullptr; in output_adapter_sink (output_adapters.hpp:185) is dead — the single constructor always initialises it, and JSON_ASSERT(oa) exists precisely to rule out the null state the initializer implies is supported.

12. The branch is 15 commits behind develop and the checks date from 2026-08-04, i.e. before #5211 touched binary_writer.hpp. The merge is textually clean (no new oa-> landed on develop), but the "byte-for-byte identical to develop" differential was measured against ad94fb01c, and the matrix wants a rerun on a rebased branch.


This review was written by Claude Code.

@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch from 8895cfb to 45405f7 Compare August 19, 2026 18:34
@github-actions github-actions Bot added the tests label Aug 19, 2026
Comment thread include/nlohmann/detail/output/binary_writer.hpp Dismissed
Comment thread single_include/nlohmann/json.hpp Dismissed
@nlohmann
nlohmann marked this pull request as ready for review August 25, 2026 06:14
@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch from 45405f7 to aad5fa9 Compare August 31, 2026 23:25
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 9, 2026
@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch 2 times, most recently from 357342d to ac58c6b Compare September 11, 2026 16:12
@gregmarr

Copy link
Copy Markdown
Contributor

Needs to be rebased on develop.

nlohmann and others added 6 commits September 22, 2026 21:40
to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson wrote every byte through
output_adapter_t, a shared_ptr<output_adapter_protocol> whose
write_character/write_characters are virtual. Unlike the lexer (templated
on a concrete InputAdapterType), the binary writer never got that
treatment, so binary output paid a vtable lookup per byte and a
make_shared per call.

Template binary_writer on an OutputSinkType and give it two concrete,
non-virtual sinks:

- output_vector_sink: appends straight into a std::vector (push_back /
  insert), used by the vector-returning to_* convenience functions. No
  vtable, no shared_ptr; the writes inline.
- output_adapter_sink: forwards to a type-erased output_adapter_t, so the
  existing to_*(j, output_adapter) overloads (streams, strings, custom
  adapters) keep working exactly as before -- one virtual call each,
  unchanged.

binary_writer keeps a convenience constructor taking output_adapter_t
(building the default output_adapter_sink), so the adapter overloads are
untouched; only the convenience functions switch to the vector sink. The
friend declaration and the basic_json binary_writer alias gain the new
(defaulted) template parameter.

Output is byte-for-byte identical: verified across ~3000 randomized
values plus curated edge cases (all scalar widths, strings with invalid
UTF-8, binary, nested arrays/objects) for CBOR, MessagePack, UBJSON (both
size/type settings), BJData, and BSON, plus the output_adapter path, in
C++11/17/20. Warning-clean under clang -Weverything and the gcc pedantic
set; clang-tidy clean on the changed headers; make check-amalgamation
clean.

Throughput (g++ -O3, vs develop): scalar-dense binary output such as
integer arrays ~1.4x; many small to_cbor calls ~1.04x (DOM traversal
bound); string/blob-heavy output unchanged (already bulk-bound). No
workload regressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Four CI jobs failed on the initial commit; all are addressed here without
changing any output (binary encodings remain byte-for-byte identical to
develop across the differential corpus):

1. ci_test_gcc / cuda (-Werror=duplicated-branches): for number_float_t ==
   float, static_cast<float>(n) is the identity, so write_compact_float's
   two branches are intentionally identical. Once the concrete vector sink
   is inlined, GCC constant-folds and diagnoses this (the type-erased path
   hid it behind a non-inlined virtual call). Silence -Wduplicated-branches
   for GCC (clang has no such warning) alongside the existing -Wfloat-equal
   pragma.

2. ci_static_analysis_clang (UBSan nonnull-attribute): binary_writer passes
   a null pointer with length 0 for empty strings/binary. output_vector_sink
   / output_adapter_sink declared write_characters JSON_HEDLEY_NON_NULL, so
   the sanitizer flagged the (harmless) zero-length call once the sink was
   called directly rather than through the attribute-free virtual base. Drop
   the attribute from both sinks, matching the pre-existing behavior.

3. ci_cpplint (build/include_what_you_use): output_adapter_sink uses
   std::move; add #include <utility>.

4. ci_cuda_example (nvcc 11.8): NVCC's front end rejects the default
   template argument on the binary_writer alias template. Revert the alias
   to its original single-parameter form (relying on binary_writer's own
   defaulted OutputSinkType) and spell out the full type in the vector-sink
   convenience functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
write_number() reordered multi-byte numbers for the big-endian formats
(CBOR/MessagePack/UBJSON) with std::reverse over the byte array. GCC
lowered only some sizes to a bswap; clang kept a scalar byte shuffle
(0 bswap instructions in the CBOR number path). Replace the reverse with
size-dispatched __builtin_bswap16/32/64 helpers (portable shift fallback
for other compilers; std::reverse retained for exotic sizes such as a
long double number_float_t).

Codegen: the CBOR number path now emits bswap on both compilers
(gcc 2 -> 16, clang 0 -> 4). Output is byte-for-byte identical to the
previous implementation across the binary differential corpus.

Throughput (isolated vs the std::reverse version, best of 9):
  CBOR int64 array   gcc +7%   clang +10%
  CBOR uint16 array  gcc +27%  clang flat

Modest but consistent on number-dense encodings; negligible on
string/blob-heavy output, as expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The vector-returning to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson grew
the output buffer purely by geometric reallocation. Reserving an estimate
up front avoids the early reallocations, which is the dominant per-byte
cost for array/object-heavy output.

The estimate (binary_reserve_hint) is deliberately conservative and safe
against untrusted input: it consults only the top-level element count
(O(1), no walk of the DOM), guards the multiplication against overflow,
and clamps the result to a fixed 1 MiB ceiling, so a large or hostile DOM
can never force an oversized allocation here. The buffer still grows
geometrically past the hint, so an underestimate only costs a few later
reallocations; scalars/strings/binary are written in one shot and get no
hint. Reserving capacity does not change the bytes produced.

Throughput (g++/clang -O3, vs the previous commit):
  cbor int array     +10% / +13%
  cbor object array  +20% / +38%

Output is byte-for-byte identical to develop across the binary
differential corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- binary_reserve_hint(): the 4-bytes-per-element estimate over-reserved by up
  to 4x for arrays of small scalars (CBOR encodes 0..23 in one byte), and the
  returned vector kept that capacity. Make the hint a strict lower bound on the
  encoded size instead, which also removes the 1 MiB clamp whose branch no test
  could reach (the largest container in the suite has 65793 elements).

- Guard the -Wduplicated-branches pragma with __GNUC__ >= 7. The warning does
  not exist before GCC 7, so naming it made GCC 4.8/4.9/5/6 - which the CI
  matrix still builds - warn under -Wpragmas on every including translation
  unit, breaking downstream -Werror builds.

- Constrain the adapter constructor of binary_writer with the enable_if its
  documentation already claimed, so a writer over some other sink type is no
  longer advertised as constructible from an output adapter.

- Let output_vector_adapter wrap output_vector_sink rather than duplicating the
  append logic, so the type-erased and templated paths share one implementation.

- Collapse the three copies of the memcpy/byte_swap/memcpy dance into a single
  byte_swap_buffer() helper, and add the MSVC _byteswap_* intrinsics so MSVC no
  longer falls back to the scalar shuffle this change exists to eliminate.

- Add a vector_writer() helper for the five vector-returning to_* overloads
  instead of spelling out the writer type at each call site, and drop a dead
  default member initializer on output_adapter_sink.

- New tests: the vector sink and the adapter sink must produce identical bytes
  for every format (the two to_* overloads no longer delegate to each other and
  could otherwise drift), and binary_reserve_hint() must never exceed the size
  actually written.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Match #5485, which moved the binary writer's hand-rolled diagnostic
pragmas onto JSON_HEDLEY_PRAGMA (merged into develop while this branch
was open). The devirtualization's -Wduplicated-branches suppression in
write_compact_float was the one raw '#pragma GCC diagnostic' left; it
now uses JSON_HEDLEY_PRAGMA like the adjacent -Wfloat-equal line, still
guarded to GCC >= 7 and non-clang (the warning exists only there).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch from ac58c6b to 4d0ccb4 Compare September 22, 2026 19:44
@nlohmann nlohmann added 🚀 ready to merge Ready to merge - just waiting for CI to complete. and removed review needed It would be great if someone could review the proposed changes. labels Sep 23, 2026
@nlohmann
nlohmann merged commit 1054b20 into develop Sep 23, 2026
161 checks passed
@nlohmann
nlohmann deleted the claude/binary-writer-output-sinks branch September 23, 2026 06:59
@nlohmann nlohmann added this to the Release 3.13.0 milestone Sep 23, 2026
nlohmann added a commit that referenced this pull request Sep 30, 2026
* Fix stale and missing comments in binary_writer

The doc block of write_number() ended up above the byte_swap() helpers
added in #5286, about 80 lines from the function. It was also a plain
comment that Doxygen skips, said "write a number to output input", and
left BON8 out of the big-endian formats. Move it back onto
write_number() as a /*! block and fix the text.

write_bson() documented "@pre j.type() == value_t::object", but it
throws type_error.317 for every other type, and to_bson() relies on
that. Document the exception instead.

Explain why the CBOR binary subtype is always written with a 0xD8..0xDB
head and never in the one-byte tag form: binary_reader with
cbor_tag_handler_t::store only keeps those heads as a subtype, so
switching to write_cbor_head() would break round trips for subtypes
0..23.

Also fix the grammar of the to_char_type comment. Comments only; no
change in behavior, API or ABI.

Part of #5710

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Merge the duplicated UBJSON/BJData integer marker ladders

write_number_with_ubjson_prefix() (unsigned and signed overloads) and
ubjson_prefix() (number_integer and number_unsigned cases) each picked
the UBJSON/BJData integer marker (i, U, I, u, l, m, L, M, H) with their
own independent if/else ladder, and the values beyond 64 bits were
handled by a second, tag-dispatched pair of ladders. An optimized
container announces the marker of its first element via ubjson_prefix()
and then writes every element through write_number_with_ubjson_prefix(),
so the two had to be kept in lockstep by hand across four call sites.

Replace all of that with one ubjson_integer_prefix() built on
value_in_range_of<T>, and one write_ubjson_integer_payload() that
writes the value (or, for 'H', the decimal digits) for a given marker.
write_number_with_ubjson_prefix() and ubjson_prefix() keep their
signatures and now just call these two helpers.

Behavior, the public API and the ABI are unchanged. Verified with a
new regression test covering scalars and $-optimized arrays/objects at
every int8/uint8/int16/uint16/int32/uint32/int64/uint64 boundary for
to_ubjson/to_bjdata (both use_size/use_type settings), and by diffing
to_ubjson/to_bjdata output before and after over the json_test_data
corpus (bit-identical).

Part of #5710

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Remove dead get_char parameters in binary_reader

The non-recursive rewrite of the binary readers (#5505, #5506, #5507)
left parse_cbor_internal()'s and parse_ubjson_internal()'s get_char
parameters dead: parse_cbor_internal() has one caller and it always
passes true, and parse_ubjson_internal() has one caller and it always
uses the true default. Both parameters, and the @PARAM docs describing
the "reuse the last character" mode they used to select, no longer
correspond to anything.

Drop both parameters, initialise fetch/prefix unconditionally, and
update the two call sites in sax_parse(). parse_cbor_value()'s and
get_ubjson_string()'s own get_char parameters are unrelated and are
left alone; both still have a false caller.

Also delete a stray `@return whether a valid MessagePack value was
passed to the SAX parser` doxygen block that sits directly above
parse_msgpack_value()'s real doc comment, a leftover of the same
rewrite.

Behavior, the public API and the ABI are unchanged; these are private
members of detail::binary_reader. Verified by compiling with
-Wunused-parameter and running unit-cbor, unit-ubjson, unit-bjdata and
unit-msgpack (offline, against the stubbed test_data.hpp).

Part of #5711

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Share the IEEE half-precision decoder between CBOR and BJData

binary_reader had two ~45-line copies of the IEEE 754 half-precision
decoder: CBOR's case 0xF9 and BJData's case 'h'. Once formatting is
normalised, the two blocks were identical except for the byte order
used to assemble the 16-bit half (CBOR is big endian, BJData is little
endian). Any future change to half-float decoding had to be made and
kept in sync in both places.

Add one get_half_float(format, little_endian) helper that does the two
get()/unexpect_eof() reads, assembles the half in the requested byte
order, decodes it per RFC 8949 Appendix D, and calls sax->number_float.
Both cases now just call it with their byte order; the BJData case
keeps its bjdata-only guard.

Behavior, the public API and the ABI are unchanged. Verified with a
scratch probe comparing the old and new decoders bit-for-bit (NaN by
isnan()) over all 65536 wire byte pairs, in both formats, and by
running unit-cbor and unit-bjdata (offline, against the stubbed
test_data.hpp).

Part of #5711

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Deduplicate the MessagePack unsigned-integer writer ladder

The number_integer (non-negative branch) and number_unsigned cases in
write_msgpack() each held their own copy of the fixint/uint8/16/32/64
ladder, kept in lockstep only by a comment ("we used the code from the
value_t::number_unsigned case here"). Both copies mixed union members:
the signed copy compared number_unsigned but wrote number_integer, and
vice versa.

Extract write_msgpack_unsigned(std::uint64_t), mirroring how
write_cbor_head() already avoids the same duplication for CBOR, and
call it from both cases. Each case now reads only its own active
union member. Output bytes are unchanged for the default 64-bit
number types.

#5710 item 3

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Unify float marker selection and fix the long double compile error

Four formats picked between a float32 and float64 marker through four
different helper styles: dummy-argument overloads for CBOR and
MessagePack, an std::is_same template for BON8, and a runtime if-chain
on input_format_t for write_compact_float(). With number_float_t set
to long double, to_cbor, to_msgpack and to_ubjson failed inside the
library with "call to 'get_cbor_float_prefix' is ambiguous", while
to_bson kept working because write_bson_double() takes a plain double.

Change write_compact_float() to take the two marker bytes directly
(each of its three callers already knows them at compile time) instead
of an input_format_t it only forwarded, and delete the now-unused
get_cbor_float_prefix(), get_msgpack_float_prefix(),
get_bon8_float_prefix() and get_compact_float_prefix() helpers. Turn
the two get_ubjson_float_prefix() overloads into one template. Both
write_compact_float() and get_ubjson_float_prefix() now report an
unsupported number_float_t with a static_assert naming the requirement,
rather than an ambiguous-overload error; the assert lives in the
function body, not the class scope, so to_bson with long double is
unaffected.

Verified with a probe basic_json<..., long double>: to_bson still
compiles and round-trips, while to_cbor/to_msgpack/to_ubjson now fail
to compile with the new static_assert message.

This changes the text of an existing compile error for users with an
unsupported number_float_t (documented as a public-API-visible change
in #5710).

#5710 item 1

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Deduplicate the BJData ndarray writer's dtype dispatch and drop <map>

write_bjdata_ndarray() built a 12-entry std::map<string_t, CharType> on
every call just to translate the _ArrayType_ name to a dtype marker
(the only reason binary_writer.hpp included <map>), then mapped dtype
to C++ type twice more: once as a switch for the range-check pass and
once as a separate if/else chain for the write pass, with nothing
checking that the two agreed. The caller also ran three at() lookups,
and the callee called value.at(key) about ten more times for the same
three members.

Replace the map with bjdata_ndarray_type_marker(), a plain string
comparison chain (a C++11 constexpr function cannot contain a switch,
so this mirrors binary_reader's own static table style). Replace the
switch/if-chain pair with one write_bjdata_ndarray_elements() that
switches on dtype once and calls a per-type helper -
write_bjdata_ndarray_element<T>() for the eight integer dtypes and
write_bjdata_ndarray_float_element() for 'd' - with a dry_run flag
selecting the range check or the actual write, so the two passes can
no longer disagree on the type. _ArrayType_, _ArraySize_ and
_ArrayData_ are now looked up once into references, and the four
header marker bytes ('[', '$', '#') are written through to_char_type()
like the rest of the UBJSON/BJData writer.

The 'd' (single-precision) rule is left exactly as before, since #5707
is expected to change it separately.

Verified byte-for-byte identical output before/after for every dtype
(including the Draft 2/Draft 3 'byte' fallback and the use_count/
use_type combinations) via a standalone probe, plus round-tripping
through from_bjdata().

Overlaps #5707, which is expected to touch the 'd' dtype case, and
#5518, which is expected to move the write_bjdata_ndarray() call site.

#5710 item 4

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Assert that write_bson_document() consumes every calc_bson_sizes() entry

calc_bson_sizes() and write_bson_document() are a hand-synchronized
pair of passes over the same object/array tree, introduced by #5553:
the size pass appends to nested_sizes in visiting order, and the write
pass consumes the table by position with nested_sizes[next_size++].
Nothing checked that the write pass consumed the whole table. If a
future change touched only one of the two passes - for example to skip
or reject an entry - every later size prefix in the document would be
silently wrong.

Add JSON_ASSERT(next_size == nested_sizes.size()) where
write_bson_document() returns, so such a future drift between the two
passes is caught immediately (JSON_ASSERT expands to nothing in
release builds using assert(), and the fuzzers/tests already build
with it enabled). The two passes agree today, so this changes nothing
observable; it only guards against the risk described in #5710 item 5.

Extracting a shared stepper for the two passes (the second half of the
proposed change) is left for a follow-up: it only saves ~30 lines and
the issue asks for it only if the result reads clearly, which needs
more room to get right than a mechanical cleanup pass allows.

#5710 item 5

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Make the BJData lookup tables static functions instead of members

binary_reader held bjd_optimized_type_markers and bjd_types_map as
non-static const members (12 string_t objects for the type-name table),
built and destroyed on every from_cbor/from_msgpack/from_bson/
from_ubjson/from_bon8/from_bjdata call even though only from_bjdata
ever reads them. They also needed the #define/decltype/#undef
workaround from #3637 and two NOLINTNEXTLINE suppressions, and
binary_writer already carries the same two lists in another form
(is_bjdata_excluded_type_marker() and a local std::map in
write_bjdata_ndarray(), the latter removed by the item-4 commit), so
the excluded-marker lists could drift apart.

Replace bjd_optimized_type_markers with static constexpr
is_bjd_excluded_optimized_type(char_int_type), using the same ||-chain
as binary_writer's is_bjdata_excluded_type_marker(). Replace
bjd_types_map with a non-constexpr static bjd_type_name(char_int_type)
switch returning nullptr for an unknown marker (a C++11 constexpr
function cannot contain a switch). Delete both
JSON_BINARY_READER_MAKE_* macros, the bjd_type pair alias, the
NOLINTNEXTLINE suppressions, detail::make_array() (no longer used
anywhere), and the now-unused <algorithm> and <array> includes.

Update the two call sites (the ND-array excluded-type check and the
_ArrayType_ lookup) accordingly, and replace unit-bjdata.cpp's
"LUT arrays are sorted" section, which only checked the two tables'
internal ordering, with a check of all 12 type names and all 8
excluded markers against both new functions.

#5711 item 1

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Read CBOR's 1/2/4/8-byte argument through one helper

parse_cbor_internal() hand-wrote the same "read a 1/2/4/8-byte
big-endian unsigned integer" ladder four times over:
- twice for tag numbers 0xD8-0xDB, once in the tag_handler::ignore
  branch and once, nearly identically, in the ::store branch (~90
  lines to read one integer);
- twice more for container lengths, once for array heads 0x98-0x9B and
  once for map heads 0xB8-0xBB, where the 1/2-byte forms called
  enter_array()/enter_object() directly and the 4/8-byte forms
  additionally went through get_cbor_container_size().

Add get_cbor_argument(std::uint64_t&), reading the width selected by
current & 0x1F via the same get_number() calls as before (so EOF is
reported exactly as before), and route all four sites through it:
- 0xD8-0xDB now read the argument once per branch instead of switching
  on `current` a second time; behavior split cleanly from embedded tags
  0xC0-0xD7 (tag value in the head, no argument to read), which is now
  its own case block that no longer has to fall into the ::store
  switch's "default" case to reach the same tag_pending = true; return
  true; outcome.
- 0x98-0x9B and 0xB8-0xBB collapse into one case block each, always
  going through get_cbor_container_size() (harmless for 1/2-byte
  lengths, which already always fit).

Verified byte-for-byte identical behavior before/after with a
standalone probe covering embedded and multi-byte tags under all three
tag_handler_t settings, a tag over a byte string (subtype path),
truncated tag/length arguments of every width, and array/map lengths
of every width, including the out_of_range.408 "excessive size" case:
same exceptions, same messages, same chars_read, same successful
results.

Left the string/byte-string length ladders in get_cbor_string()/
get_cbor_binary() untouched, as noted in #5711 item 2, since #5325 is
expected to touch them separately.

Overlaps #5601 (adds a branch right above the embedded-tag case) and
#5607 (touches the integer cases 0x18-0x1B, which share this ladder's
shape in separate hunks).

#5711 item 2

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Add leave_container() to match enter_container()

Every container is opened through enter_container(), whose docs
promise that a check placed there runs before every start event. The
close side had no equivalent: the same
"container_stack.pop_back(); dispatch to end_object() or end_array()"
sequence was written out separately in BSON, CBOR, MessagePack,
UBJSON/BJData and BON8, each copying the pattern of keeping an
is_object flag around the pop_back() that would otherwise invalidate
a reference to it. A check needed on close would have had to be added
in five places, and a sixth copy could go unnoticed.

Add leave_container() next to enter_container(), doing the same
pop-then-dispatch, and replace the five sites with it. Each site keeps
its own surrounding logic (BSON's check_bson_document_size() call
before popping, MessagePack's is_object copy used again below,
UBJSON/BJData's remaining-container handling after popping, BON8's
top used again below); only the repeated pop/dispatch line pair is
now shared.

Verified all six binary-format unit suites and unit-regression2's
deep-nesting tests (dependent count/reuse count and the bjdata ndarray
depth cases) still pass, compiled with -Wall -Wextra and ASan/UBSan.

Overlaps #5601, which is expected to add a sixth close site in its own
skip loop; that site can route through leave_container() too once it
lands.

#5711 item 4

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Stop passing the input format to sax_parse() when the reader already has it

binary_reader's constructor stores the format in the input_format
member, and sax_parse(format, sax_, strict, tag_handler) took the same
value again purely to dispatch on it. Every in-tree caller passed the
same value both times (all 16 from_cbor/from_msgpack/from_ubjson/
from_bjdata/from_bon8/from_bson call sites in json.hpp, and the three
public basic_json::sax_parse() overloads), so nothing was broken
today, but a caller of the detail class directly (only reachable via
JSON_PRIVATE_UNLESS_TESTED, as unit-bjdata.cpp already does) could
pass a mismatched pair - say bjdata to the constructor and ubjson to
sax_parse - and dispatch on one format while applying the other
format's rules; the default-constructed input_format_t::json reader
would additionally hit JSON_ASSERT(false) in exception_message() on
its first error.

Add sax_parse(json_sax_t*, bool, cbor_tag_handler_t) forwarding to the
existing overload with the stored input_format, and switch every
caller to it: the 16 from_*() sites (keeping their
`// cppcheck-suppress[accessMoved]` comments) and the three
basic_json::sax_parse() overloads, all of which already had the format
available from their own `format` parameter. The four-argument overload
is kept for anyone still calling it, now with
JSON_ASSERT(format == input_format) so a mismatch fails immediately
in a debug build (assert-enabled binaries, including the fuzzers and
test suite) instead of misbehaving; verified with a probe that
constructs a reader for one format and calls the explicit overload
with another, which aborts on that assertion as expected.

Removing or asserting against the constructor's input_format_t::json
default, which would affect direct detail users, is left as a separate
decision per #5711 item 5.

Overlaps #5601, which is expected to add an AllowRecovery template
parameter to sax_parse() and touch these same call sites in json.hpp.

#5711 item 5

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Deduplicate UBJSON/BJData signed-count handling, drop dead ndarray checks

get_ubjson_size_value()'s 'i'/'I'/'l'/'L' cases each read a differently
sized signed integer and then repeated the same "reject negative with
error 113" check; only 'L' additionally checked value_in_range_of for
the out_of_range.408 case. Any change to that error path had to be
made four times.

Add get_ubjson_signed_count<SignedType>(std::size_t&), doing the read,
the negative check and the range check once, and route all four
markers through it. The range check is a no-op for 'i'/'I'/'l' (their
values always fit std::size_t) and only live for 'L' on a 32-bit
std::size_t target, matching today's behavior exactly.

In the ndarray dimension-product loop, the preceding loop already
returns early on any zero dimension and result starts at 1, so `i > 0`
in the pre-multiplication overflow check was always true, and
`result == 0` in the post-multiplication check could not be reached
either: two positive factors whose product does not overflow (as the
pre-check already guarantees) cannot be zero. Drop the dead `i > 0 &&`
and narrow the post-check to `result == npos`, the one case the
pre-check cannot rule out (an exact, non-overflowing match with the
sentinel reserved for unknown-size containers), with a comment
explaining why.

Verified byte-for-byte identical behavior before/after with a
standalone probe covering negative counts for every marker, a matching
positive count, and ndarray inputs, plus the full unit-ubjson and
unit-bjdata suites (same assertion counts as before this change).

Overlaps #5601 (rewrites the four parse_error calls and the overflow
checks touched here) and #5607/#5707 (touch neighboring lines in the
same functions).

#5711 item 6

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Drop redundant format parameter and dummy float argument (review)

binary_reader::sax_parse(format, ...) only ever had to equal the format
given to the constructor, which it asserted. With every caller already
on the format-less overload, remove the four-argument overload and
dispatch on the stored input_format directly. binary_reader is a
detail class, so this is not a public API change.

get_ubjson_float_prefix() took a value only to deduce its type; make
the type an explicit template argument instead.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L 🚀 ready to merge Ready to merge - just waiting for CI to complete. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants