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; + } + } + } +}