Make basic_json destruction allocation-free and non-recursive (#5762)

* Use the provided allocator in destroy() (#4842)

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>

* Test that the destructor uses the provided allocator

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>

* Fix allocation failure during JSON destruction

Signed-off-by: Michael Sam <michaelsam94@users.noreply.github.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Make json_value::destroy() non-recursive and allocation-free

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>

* Test that destroy() performs no allocation, even under memory pressure

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>

* Refactor destroy() for readability and add edge-case tests

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>

* Make the destroy() walk helpers private

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>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Michael Sam <michaelsam94@users.noreply.github.com>
Co-authored-by: Vesko Karaganev <vesko.karaganev@gmail.com>
Co-authored-by: Michael Sam <michaelsam94@users.noreply.github.com>
Co-authored-by: Michael Sam <9461037+michaelsam94@users.noreply.github.com>
This commit is contained in:
Niels Lohmann
2026-10-06 07:40:39 +02:00
committed by GitHub
co-authored by Vesko Karaganev Michael Sam Michael Sam
parent 5379e04ce4
commit 0a365865f9
4 changed files with 734 additions and 146 deletions
+181 -73
View File
@@ -612,100 +612,210 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec
/// constructor for rvalue binary arrays (internal type)
json_value(binary_t&& value) : binary(create<binary_t>(std::move(value))) {}
void destroy(value_t t)
private:
// raw, allocation-free transfer of m_data from src to dst: no
// 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
{
dst.m_data.m_type = src.m_data.m_type;
dst.m_data.m_value = src.m_data.m_value;
src.m_data.m_type = value_t::null;
}
// true if v is not an array/object, or is an already-empty one
static bool has_no_children(const basic_json& v) noexcept
{
switch (v.m_data.m_type)
{
case value_t::array:
return v.m_data.m_value.array->empty();
case value_t::object:
return v.m_data.m_value.object->empty();
default:
return true;
}
}
static basic_json& last_child(basic_json& v)
{
if (v.m_data.m_type == value_t::array)
{
return v.m_data.m_value.array->back();
}
JSON_ASSERT(v.m_data.m_type == value_t::object);
return v.m_data.m_value.object->rbegin()->second;
}
// removes the last child of a non-empty array/object v; this never
// allocates, and since it is only ever called when that child is a
// scalar or an already-empty array/object, destroying it never
// recurses more than one level deep (see destroy() below)
static void pop_last_child(basic_json& v)
{
if (v.m_data.m_type == value_t::array)
{
v.m_data.m_value.array->pop_back();
}
else
{
JSON_ASSERT(v.m_data.m_type == value_t::object);
// erase() needs a forward iterator, so std::prev(end()) is
// used here rather than rbegin() (see last_child() above)
v.m_data.m_value.object->erase(std::prev(v.m_data.m_value.object->end()));
}
}
// deallocates the (already empty) array/object held by v; this is
// the same allocator-based free the old recursive implementation
// used, just factored out so every level of the walk in destroy()
// can share it
static void free_container(basic_json& v) noexcept
{
if (v.m_data.m_type == value_t::array)
{
JSON_ASSERT(v.m_data.m_value.array->empty());
AllocatorType<array_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, v.m_data.m_value.array);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, v.m_data.m_value.array, 1);
}
else
{
JSON_ASSERT(v.m_data.m_type == value_t::object);
JSON_ASSERT(v.m_data.m_value.object->empty());
AllocatorType<object_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, v.m_data.m_value.object);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, v.m_data.m_value.object, 1);
}
v.m_data.m_type = value_t::null; // avoid a double free if v is later destructed
}
public:
void destroy_string() noexcept
{
if (string == nullptr)
{
// not initialized (e.g., due to exception in the ctor)
return;
}
AllocatorType<string_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, string);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, string, 1);
}
void destroy_binary() noexcept
{
if (binary == nullptr)
{
// not initialized (e.g., due to exception in the ctor)
return;
}
AllocatorType<binary_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, binary);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, binary, 1);
}
// t must be value_t::array or value_t::object
void destroy_container(value_t t) noexcept
{
if (
(t == value_t::object && object == nullptr) ||
(t == value_t::array && array == nullptr) ||
(t == value_t::string && string == nullptr) ||
(t == value_t::binary && binary == nullptr)
(t == value_t::array && array == nullptr)
)
{
// not initialized (e.g., due to exception in the ctor)
return;
}
if (t == value_t::array || t == value_t::object)
// Destroy the tree without recursing per nesting level and
// without any heap allocation: a heap-allocated flattening
// stack (the previous implementation) can itself throw
// bad_alloc, which would escape this noexcept destructor and
// terminate the program (#5135).
//
// Instead, walk down the "last child" chain, reversing links
// as we go: cur is the container currently being emptied,
// and prev is its parent (value_t::null when there is none).
// Each 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. We only ever remove a child once
// it is a scalar or an empty array/object, which neither
// allocates nor recurses more than one level deep.
//
// This json_value is not itself a basic_json, so the
// top-level container is first moved into a local stand-in
// ("cur"); a default-constructed basic_json has a null
// pointer in its m_value (see data::m_value's initializer),
// so swapping it with *this leaves this union's own pointer
// null, and it is never looked at or freed a second time.
basic_json cur;
cur.m_data.m_type = t;
using std::swap;
swap(cur.m_data.m_value, *this);
basic_json prev; // value_t::null: no parent
while (true)
{
// flatten the current json_value to a heap-allocated stack
std::vector<basic_json> stack;
// move the top-level items to stack
if (t == value_t::array)
if (has_no_children(cur))
{
stack.reserve(array->size());
std::move(array->begin(), array->end(), std::back_inserter(stack));
}
else
{
stack.reserve(object->size());
for (auto&& it : *object)
if (prev.m_data.m_type == value_t::null)
{
stack.push_back(std::move(it.second));
}
}
while (!stack.empty())
{
// move the last item to a local variable to be processed
basic_json current_item(std::move(stack.back()));
stack.pop_back();
// if current_item is array/object, move
// its children to the stack to be processed later
if (current_item.is_array())
{
std::move(current_item.m_data.m_value.array->begin(), current_item.m_data.m_value.array->end(), std::back_inserter(stack));
current_item.m_data.m_value.array->clear();
}
else if (current_item.is_object())
{
for (auto&& it : *current_item.m_data.m_value.object)
{
stack.push_back(std::move(it.second));
}
current_item.m_data.m_value.object->clear();
free_container(cur);
return; // back at the top with nothing left to do
}
// it's now safe that current_item gets destructed
// since it doesn't have any children
// ascend: detach the grandparent link from prev's
// last slot, drop that (now null) slot, free cur
// (it is empty), then move up one level
basic_json gp;
take(gp, last_child(prev));
pop_last_child(prev);
free_container(cur);
take(cur, prev);
take(prev, gp);
continue;
}
basic_json& cur_last_ref = last_child(cur);
if (has_no_children(cur_last_ref))
{
// scalar, or already-empty array/object
pop_last_child(cur);
continue;
}
// descend into the non-empty last child, reversing the
// link: its slot takes over prev, and the child becomes
// the new cur
basic_json tmp;
take(tmp, cur_last_ref);
take(cur_last_ref, prev);
take(prev, cur);
take(cur, tmp);
}
}
void destroy(value_t t)
{
switch (t)
{
case value_t::object:
{
AllocatorType<object_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, object);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, object, 1);
break;
}
case value_t::array:
{
AllocatorType<array_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, array);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, array, 1);
break;
}
case value_t::string:
{
AllocatorType<string_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, string);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, string, 1);
destroy_string();
break;
}
case value_t::binary:
{
AllocatorType<binary_t> alloc;
std::allocator_traits<decltype(alloc)>::destroy(alloc, binary);
std::allocator_traits<decltype(alloc)>::deallocate(alloc, binary, 1);
destroy_binary();
break;
case value_t::object:
case value_t::array:
destroy_container(t);
break;
}
case value_t::null:
case value_t::boolean:
@@ -714,9 +824,7 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec
case value_t::number_float:
case value_t::discarded:
default:
{
break;
}
}
}
};