From 4b2d2244c3151c4668045ac2ed97b25f6caeeb25 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Mon, 28 Sep 2026 18:48:05 +0200 Subject: [PATCH] Move instead of deep-copy ordered_json values when an object grows ordered_map keeps its elements in a std::vector>. With a std::string key, that pair is not nothrow move constructible (the const key has to be copied), so std::vector copies every element when it reallocates. For ordered_json, this deep-copies every member value an object already holds, including whole nested subtrees, on each growth step. Grow the storage in ordered_map instead, copying the keys and moving the values. This happens in two phases, so the strong exception guarantee is kept without try/catch. The first phase may throw, but only touches a temporary buffer: it copies the keys, value-initializes the values, and constructs the new element. The second phase moves the values (noexcept) and swaps the buffers. Because the new element is constructed before any value is moved, arguments that refer to elements of the container stay valid, as with std::vector. Types that cannot take this path keep the std::vector behavior. Parsing into ordered_json (ParseStringOrdered, Apple M1 Max, clang -O3): twitter 3.20 -> 1.70 ms, citm_catalog 7.73 -> 3.67 ms, jeopardy 219 -> 177 ms, canada unchanged. The number of allocations for twitter and citm_catalog drops by two thirds. Also add ParseStringOrdered rows to the benchmarks, and document the growth behavior and the exception safety of ordered_map. Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/ordered_map.md | 11 + include/nlohmann/ordered_map.hpp | 70 ++++- single_include/nlohmann/json.hpp | 70 ++++- tests/benchmarks/src/benchmarks.cpp | 33 +++ tests/src/unit-disabled_exceptions.cpp | 18 ++ tests/src/unit-ordered_map.cpp | 348 +++++++++++++++++++++++++ 6 files changed, 540 insertions(+), 10 deletions(-) diff --git a/docs/mkdocs/docs/api/ordered_map.md b/docs/mkdocs/docs/api/ordered_map.md index df21175d0..e464d0b1e 100644 --- a/docs/mkdocs/docs/api/ordered_map.md +++ b/docs/mkdocs/docs/api/ordered_map.md @@ -28,6 +28,11 @@ A minimal map-like container that preserves insertion order for use within [`nlo The type uses a `std::vector` to store object elements. Therefore, adding elements can yield a reallocation in which case all iterators (including the `end()` iterator) and all references to the elements are invalidated. +When the storage grows, the keys are copied and the mapped values are moved to the new storage. A plain `std::vector` +would copy the whole elements instead, because their `#!cpp const` keys make them not nothrow move constructible; for +[`ordered_json`](ordered_json.md), this would be a deep copy of every nested value. The values are only copied if +`T` is not default constructible or not nothrow move assignable. + ## Member types - **key_type** - key type (`Key`) @@ -56,6 +61,11 @@ std::equal_to<> // since C++14 - **find** - **insert** +## Exception safety + +**emplace**, **operator\[\]**, and **insert(value)** have the strong exception guarantee: if an exception is thrown (for +instance, because copying a key or allocating memory fails), the contents of the container are unchanged. + ## Complexity Because the elements are stored in a `std::vector` in insertion order, there is no index to look a key up by. Every @@ -122,3 +132,4 @@ This differs from `#!cpp std::map`, where the same operations are O(log n). - Added in version 3.9.0 to implement [`nlohmann::ordered_json`](ordered_json.md). - Added **key_compare** member in version 3.11.0. +- Changed in version 3.13.0: growing the storage moves the mapped values instead of copying them. diff --git a/include/nlohmann/ordered_map.hpp b/include/nlohmann/ordered_map.hpp index 7b8cf70f4..15e52ebb1 100644 --- a/include/nlohmann/ordered_map.hpp +++ b/include/nlohmann/ordered_map.hpp @@ -8,13 +8,15 @@ #pragma once +#include // max, min #include // equal_to, less #include // initializer_list #include // input_iterator_tag, iterator_traits #include // allocator #include // for out_of_range -#include // enable_if, is_convertible -#include // pair +#include // forward_as_tuple +#include // enable_if, integral_constant, is_convertible, is_nothrow_move_constructible +#include // forward, move, pair, piecewise_construct #include // vector #include @@ -79,7 +81,7 @@ template , return {it, false}; } } - Container::emplace_back(key, std::forward(t)); + append(key, std::forward(t)); return {std::prev(this->end()), true}; } @@ -94,7 +96,7 @@ template , return {it, false}; } } - Container::emplace_back(std::forward(key), std::forward(t)); + append(std::forward(key), std::forward(t)); return {std::prev(this->end()), true}; } @@ -368,7 +370,7 @@ template , return {it, false}; } } - Container::push_back(value); + append(value); return {--this->end(), true}; } @@ -386,6 +388,64 @@ template , } private: + /*! + @brief add an element whose key is not yet contained at the end + + A std::vector copies all elements when it grows, because their const keys + make them not nothrow move constructible. For ordered_json, this is a deep + copy of every value. Where the strong exception guarantee can be kept, grow + the storage here instead, copying only the keys and moving the values. + */ + template + void append(Args&& ... args) + { + // evaluated here rather than at class scope, because T is still + // incomplete when basic_json instantiates its object_t + using move_values = std::integral_constant>, + std::is_copy_constructible, + detail::is_default_constructible, + std::is_nothrow_move_assignable>::value>; + append_impl(move_values{}, std::forward(args)...); + } + + template + void append_impl(std::true_type /*unused*/, Args&& ... args) + { + if (this->size() < this->capacity()) + { + Container::emplace_back(std::forward(args)...); + return; + } + + // 1. May throw, but only changes tmp: copy the keys, value-initialize + // the values, and add the new element. The arguments may refer to + // elements of this container, so they are used before any value is + // moved out of it. + Container tmp(this->get_allocator()); // equal allocators, so swap() is valid + tmp.reserve((std::min)(this->max_size(), (std::max)(size_type{1}, 2 * this->size()))); + for (const auto& element : *this) + { + tmp.emplace_back(std::piecewise_construct, std::forward_as_tuple(element.first), std::forward_as_tuple()); + } + tmp.emplace_back(std::forward(args)...); + + // 2. Cannot throw: move the values over and adopt the new storage. + auto it = tmp.begin(); + for (auto& element : *this) + { + it->second = std::move(element.second); + ++it; + } + Container::swap(tmp); + } + + template + void append_impl(std::false_type /*unused*/, Args&& ... args) + { + Container::emplace_back(std::forward(args)...); + } + JSON_NO_UNIQUE_ADDRESS key_compare m_compare = key_compare(); }; diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 576498738..29c38238a 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -25768,13 +25768,15 @@ NLOHMANN_JSON_NAMESPACE_END +#include // max, min #include // equal_to, less #include // initializer_list #include // input_iterator_tag, iterator_traits #include // allocator #include // for out_of_range -#include // enable_if, is_convertible -#include // pair +#include // forward_as_tuple +#include // enable_if, integral_constant, is_convertible, is_nothrow_move_constructible +#include // forward, move, pair, piecewise_construct #include // vector // #include @@ -25841,7 +25843,7 @@ template , return {it, false}; } } - Container::emplace_back(key, std::forward(t)); + append(key, std::forward(t)); return {std::prev(this->end()), true}; } @@ -25856,7 +25858,7 @@ template , return {it, false}; } } - Container::emplace_back(std::forward(key), std::forward(t)); + append(std::forward(key), std::forward(t)); return {std::prev(this->end()), true}; } @@ -26130,7 +26132,7 @@ template , return {it, false}; } } - Container::push_back(value); + append(value); return {--this->end(), true}; } @@ -26148,6 +26150,64 @@ template , } private: + /*! + @brief add an element whose key is not yet contained at the end + + A std::vector copies all elements when it grows, because their const keys + make them not nothrow move constructible. For ordered_json, this is a deep + copy of every value. Where the strong exception guarantee can be kept, grow + the storage here instead, copying only the keys and moving the values. + */ + template + void append(Args&& ... args) + { + // evaluated here rather than at class scope, because T is still + // incomplete when basic_json instantiates its object_t + using move_values = std::integral_constant>, + std::is_copy_constructible, + detail::is_default_constructible, + std::is_nothrow_move_assignable>::value>; + append_impl(move_values{}, std::forward(args)...); + } + + template + void append_impl(std::true_type /*unused*/, Args&& ... args) + { + if (this->size() < this->capacity()) + { + Container::emplace_back(std::forward(args)...); + return; + } + + // 1. May throw, but only changes tmp: copy the keys, value-initialize + // the values, and add the new element. The arguments may refer to + // elements of this container, so they are used before any value is + // moved out of it. + Container tmp(this->get_allocator()); // equal allocators, so swap() is valid + tmp.reserve((std::min)(this->max_size(), (std::max)(size_type{1}, 2 * this->size()))); + for (const auto& element : *this) + { + tmp.emplace_back(std::piecewise_construct, std::forward_as_tuple(element.first), std::forward_as_tuple()); + } + tmp.emplace_back(std::forward(args)...); + + // 2. Cannot throw: move the values over and adopt the new storage. + auto it = tmp.begin(); + for (auto& element : *this) + { + it->second = std::move(element.second); + ++it; + } + Container::swap(tmp); + } + + template + void append_impl(std::false_type /*unused*/, Args&& ... args) + { + Container::emplace_back(std::forward(args)...); + } + JSON_NO_UNIQUE_ADDRESS key_compare m_compare = key_compare(); }; diff --git a/tests/benchmarks/src/benchmarks.cpp b/tests/benchmarks/src/benchmarks.cpp index 2ad28a57a..5df7dd473 100644 --- a/tests/benchmarks/src/benchmarks.cpp +++ b/tests/benchmarks/src/benchmarks.cpp @@ -119,6 +119,39 @@ BENCHMARK_CAPTURE(ParseIndented, canada / 4, TEST_DATA_DIRECTORY "/nativej BENCHMARK_CAPTURE(ParseIndented, citm_catalog / 4, TEST_DATA_DIRECTORY "/nativejson-benchmark/citm_catalog.json", 4); BENCHMARK_CAPTURE(ParseIndented, twitter / 4, TEST_DATA_DIRECTORY "/nativejson-benchmark/twitter.json", 4); +////////////////////////////////////////////////////////////////////////////// +// parse JSON from string into an ordered_json +// +// Same as ParseString above, but with nlohmann::ordered_json, whose objects +// keep their members in a vector: the pair of rows shows what preserving the +// insertion order costs. +////////////////////////////////////////////////////////////////////////////// + +static void ParseStringOrdered(benchmark::State& state, const char* filename) +{ + std::ifstream f(filename); + std::string str((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + + while (state.KeepRunning()) + { + state.PauseTiming(); + auto* j = new nlohmann::ordered_json(); + state.ResumeTiming(); + + *j = nlohmann::ordered_json::parse(str); + + state.PauseTiming(); + delete j; + state.ResumeTiming(); + } + + state.SetBytesProcessed(state.iterations() * str.size()); +} +BENCHMARK_CAPTURE(ParseStringOrdered, jeopardy, TEST_DATA_DIRECTORY "/jeopardy/jeopardy.json"); +BENCHMARK_CAPTURE(ParseStringOrdered, canada, TEST_DATA_DIRECTORY "/nativejson-benchmark/canada.json"); +BENCHMARK_CAPTURE(ParseStringOrdered, citm_catalog, TEST_DATA_DIRECTORY "/nativejson-benchmark/citm_catalog.json"); +BENCHMARK_CAPTURE(ParseStringOrdered, twitter, TEST_DATA_DIRECTORY "/nativejson-benchmark/twitter.json"); + ////////////////////////////////////////////////////////////////////////////// // serialize JSON ////////////////////////////////////////////////////////////////////////////// diff --git a/tests/src/unit-disabled_exceptions.cpp b/tests/src/unit-disabled_exceptions.cpp index e4532e234..0b8de64d3 100644 --- a/tests/src/unit-disabled_exceptions.cpp +++ b/tests/src/unit-disabled_exceptions.cpp @@ -46,6 +46,24 @@ TEST_CASE("Tests with disabled exceptions") CHECK(*sax_no_exception::error_string == "[json.exception.parse_error.101] parse error at line 1, column 1: syntax error while parsing value - invalid literal; last read: 'x'"); delete sax_no_exception::error_string; // NOLINT(cppcoreguidelines-owning-memory) } + + SECTION("growing an ordered_json object") + { + auto j = nlohmann::ordered_json::object(); + for (int i = 0; i < 100; ++i) + { + j[std::to_string(i)] = {{"nested", i}}; + } + + CHECK(j.size() == 100); + int i = 0; + for (const auto& element : j.items()) + { + CHECK(element.key() == std::to_string(i)); + CHECK(element.value()["nested"] == i); + ++i; + } + } } DOCTEST_GCC_SUPPRESS_WARNING_POP diff --git a/tests/src/unit-ordered_map.cpp b/tests/src/unit-ordered_map.cpp index f380a9869..538826b5c 100644 --- a/tests/src/unit-ordered_map.cpp +++ b/tests/src/unit-ordered_map.cpp @@ -11,6 +11,87 @@ #include using nlohmann::ordered_map; +#include +#include +#include +#include +#include + +namespace +{ +// number of copies made of counted values +int value_copies = 0; + +// a mapped type that counts its copies; moving from it leaves -1 behind +struct counted // NOLINT(cppcoreguidelines-special-member-functions,hicpp-special-member-functions) +{ + int payload = 0; + + counted() = default; + explicit counted(int p) noexcept : payload(p) {} + counted(const counted& other) : payload(other.payload) + { + ++value_copies; + } + counted(counted&& other) noexcept : payload(other.payload) + { + other.payload = -1; + } + counted& operator=(const counted&) = delete; + counted& operator=(counted&& other) noexcept + { + payload = other.payload; + other.payload = -1; + return *this; + } +}; + +#if !defined(JSON_NOEXCEPTION) +// number of throwing_key copies that still succeed; the next one throws +// (a negative value means that copies never throw) +int key_copies_until_throw = -1; + +// a key type whose copy constructor can be made to throw +struct throwing_key // NOLINT(cppcoreguidelines-special-member-functions,hicpp-special-member-functions) +{ + int id = 0; + + explicit throwing_key(int i) noexcept : id(i) {} + throwing_key(const throwing_key& other) : id(other.id) + { + if (key_copies_until_throw == 0) + { + throw std::runtime_error("key copy failed"); + } + if (key_copies_until_throw > 0) + { + --key_copies_until_throw; + } + } + throwing_key& operator=(const throwing_key&) = delete; + + friend bool operator==(const throwing_key& lhs, const throwing_key& rhs) noexcept + { + return lhs.id == rhs.id; + } +}; +#endif + +// a mapped type that cannot be default-constructed +struct no_default +{ + explicit no_default(int v) noexcept : value(v) {} + int value; +}; + +// ordered_json must keep moving its values when an object grows +using ordered_object_t = nlohmann::ordered_json::object_t; +static_assert(!std::is_nothrow_move_constructible::value, "std::vector would move the elements itself"); +static_assert(std::is_copy_constructible::value, "keys must be copyable"); +static_assert(std::is_default_constructible::value, "values must be default-constructible"); +static_assert(std::is_nothrow_move_assignable::value, "values must be nothrow move-assignable"); +} // namespace + TEST_CASE("ordered_map") { SECTION("constructor") @@ -313,3 +394,270 @@ TEST_CASE("ordered_map") } } } + +TEST_CASE("ordered_map growth") +{ + SECTION("values are moved, not copied, when the storage grows") + { + ordered_map om; + std::size_t growths = 0; + value_copies = 0; + + // inserts 100 elements with the given function and counts the growths + const auto fill = [&om, &growths](void (*insert)(ordered_map&, int)) + { + for (int i = 0; i < 100; ++i) + { + const auto old_capacity = om.capacity(); + insert(om, i); + if (om.capacity() > old_capacity) + { + ++growths; + } + } + }; + + // checks that the elements are in insertion order with their values + const auto check_contents = [&om] + { + CHECK(om.size() == 100); + int i = 0; + for (const auto& element : om) + { + CHECK(element.first == std::to_string(i)); + CHECK(element.second.payload == i); + ++i; + } + }; + + SECTION("emplace") + { + fill([](ordered_map& m, int i) + { + m.emplace(std::to_string(i), counted(i)); + }); + CHECK(growths >= 3); + CHECK(value_copies == 0); + check_contents(); + } + + SECTION("operator[]") + { + fill([](ordered_map& m, int i) + { + m[std::to_string(i)] = counted(i); + }); + CHECK(growths >= 3); + CHECK(value_copies == 0); + check_contents(); + } + + SECTION("insert(value_type&&)") + { + fill([](ordered_map& m, int i) + { + m.insert({std::to_string(i), counted(i)}); + }); + CHECK(growths >= 3); + CHECK(value_copies == 0); + check_contents(); + } + + SECTION("insert(const value_type&)") + { + fill([](ordered_map& m, int i) + { + const std::pair value(std::to_string(i), counted(i)); + m.insert(value); + }); + CHECK(growths >= 3); + // only the inserted values are copied + CHECK(value_copies == 100); + check_contents(); + } + + SECTION("insert(first, last)") + { + std::vector> values; + values.reserve(100); + for (int i = 0; i < 100; ++i) + { + values.emplace_back(std::to_string(i), counted(i)); + } + value_copies = 0; + + om.insert(values.cbegin(), values.cend()); + // only the inserted values are copied + CHECK(value_copies == 100); + check_contents(); + } + } + + SECTION("elements keep their order and values over many growths") + { + ordered_map om; + for (int i = 0; i < 1000; ++i) + { + om.emplace(std::to_string(i), counted(i)); + } + + CHECK(om.size() == 1000); + int i = 0; + for (const auto& element : om) + { + CHECK(element.first == std::to_string(i)); + CHECK(element.second.payload == i); + ++i; + } + } + + SECTION("arguments may refer to elements of the full container") + { + SECTION("moving a value out of the container") + { + ordered_map om; + om.reserve(4); + while (om.size() < om.capacity()) + { + const auto i = static_cast(om.size()); + om.emplace(std::to_string(i), counted(i)); + } + const auto size = om.size(); + + om.emplace("new", std::move(om.at("0"))); + CHECK(om.size() == size + 1); + CHECK(om.at("new").payload == 0); + CHECK(om.at("0").payload == -1); + } + + SECTION("using a value as key") + { + ordered_map om; + om.reserve(4); + while (om.size() < om.capacity()) + { + const auto i = std::to_string(om.size()); + om.emplace("k" + i, "v" + i); + } + const auto size = om.size(); + + om.emplace(om.at("k0"), std::string("x")); + CHECK(om.size() == size + 1); + CHECK(om.at("k0") == "v0"); + CHECK(om.at("v0") == "x"); + } + + SECTION("ordered_json") + { + auto j = nlohmann::ordered_json::object(); + auto& object = j.get_ref(); + object.reserve(4); + while (object.size() < object.capacity()) + { + const auto i = std::to_string(object.size()); + j[i] = "a value that is too long for the small string optimization " + i; + } + const auto size = j.size(); + + j.emplace("new", std::move(j["0"])); + CHECK(j.size() == size + 1); + CHECK(j["new"] == "a value that is too long for the small string optimization 0"); + CHECK(j["0"].is_null()); + } + } + +#if !defined(JSON_NOEXCEPTION) + SECTION("the container is unchanged if growing it throws") + { + ordered_map om; + om.reserve(4); + while (om.size() < om.capacity()) + { + const auto i = static_cast(om.size()); + om.emplace(throwing_key(i), counted(i)); + } + const auto size = om.size(); + const auto capacity = om.capacity(); + + // checks that the elements are unchanged + const auto check_unchanged = [&om, size, capacity] + { + CHECK(om.size() == size); + CHECK(om.capacity() == capacity); + int i = 0; + for (const auto& element : om) + { + CHECK(element.first.id == i); + CHECK(element.second.payload == i); + ++i; + } + }; + + SECTION("emplace") + { + // growing copies the existing keys and then the new one; let each of these copies throw + for (std::size_t k = 0; k <= size; ++k) + { + counted value(100); + key_copies_until_throw = static_cast(k); + CHECK_THROWS_AS(om.emplace(throwing_key(100), std::move(value)), std::runtime_error); + key_copies_until_throw = -1; + + check_unchanged(); + CHECK(value.payload == 100); // NOLINT(bugprone-use-after-move,hicpp-invalid-access-moved) + } + + om.emplace(throwing_key(100), counted(100)); + CHECK(om.size() == size + 1); + CHECK(om.capacity() > capacity); + CHECK(om.at(throwing_key(100)).payload == 100); + } + + SECTION("insert(const value_type&)") + { + const std::pair value(throwing_key(100), counted(100)); + value_copies = 0; + + key_copies_until_throw = static_cast(size / 2); + CHECK_THROWS_AS(om.insert(value), std::runtime_error); + key_copies_until_throw = -1; + + check_unchanged(); + CHECK(value_copies == 0); + } + } +#endif + + SECTION("elements that std::vector moves, or that cannot be moved back") + { + SECTION("nothrow move-constructible elements") + { + ordered_map om; + value_copies = 0; + for (int i = 0; i < 100; ++i) + { + om.emplace(i, counted(i)); + } + CHECK(om.size() == 100); + CHECK(value_copies == 0); + } + + SECTION("mapped type without default constructor") + { + ordered_map om; + for (int i = 0; i < 100; ++i) + { + om.emplace(std::to_string(i), no_default(i)); + } + + CHECK(om.size() == 100); + int i = 0; + for (const auto& element : om) + { + CHECK(element.first == std::to_string(i)); + CHECK(element.second.value == i); + ++i; + } + } + } +}