Repository navigation
Make basic_json destruction allocation-free and non-recursive - #5762
Conversation
| { | ||
| return v.m_data.m_type == value_t::array | ||
| ? v.m_data.m_value.array->back() | ||
| : std::prev(v.m_data.m_value.object->end())->second; |
There was a problem hiding this comment.
Would rbegin() be better here?
There was a problem hiding this comment.
Function is only called once, just inline?
There was a problem hiding this comment.
Yes, last_child() now uses object->rbegin()->second (daad972). On inlining it: it is called on both cur and prev (ascending and descending), so I kept it as a helper.
(Written by Claude Code.)
| } | ||
| else | ||
| { | ||
| v.m_data.m_value.object->erase(std::prev(v.m_data.m_value.object->end())); |
There was a problem hiding this comment.
Here I kept std::prev(end()): erase() needs a forward iterator, and std::next(rbegin()).base() would be harder to read. There is a comment saying so now.
(Written by Claude Code.)
| { | ||
| if (v.m_data.m_type == value_t::array) | ||
| { | ||
| AllocatorType<array_t> alloc; |
There was a problem hiding this comment.
Should this use the allocator from the container instead of default constructing one?
There was a problem hiding this comment.
I left this as is: create<T>() and the previous destroy() both default-construct AllocatorType<T>, so freeing matches allocation. Using a stateful allocator taken from the container would be a library-wide change (see #5298), not something to do only on the destruction side.
(Written by Claude Code.)
There was a problem hiding this comment.
Makes sense. I was thinking future proofing, and that it would always get the same allocator now, but I suppose that assumption could be wrong and it could trigger a new bug. Better to do it all at once.
| // set_parents()/assert_invariant() (the former is O(#children) per | ||
| // call under JSON_DIAGNOSTICS, which would make the walk below | ||
| // quadratic); dst takes ownership, src is left as value_t::null. | ||
| static void take(basic_json& dst, basic_json& src) noexcept |
There was a problem hiding this comment.
Since this function makes dangerous changes, should it be a lambda inside the destroy function?
There was a problem hiding this comment.
I kept them as static member functions: several are used from more than one place, and this avoids lambda capture noise under C++11. They are only reachable from inside json_value, and the dangerous ones now assert their preconditions (empty container, expected type).
(Written by Claude Code.)
There was a problem hiding this comment.
Can it be made private then?
There was a problem hiding this comment.
Done in 65a9dfe: the walk helpers (take, has_no_children, last_child, pop_last_child, free_container) are now private members of json_value. destroy_string/binary/container stay public since ~data() calls them.
(Written by Claude Code.)
| std::vector<basic_json> stack; | ||
|
|
||
| // move the top-level items to stack | ||
| // Destroy the tree without recursing per nesting level and |
There was a problem hiding this comment.
My personal style preference would be to have this after the switch, have the array and object cases check for null and break or return, have everything else do the null check, destroy, and return. That reduces a level of nesting, and has all of the simpler cases up front, also reduces the number of tests on t.
There was a problem hiding this comment.
Or see this comment which splits up the function: #5762 (comment)
There was a problem hiding this comment.
Done in daad972, following your split suggestion: destroy() is now a flat switch with the simple cases first, dispatching to destroy_string(), destroy_binary() and destroy_container(t). Each of those does its own "not initialized" null check.
(Written by Claude Code.)
|
I'm looking at I am wondering whether it would make sense to have I'm still a bit unsure why these member calls should set the |
| basic_json cur; | ||
| cur.m_data.m_type = t; | ||
| cur.m_data.m_value = *this; | ||
| if (t == value_t::array) | ||
| { | ||
| stack.reserve(array->size()); | ||
| std::move(array->begin(), array->end(), std::back_inserter(stack)); | ||
| array = nullptr; | ||
| } | ||
| else | ||
| { | ||
| stack.reserve(object->size()); | ||
| for (auto&& it : *object) | ||
| { | ||
| stack.push_back(std::move(it.second)); | ||
| } | ||
| object = nullptr; | ||
| } |
There was a problem hiding this comment.
This looks like
basic_json cur;
std::swap(cur.m_data, *this);
There was a problem hiding this comment.
Close. *this is the json_value, not the data, so it is now cur.m_data.m_type = t; swap(cur.m_data.m_value, *this);. A default-constructed value's union holds a null pointer, so *this ends up null exactly as before, and the if/else that cleared the pointer is gone.
(Written by Claude Code.)
There was a problem hiding this comment.
Right, I shouldn't have taken off m_value. This would need to be in data for that swap to work.
| const bool last_is_container = last.m_data.m_type == value_t::array || last.m_data.m_type == value_t::object; | ||
|
|
||
| if (!last_is_container || is_empty_container(last)) |
There was a problem hiding this comment.
I found this phrasing hard to reason about. Also, is_empty_container() requiring that we already know that it is a container isn't obvious.
bool has_no_children(basic_json &v)
{
if (v.m_data.m_type == value_t::array) return v.m_data.m_value.array->empty());
if (v.m_data.m_type == value_t::object) return v.m_data.m_value.object->empty());
return true;
}
if (has_no_children(last))
{
pop_last_child(cur);
continue;
}
There was a problem hiding this comment.
Agreed. is_empty_container() is replaced by has_no_children(), which returns true for non-containers too, so the loop now checks just if (has_no_children(cur_last_ref)) and the hidden "must already be a container" precondition is gone.
(Written by Claude Code.)
|
|
||
| // it's now safe that current_item gets destructed | ||
| // since it doesn't have any children | ||
| basic_json& last = last_child(cur); |
There was a problem hiding this comment.
When working out a verification of the algorithm, I found it helpful to refer to this as cur.last& so maybe call it cur_last_ref?
There was a problem hiding this comment.
Renamed to cur_last_ref.
(Written by Claude Code.)
| if (prev.m_data.m_type == value_t::null) | ||
| { | ||
| stack.push_back(std::move(it.second)); | ||
| break; // back at the top with nothing left to do |
There was a problem hiding this comment.
Move the lines from after the loop here so no one has to look past the loop to see what happens next, and so that way we know that there are no other breaks from this loop.
if (prev.m_data.m_type == value_t::null)
{
free_container(cur);
return;
}
There was a problem hiding this comment.
Done: the "no children and no parent" case now does free_container(cur); return; inside the loop, so there is no break and nothing after the loop.
(Written by Claude Code.)
|
It seems like the algorithm is sound. It might be useful to have tests for some of the potential edge cases. Containers with a mix of empty objects, empty arrays, non-empty objects, non-empty arrays, non-containers, containers in the first position, containers in the last position, both ordered and unordered objects. |
| if (v.m_data.m_type == value_t::array) | ||
| { | ||
| AllocatorType<array_t> alloc; | ||
| std::allocator_traits<decltype(alloc)>::destroy(alloc, v.m_data.m_value.array); |
There was a problem hiding this comment.
assert that the container is empty here and in the else?
There was a problem hiding this comment.
Added JSON_ASSERT(...->empty()) in both branches.
(Written by Claude Code.)
| } | ||
| else | ||
| { | ||
| AllocatorType<object_t> alloc; |
There was a problem hiding this comment.
assert that type is object to catch mis-uses, especially if this is not a local lambda?
There was a problem hiding this comment.
Added JSON_ASSERT(v.m_data.m_type == value_t::object) in the object branches of free_container(), pop_last_child() and last_child().
(Written by Claude Code.)
|
Thanks for the thorough review! daad972 addresses most of it; I replied to each inline comment.
Done, and the switch in
That is existing, documented behaviour of
Added a (Written by Claude Code.) |
Yeah, just a general musing, not specific to this PR. |
| // set_parents()/assert_invariant() (the former is O(#children) per | ||
| // call under JSON_DIAGNOSTICS, which would make the walk below | ||
| // quadratic); dst takes ownership, src is left as value_t::null. | ||
| static void take(basic_json& dst, basic_json& src) noexcept |
There was a problem hiding this comment.
Can it be made private then?
| } | ||
| else | ||
| { | ||
| v.m_data.m_value.object->erase(std::prev(v.m_data.m_value.object->end())); |
| { | ||
| if (v.m_data.m_type == value_t::array) | ||
| { | ||
| AllocatorType<array_t> alloc; |
There was a problem hiding this comment.
Makes sense. I was thinking future proofing, and that it would always get the same allocator now, but I suppose that assumption could be wrong and it could trigger a new bug. Better to do it all at once.
| } | ||
| } | ||
|
|
||
| void destroy(value_t t) |
There was a problem hiding this comment.
I was thinking of putting this body in ~data because ~data only calls destroy and destroy is only called from ~data. It also means that you don't have another function called destroy() which is different than the allocator_traits::destroy() that it calls. I see that I didn't explain my rationale and didn't explicitly call that out. Just a suggestion, fine with keeping it as is if that's your preference.
There was a problem hiding this comment.
I tried this, but destroy() isn't only called from ~data(). The 19 external_constructor<...>::construct overloads in to_json.hpp and both erase() overloads also call m_value.destroy(m_type), so it has to stay a shared dispatcher. I'd rather not duplicate the switch in all those places.
(Written by Claude Code.)
There was a problem hiding this comment.
Interesting, I didn't find most of those calls. Thanks.
There was a problem hiding this comment.
Ah, I see what I did. I was only searching in the PR and not in the whole code, and thus only saw the two calls in erase(), which could be replaced with destroy_string() and destroy_binary(), and the call in ~data().
Uses the provided allocator to allocate the stack used to avoid recursion in the destroy() implementation used by ~basic_json. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Adds a regression test for #4842: destroying a nested array or object must allocate its temporary stack through the basic_json allocator, not std::allocator. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Michael Sam <michaelsam94@users.noreply.github.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
destroy() used to flatten a nested array/object into a heap-allocated std::vector to avoid recursing per nesting level. That vector could itself throw bad_alloc under memory pressure, and since it now used the basic_json's own allocator (#4842), a failing allocator supplied by the caller made this more likely, not less. An exception thrown from inside ~basic_json(), which is noexcept, terminates the program (#5135). Replace the vector-based stack with a pointer-reversal walk that visits the tree without recursing per level and without allocating anything: cur is the array/object currently being emptied, prev is its parent (or null at the top). A parent's last child slot doubles as storage for that parent's own parent link while we are below it, so no extra memory is needed. A child is only ever removed once it is a scalar or an empty array/object, which neither allocates nor recurses more than one level deep. take() moves m_data between these locals directly, bypassing set_parents()/assert_invariant() (the former is O(#children) per call under JSON_DIAGNOSTICS, which would make the walk quadratic otherwise). This also removes the std::vector<basic_json, allocator_type> stack added by #4842, so the extra allocations it introduced disappear along with it. Co-authored-by: Michael Sam <9461037+michaelsam94@users.noreply.github.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Update the #4842 regression test: it used to check that destroying a nested array/object made at least one allocation through the provided allocator (the old flattening stack). Now that destroy() does not allocate at all, assert the opposite: zero allocations, deallocations only. Rework the #5135 regression test to use a dedicated failing/counting allocator instead of overriding the process-wide ::operator new and ::operator delete, which affected every allocation in the whole unit-regression2 binary rather than just the values under test. Keep the original small repro as one case, and add deep (100000 levels) and wide-and-deep nested array/object/ordered_json cases, all destroyed while every further allocation is made to fail: the destructor must complete without allocating, without throwing, and without leaking. Co-authored-by: Michael Sam <9461037+michaelsam94@users.noreply.github.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Apply review feedback from Greg Marr on the json_value::destroy() non-recursive, allocation-free destruction walk (#5135): - last_child() now uses object->rbegin()->second instead of std::prev(object->end())->second; pop_last_child() keeps std::prev(end()) since erase() needs a forward iterator. - is_empty_container() becomes has_no_children(), a switch that returns true for every non-container type as well as empty array/object, simplifying the "scalar or already-empty child" check at the call site. The local variable `last` is renamed to `cur_last_ref` for clarity. - free_container() asserts the array/object is already empty before freeing it, and the object branches assert the expected type. - destroy(value_t t) is now a thin dispatcher to destroy_string(), destroy_binary(), and destroy_container(t), each handling its own "not initialized" check and sharing the simple cases first in the switch. - destroy_container() moves the top-level container into the local stand-in via a plain swap of the json_value union, instead of a manual copy plus clearing array/object by hand. - The "cur has no children and there is no parent" case now frees cur and returns immediately, so the main loop is a plain while (true) with no trailing code after it. Also adds edge-case tests for both json and ordered_json (mixes of empty/non-empty arrays and objects, container children in first/last position, single-element chains, top-level empty containers, and destruction via erase()/assignment), plus a mixed-tree case in the "destructor performs no allocation" test. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
daad972 to
230c460
Compare
They modify basic_json internals without maintaining its invariants and are only meant for destroy_container(), so they no longer need to be reachable from the rest of basic_json. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The non-recursive destroy walk from #5762 picked an object's last child via object_t::rbegin() and std::prev(end()). Neither is available for every ObjectType: no_key_compare_map in unit-custom-object-type.cpp has no rbegin(), so develop no longer compiles that test, and hash maps such as std::unordered_map only have forward iterators. The walk can take an object's children in any order, as long as it finds the same child again while the object is not modified in between. So objects with bidirectional iterators keep using their last child (O(1) to remove from vector-based maps like ordered_map), and objects with forward-only iterators use begin() instead. No reverse iteration or rbegin() is needed any more, and the walk stays allocation-free. Adds a forward-only ObjectType to the tests, destroyed both with mixed nesting and 100000 levels deep. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- unit-regression2.cpp (#5762): const/auto for the destroy() test values; NOLINT the intended copy in check_destroy_edge_case(). - unit-serialization.cpp (#5774): build the expected strings with += instead of chained operator+ (performance-inefficient-string-concatenation). Signed-off-by: Niels Lohmann <mail@nlohmann.me>
* Fix CI on develop after #5585 - test-diagnostics-optimized: -O3 makes GCC's -Winline and -Wsuggest-attribute=pure/const warnings fire with the ci_test_gcc flag set; turn them off for this test. - test-diagnostics-optimized: suppress Clang's -Wexit-time-destructors for the static table in to_json. - Infer: raise pulse-max-disjuncts from 20 to 40. With the default, Pulse loses the stored type in basic_json::replace_value() and reports false null dereferences of get_ptr() results in unit-pointer_access.cpp. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Ignore Infer's false USE_AFTER_DELETE in ordered_map::erase Infer's std::string model keeps the buffer of a moved-from string, so the destroy-and-reconstruct loop in erase(first, last) looks like it destroys a buffer twice. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Mark throw_on_discarded()'s parameters as used without exceptions With JSON_NOEXCEPTION, JSON_THROW expands to std::abort(), so Clang's -Wunused-parameter breaks test-disabled_exceptions (since #5761). Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Skip the span_input_adapter sax_parse checks with deleted deprecated functions The #5676 regression test (#5740) calls the deprecated sax_parse(span_input_adapter&&, ...), which JSON_DELETE_DEPRECATED_FUNCTIONS deletes, so ci_test_delete_deprecated_functions failed to build. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fall back to the first entry in test-diagnostics-optimized's to_json clang-tidy (clang-analyzer-security.ArrayBound) flagged it->second for a value not in the table. Use the same fallback as NLOHMANN_JSON_SERIALIZE_ENUM; the test still fails with -Werror=array-bounds on the headers from before #5585. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fix clang-tidy findings in tests from #5762 and #5774 - unit-regression2.cpp (#5762): const/auto for the destroy() test values; NOLINT the intended copy in check_destroy_edge_case(). - unit-serialization.cpp (#5774): build the expected strings with += instead of chained operator+ (performance-inefficient-string-concatenation). Signed-off-by: Niels Lohmann <mail@nlohmann.me> --------- Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Copy timed only the copy and destroyed it with the timer paused, and Destroy did the reverse. How a document is freed shapes the heap the next copy allocates from: since the faster destruction (#5762/#5767), part of its saved work shows up in the next copy's allocations. Copy therefore looked up to 40% slower than 3.12.0 on glibc although copying and destroying together got 1.1-1.5x faster. Copy now times a temporary copy including its destruction. CopyAssign assigns over an earlier copy, and CopyRing replaces the oldest of eight live copies. The old benchmarks remain as CopyOnly and DestroyOnly, with a note that they tell the halves apart but do not compare versions. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
~basic_json()callsjson_value::destroy(). To destroy deeply nested values without recursion,destroy()moved the children into a heap-allocatedstd::vector. This caused two problems:std::allocator, not theAllocatorTypethatbasic_jsonwas instantiated with.std::bad_alloc, the exception escapes thenoexceptdestructor and callsstd::terminate. Once the vector usesAllocatorType(the fix for json destructor does not use the provided allocator #4842), a failing custom allocator triggers this too. For example, the existing Deep copy of a value nested more than 128 levels crashes when an allocation fails #5640 test ("copy of a deeply nested value survives a failing allocation") then crashes. So the two issues have to be fixed together.This PR makes
destroy()allocation-free while staying non-recursive. It walks the tree with pointer reversal:curis the container currently being emptied, andprevis the chain of its parents. While the walk is below a parent, that parent's last child slot stores the link to the parent's own parent.pop_back()/erase()of the last element) only once it is a scalar or an empty container. Removing it therefore never recurses and never allocates.m_datatransfer that skipsset_parents(), which would cost O(children) per move withJSON_DIAGNOSTICS.Without the stack, nothing in
destroy()can throw. Each point from the review of #5239 is therefore resolved by construction:external_constructorcan no longer end up withm_typeandm_valueout of sync, becausedestroy()never fails halfway.JSON_NOEXCEPTIONneeds no special handling, because there is nocatchto compile away.Destruction is also faster. Median of 30 runs,
-O2, Apple clang, compared todevelop:{"a":1,"b":[1,2,3],"c":"str"}JSON_DIAGNOSTICS=1Tests:
unit-allocator.cpp: destroying nested arrays and objects with a counting allocator makes zero allocations (deallocations only).unit-regression2.cpp: the basic_json destructor allocates memory, violating noexcept semantics #5135 regression test from Fix allocation failure during JSON destruction #5239, changed to use a scoped failingAllocatorTypeinstead of replacing the globaloperator new. It now also destroys 100000-deep arrays, objects andordered_json, plus a 1000×1000 wide-and-deep value, with allocations set to fail. None are attempted.This takes over two stale PRs. Their commits are included with the authors as git authors:
destroy()), plus its regression testJSON_TRY/JSON_CATCHapproach is replaced by the rewrite above, and their regression test is kept and extended.Note: the rewrite assumes that
object_thas bidirectional iterators (std::prev(end())). This holds forstd::mapandordered_map.Closes #4842. Closes #5135. Supersedes #4843 and #5239. Thanks @veselink1 and @michaelsam94!
Public API
No breaking changes.
destroy()is internal. The only observable differences: destroying a value never allocates and never callsstd::terminatebecause an allocation failed.🤖 Generated with Claude Code