Skip to content

Add dump() and comparisons to json_view - #5624

Open
nlohmann wants to merge 16 commits into
json-view/11-view-accessfrom
json-view/13-view-dump
Open

nlohmann wants to merge 16 commits into
json-view/11-view-accessfrom
json-view/13-view-dump

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Adds dump(), operator<<, operator== and operator!= to basic_json_view, and reads floats from the parser's digit layout; part of the zero-copy view stack for #5295, on top of #5622.

Summary

  • basic_json_view::dump(indent, indent_char, ensure_ascii, number_format) writes a value the way ordered_json::parse(text).dump() writes it, for the same arguments:
    • members in document order; all of them, should a key occur more than once;
    • strings escaped by the same rules, using the library's scanning kernels;
    • floats written with the library's conversion (detail::to_chars), so the output equals basic_json's byte for byte;
    • integers copied from the source, where they are already canonical, except -0, which parse() reads as 0.
    • There is no error_handler argument: the view only holds valid UTF-8.
    • With number_format::source, numbers are copied exactly as they appear in the source, e.g. 1.50, 1E2, -0, or all digits of a long integer. basic_json cannot provide this.
    • operator<< takes the indentation from the stream width, as for basic_json.
    • The writer (detail/view/serializer.hpp) writes through a raw pointer into a string. The walk is iterative, so the nesting depth is limited by memory only.
    • The output buffer is sized from the extent of the value itself (not from the rest of the document), and it is not shrunk afterwards.
  • operator== and operator!= compare two views, or a view and a basic_json value in either order. They answer whether the values parse() would produce are equal by basic_json's operator==:
    • numbers compare by value across their types (1 == 1.0);
    • objects compare by their members, with duplicate keys resolved as parse() resolves them: the last value, at the position of the first key. (Lookups such as operator[] still return the first member; this is documented in both places.)
    • member order matters where the object type keeps one (ordered_json) and not otherwise, exactly as basic_json compares;
    • discarded views compare as discarded basic_json values do, following JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON.
    • Nothing is materialized except single scalars, and the walk is iterative. There is no operator<.
  • While parsing, the view records where the integer digits, the fraction digits and the exponent of a float token are. For float and double tokens with at most 19 digits, the value is now read from that layout instead of rescanning the token:
    • the digits are read eight at a time;
    • the result is rounded by the library's conversion core, decimal_to_float() from Speed up the lexer: own float parser, string scan, and \u table #5738: Clinger's fast path where both operands are exact, and Eisel-Lemire otherwise, which needs no fallback for up to 19 digits.
    • Both round correctly, so the values are those of parse(). Longer tokens, and other float types, keep the library's conversion of the whole token.
    • get<double>(), materialize(), dump() and the comparisons all use it.
  • Docs: new pages for dump, number_format, operator==, operator!= and operator<<; the view article has a "Writing a view back" section; the pages of basic_json::dump and basic_json::operator== link to the view's counterparts. The ViewDump benchmark joins tests/benchmarks.

Performance

Measured on Apple M1 Max (Apple clang -O2) and an x86-64 KVM VPS (GCC 13 / Clang 18 -O2, pinned to one core, about ±5-10% noise), µs, best of 3 interleaved rounds of 15 runs; files from nativejson-benchmark.

Traversing canada (every number converted), previous PR → this PR: Apple M1 2074 → 1501 (-28%), x86-64 GCC 4296 → 3505 (-18%), Clang 4331 → 3348 (-23%).

dump() at this PR (a faster writer comes later in the stack):

Apple M1 x86-64 GCC x86-64 Clang
canada 5319 8180 8063
citm_catalog 379 835 623
twitter 238 454 403

The core library (json::parse / dump) is unaffected: the code is view-internal (detail/view/serializer.hpp, detail/view/compare.hpp, additions to detail/view/number.hpp, small changes to materialize.hpp, value.hpp and lookup.hpp, and new members of basic_json_view).

Tests

The tests of dump() and the comparisons are in a new file, tests/src/unit-json_view_dump.cpp (with helpers shared through tests/src/json_view_test_helpers.hpp), so that no test object exceeds the 32,767 sections that the MinGW linker of the Windows clang job accepts.

  • dump(): 2,000 generated documents compared with ordered_json::dump() for indentations -1/0/2, space and tab, and ensure_ascii; strings with every kind of escape; numbers (5,000 random doubles, and float as number_float_t); duplicate keys, 100,000 levels of nesting, streams and discarded views; the buffer of a small value stays small, and output that outgrows the estimate is handled.
  • Comparisons: pairs of generated documents, also written differently (sorted keys, canonical numbers), give the same result as basic_json, for json and for ordered_json; numbers, duplicate keys, member order, discarded values and 100,000 levels of nesting.
  • Float layout: tokens around the limits (19 and 20 digits, 2^53, 10^22, underflow, and those of float) join the bit-for-bit comparison with parse().

Public API

No breaking changes. The view API is new in this stack, so nothing here changes a released interface. New members of basic_json_view: dump, operator<<, operator==, operator!= and number_format. Reading floats from the digit layout changes no observable behavior, only how the value is computed.

User-visible decisions in this PR:

  • dump() writes members in document order and writes every occurrence of a repeated key, so for a json_view it can differ from materialize().dump().
  • operator== resolves duplicate keys as parse() does (last value), while lookups find the first member.
  • There is no operator< and no error_handler argument for dump().

Written by Claude Code.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5636 September 29, 2026 14:20
@nlohmann nlohmann changed the title json view/13 view dump Add dump() to json_view Sep 29, 2026
@nlohmann

Copy link
Copy Markdown
Owner Author

Besides merging the lower CI fixes (see #5617 to #5623), commit 5484f2e reformats docs/mkdocs/docs/examples/basic_json_view__dump.cpp with the pinned astyle 3.4.13. The "check" job runs astyle over the examples once it gets past the amalgamation step (see #5621).

— posted by Claude Code on behalf of @nlohmann

@nlohmann
nlohmann removed this pull request from stack #5636 September 30, 2026 13:19
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 3e2b810 to 7c179b3 Compare September 30, 2026 13:20
@nlohmann
nlohmann added this pull request to stack #5739 September 30, 2026 13:21
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 7c179b3 to bd22b6e Compare September 30, 2026 15:34
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch 2 times, most recently from d1e2563 to 48307dc Compare September 30, 2026 18:06
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 30, 2026
@nlohmann
nlohmann marked this pull request as ready for review September 30, 2026 18:17
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 48307dc to bc56ac6 Compare September 30, 2026 19:15
Comment thread include/nlohmann/detail/view/serializer.hpp Fixed
Comment thread single_include/nlohmann/json_view.hpp Fixed
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann
nlohmann changed the base branch from json-view/12-view-values to json-view/11-view-access October 6, 2026 09:26
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from dfa17bd to d26cbef Compare October 6, 2026 09:28
@nlohmann nlohmann changed the title Add dump() to json_view Add dump() and comparisons to json_view Oct 6, 2026
@nlohmann
nlohmann added this pull request to stack #5768 October 6, 2026 09:29
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from d26cbef to da1ca7f Compare October 7, 2026 14:44
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from f959e59 to 10757c1 Compare October 17, 2026 06:32
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 10757c1 to 3807030 Compare October 17, 2026 12:28
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 3807030 to 02748c5 Compare October 17, 2026 21:25
: The character used for indentation if `indent` is greater than `0`. The default is ` ` (space).

`ensure_ascii` (in)
: If `ensure_ascii` is `#!cpp true`, all non-ASCII characters in the output are escaped with `\uXXXX` sequences, and

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.

Will this even do anything, since everything in a basic_json_view is UTF-8?

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.

Ah, it also escapes control characters that JSON doesn't allow.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right: the input is valid UTF-8 either way, but ensure_ascii still writes non-ASCII as \uXXXX, and control characters are escaped in both modes.

— posted by Claude Code on behalf of @nlohmann

The walk over the subtree is iterative, so the nesting depth it can write is limited by available memory only, not by
the call stack -- as for [`materialize()`](materialize.md).

Strings are escaped by the same rules as [`BasicJsonType::dump`](../basic_json/dump.md). With

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.

Why not just write the source strings which weren't decoded? Oh, because you don't have that info recorded yet.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct. A string with escape sequences is stored decoded, so its source spelling is gone, and the output must match BasicJsonType::dump anyway. Strings without escapes are already copied verbatim.

— posted by Claude Code on behalf of @nlohmann

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.

That could be an option, just like for numbers. I think the primary reason for that option would be to save the encoding cost, or for strings that decoding replaced invalid characters (does it do that in this feature?), but it can't be an option unless/until that data is available anyway.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed. Decoding never replaces characters in the view: invalid UTF-8 is a parse error, as in basic_json. So such an option would only save the escaping work, and as you say, it would first need the source position of escaped strings to be recorded (#5864).

— posted by Claude Code on behalf of @nlohmann

the call stack -- as for [`materialize()`](materialize.md).

Strings are escaped by the same rules as [`BasicJsonType::dump`](../basic_json/dump.md). With
`#!cpp numbers == number_format::shortest`, floats are written with the library's shortest round-trip conversion,

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.

split up into a separate paragraph for each case for easier reading.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Split into one paragraph per case in 7b66cdb.

— posted by Claude Code on behalf of @nlohmann

if (f.pos == f.end)
{
const bool object = f.object;
stack.pop_back();

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.

invalidates f

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

f isn't used after pop_back(): object is copied out of it first, and the continue binds f again. I added a comment saying so in 7b66cdb.

— posted by Claude Code on behalf of @nlohmann

const bool object = n->kind == static_cast<std::uint8_t>(value_t::object);
if (n->len == 0)
{
m_out.put(object ? "{}" : "[]", 2);

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.

literals, determine size automatically (multiple times)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 7b66cdb: the output buffer has a put for string literals that takes the length from the array type.

— posted by Claude Code on behalf of @nlohmann

}
else
{
if (!numbers && ta != tb)

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.

swap order of tests and then inline the numbers test so it's not needed if they're the same.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 7b66cdb: ta != tb && !(is_number(ta) && is_number(tb)), so the number test only runs when the types differ.

— posted by Claude Code on behalf of @nlohmann

}
if (ta == value_t::string)
{
if (!(a.string() == b.string()))

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.

Why is this written that way? Is the result of .string() not required to have operator!=()?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No reason; string_ref and std::string_view both have !=. Changed in 7b66cdb.

— posted by Claude Code on behalf of @nlohmann

}
stack.push_back(std::move(f));
}
else if (!(a.scalar() == b.scalar())) // numbers (also of different types), null, boolean

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.

Why not !=?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Changed to != in 7b66cdb; basic_json's != is !(==) in every language mode, so nothing changes.

— posted by Claude Code on behalf of @nlohmann

{
a.members(f.members_a, ordered);
b.members(f.members_b, ordered);
if (f.members_a.size() != f.members_b.size())

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.

Potential optimization: a single function that generates one of the members, then walks through the other one, and returns false as soon as it can be shown that the second will have fewer members.

I assume that the faster method will be to generate the larger one, and if the smaller one is still smaller, it's done. If it's larger or the same, compute how many duplicates will make it fail, and then as soon as you find that many duplicates, return false.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

That would help only for objects with duplicate keys, where the deduplicated lists can differ in size; for objects without duplicates, both lists have to be built anyway. Since duplicates are rare, I'd rather not add the complexity, unless a benchmark shows otherwise.

— posted by Claude Code on behalf of @nlohmann

@gregmarr gregmarr Oct 18, 2026 •

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.

Okay, I thought those were only being built to check for duplicates. I probably hadn't read far enough to see the usage yet.

const std::size_t count = f.object ? f.members_a.size() : f.elements_a.size();
if (f.next == count)
{
stack.pop_back();

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.

invalidates f

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.

Could get around this and reuse allocations by never shrinking stack, but tracking a current member, incrementing and decrementing, and growing the stack vector when needed.

That would mean that this:

    frame f;
    f.object = ta == value_t::object;

...

    stack.push_back(std::move(f));

becomes this:

    ++currentFrame;
    if (current frame >= stack.size())
    {
        stack.emplace_back();
    }
    frame& f = stack[currentFrame];
    f.clear(); // clears all of the vectors, resets next to 0
...

and stack.pop_back(); becomes --currentFrame;.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Same here: f isn't read after pop_back(), the continue binds it again. I added a comment in 7b66cdb. Reusing the frames (and their vectors' capacity) instead of pushing fresh ones is a good idea; I'll try it as a measured follow-up rather than in this PR.

— posted by Claude Code on behalf of @nlohmann

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.

I mention it here because you've replaced that pattern in the past with a local copy and modifying the in-vector version using .back().

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good point. I'll keep that pattern in mind for the frame-reuse follow-up: reuse frames via .back() and an index instead of a reference that push_back/pop_back can invalidate.

— posted by Claude Code on behalf of @nlohmann

@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch 2 times, most recently from 669967a to 109cab3 Compare October 18, 2026 08:38
NLOHMANN_VIEW_ALWAYS_INLINE void put(const char* s, std::size_t n)
{
reserve(n);
std::memcpy(m_pos, s, n);
NLOHMANN_VIEW_ALWAYS_INLINE void put(const char* s, std::size_t n)
{
reserve(n);
std::memcpy(m_pos, s, n);
nlohmann added a commit that referenced this pull request Oct 18, 2026
From gregmarr's review of #5624:

- The output buffer takes string literals without a length.
- write_float() names the IEEE single/double condition once, as the
  library's serializer does.
- equal() checks the types first and compares with !=; the reference to
  the top frame is noted as dangling after pop_back().
- dump.md: one paragraph per case in the notes.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 109cab3 to 2692892 Compare October 18, 2026 11:41
Add basic_json_view::dump() and the comparison operators, and read
floats from the parser's digit layout instead of rescanning the
token.

dump(indent, indent_char, ensure_ascii, number_format) writes a
value the way ordered_json::parse(text).dump() writes it for the
same arguments: members in document order, all of them should a
key occur more than once; strings escaped by the same rules, using
the library's scanning kernels; floats written with the library's
to_chars conversion, so the output equals basic_json's byte for
byte; integers copied from the source, where they are already
canonical, except -0, which parse() reads as 0. There is no
error_handler argument, because the view only holds valid UTF-8.
number_format::source copies numbers exactly as they appear in the
source (e.g. "1.50", "1E2", "-0"), which basic_json cannot provide.
operator<< takes the indentation from the stream width, as for
basic_json. The writer walks iteratively, so nesting depth is
limited by memory only.

operator== and operator!= compare two views, or a view and a
basic_json value in either order, by the rules basic_json's
operator== uses: numbers compare by value across their types,
objects compare by their members with duplicate keys resolved as
parse() resolves them, member order matters only where the object
type keeps one, and discarded views compare as discarded basic_json
values do, including under JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON.
Nothing is materialized except single scalars.

While parsing, the view now records where the integer digits, the
fraction digits, and the exponent of a float token are, so floats
and doubles with at most 19 digits are read from that layout with
the library's decimal_to_float() instead of rescanning the token.
Both round correctly, so the values are those of parse(). get<double>(),
materialize(), dump(), and the comparisons all use it.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The source extent of a value was read from the next node, falling back to the rest of the document when that node held a decoded string. dump() of a small value could thus allocate a buffer as large as the document. Skip a few such nodes, cap the fallback estimate, and shrink a buffer that is much larger than its output.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
root() of a temporary document is deleted: its views would dangle.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
finish() copied the whole output when the buffer was more than twice as
large as the result (citm dump +11%). The tighter source_extent()
estimate already keeps the buffer of a small value small.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
dump() writes every member and == resolves duplicate keys as parse()
does, whereas lookups find the first member of a duplicate key.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The MinGW linker of the Windows clang jobs cannot link object files with more
than 32767 sections ("relocation truncated to fit: IMAGE_REL_AMD64_REL32
against `.rdata'"). unit-json_view.cpp reaches that limit as the stack
grows, so its "json_view dump" and "json_view comparison" test cases move
into unit-json_view_dump.cpp. The test generator and has_duplicate_keys()
that both files use move into json_view_test_helpers.hpp.

The new file mentions JSON_HAS_CPP_17, so it is built for C++17 like the file
it was split from.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
ci_single_binaries runs IWYU with --error on every header. For json_view.hpp
it suggested adding <array> (std::array is used), the headers that json.hpp
already provides (abi_config, abi_macros, input_adapters, json_pointer,
cpp_future, string_concat, value_t, json_fwd), and <version> for
std::nullptr_t, and removing <cstddef>.

- include <array>
- keep <cstddef> (nullptr_t, size_t; IWYU attributes them to <version> and <cstring>)
- tell IWYU not to suggest the headers that json.hpp provides: the amalgamated
  json_view.hpp only includes json.hpp, so including them here would duplicate
  their definitions

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The review of #5620 renamed document_data's tape/tape_size/tape_cap to
nodes/node_count/node_capacity and made the decode arena a
std::vector<char>; adapt the code of this change to it.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
json_view.hpp uses std::istream and std::ostream itself, but IWYU sees
them through the exported json.hpp and asked to remove both includes,
which fails ci_single_binaries (single_json_view).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
From gregmarr's review of #5624:

- The output buffer takes string literals without a length.
- write_float() names the IEEE single/double condition once, as the
  library's serializer does.
- equal() checks the types first and compares with !=; the reference to
  the top frame is noted as dangling after pop_back().
- dump.md: one paragraph per case in the notes.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The same std::unique_ptr false positive (USE_AFTER_DELETE) also shows for
two json_documents parsed in one lambda in unit-json_view_dump.cpp
(ci_infer). It affects any test that holds several documents, so the
exclusion now covers tests/src/unit-json_view*.cpp; ASan and Valgrind
cover these tests.

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

The overload takes the literal by array reference so that its length is a
template parameter, like scan_literal() and the C array overloads of
to_json() and from_json().

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the json-view/13-view-dump branch from 2692892 to 3d318ab Compare October 18, 2026 20:30

This branch has not been deployed

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

Labels

CI CMake documentation L review needed It would be great if someone could review the proposed changes. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants