From 6178982b8d37f1ec3c0954baf26a69c826a79425 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Sun, 27 Sep 2026 14:21:58 +0200 Subject: [PATCH] Compare unordered objects by key below the nesting bound (#5582) * Compare unordered objects by key below the nesting bound Values nested deeper than the nesting bound are compared without the call stack, walking both objects entry by entry. Two equal objects of a type that enumerates its entries in no fixed order - std::unordered_map, say - can be walked in different orders, so they compared unequal, and a deep copy compared unequal to its original. std::unordered_map's own operator== does not depend on the order, which is what applies above the bound. Where the keys differ, equality now finds the entry by its key instead. An ordering, and ordered_map, whose operator== compares its entries in sequence, still decide by the key. Signed-off-by: Niels Lohmann * Test unordered object equality without std::unordered_map basic_json instantiates std::pair while basic_json is still incomplete. The standard does not require std::unordered_map to support that, and libstdc++ 6 to 9 as well as the EDG front ends of icpc and nvc++ reject it, which broke the build of unit-comparison on those CI jobs. The test now uses an object type derived from std::map (which, as the default object type, works everywhere) whose comparator orders keys ascending or descending as chosen at construction, and whose operator== does not depend on the order of the entries - the property of std::unordered_map the test is about. Signed-off-by: Niels Lohmann * Compare the test object type's entries with std::all_of clang-tidy (readability-use-anyofallof) asked for std::all_of instead of the loop in unordered_object_t's operator==. The entry type is spelled out, as C++11 needs typename for base_type::value_type and C++20 reports it as redundant. Signed-off-by: Niels Lohmann --------- Signed-off-by: Niels Lohmann --- include/nlohmann/json.hpp | 25 ++++-- single_include/nlohmann/json.hpp | 25 ++++-- tests/src/unit-comparison.cpp | 127 +++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index d18baf599..abd461d6c 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -1492,13 +1492,28 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec compare_keys(current.lhs_object_it->first, current.rhs_object_it->first, std::integral_constant {}); - if (key_result != compare_result::equal) - { - return key_result; - } - left = &(current.lhs_object_it->second); right = &(current.rhs_object_it->second); + + if (key_result != compare_result::equal) + { + // An object type without a fixed order of its entries - + // std::unordered_map, say - may enumerate two equal + // objects differently, and its operator== does not care. + // Equality then finds the entry by its key; an ordering, + // or an object type that compares its entries in + // sequence (ordered_map), is decided by the key itself. + const auto* rhs_object = current.rhs_value->m_data.m_value.object; + const auto found = (!Ordered && !detail::is_ordered_map::value) + ? rhs_object->find(current.lhs_object_it->first) + : rhs_object->cend(); + if (found == rhs_object->cend()) + { + return key_result; + } + right = &(found->second); + } + ++current.lhs_object_it; ++current.rhs_object_it; } diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index b5938be0e..ba41273de 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -26361,13 +26361,28 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec compare_keys(current.lhs_object_it->first, current.rhs_object_it->first, std::integral_constant {}); - if (key_result != compare_result::equal) - { - return key_result; - } - left = &(current.lhs_object_it->second); right = &(current.rhs_object_it->second); + + if (key_result != compare_result::equal) + { + // An object type without a fixed order of its entries - + // std::unordered_map, say - may enumerate two equal + // objects differently, and its operator== does not care. + // Equality then finds the entry by its key; an ordering, + // or an object type that compares its entries in + // sequence (ordered_map), is decided by the key itself. + const auto* rhs_object = current.rhs_value->m_data.m_value.object; + const auto found = (!Ordered && !detail::is_ordered_map::value) + ? rhs_object->find(current.lhs_object_it->first) + : rhs_object->cend(); + if (found == rhs_object->cend()) + { + return key_result; + } + right = &(found->second); + } + ++current.lhs_object_it; ++current.rhs_object_it; } diff --git a/tests/src/unit-comparison.cpp b/tests/src/unit-comparison.cpp index 9a6606256..69c0103c9 100644 --- a/tests/src/unit-comparison.cpp +++ b/tests/src/unit-comparison.cpp @@ -15,7 +15,13 @@ #include "doctest_compatibility.h" +#include + #include +#include +#include +#include +#include #define JSON_TESTS_PRIVATE #include @@ -745,6 +751,127 @@ TEST_CASE("regression #3868 - heterogeneous comparisons compile under C++20 (P24 } #endif +namespace +{ +// orders keys ascending or descending, as chosen when a map is created +template +class directed_less +{ + public: + directed_less() = default; + + explicit directed_less(const bool descending) noexcept + : m_descending(descending) + {} + + bool operator()(const Key& lhs, const Key& rhs) const + { + return m_descending ? rhs < lhs : lhs < rhs; + } + + private: + bool m_descending = false; +}; + +// An object type that, like std::unordered_map, enumerates its entries in no +// fixed order - ascending or descending by key, depending on how the map was +// created - and whose operator== does not depend on that order. +// std::unordered_map itself cannot be used here: the standard does not +// require it to accept an incomplete mapped type such as basic_json, and +// libstdc++ 6 to 9 as well as the EDG front ends of icpc and nvc++ reject +// basic_json. std::map, the default object type, works +// with all supported compilers. +template +struct unordered_object_t : std::map, Allocator> +{ + using base_type = std::map, Allocator>; + using base_type::base_type; + + friend bool operator==(const unordered_object_t& lhs, const unordered_object_t& rhs) + { + return lhs.size() == rhs.size() && std::all_of(lhs.begin(), lhs.end(), [&rhs](const std::pair& entry) + { + const auto it = rhs.find(entry.first); + return it != rhs.end() && it->second == entry.second; + }); + } + + friend bool operator!=(const unordered_object_t& lhs, const unordered_object_t& rhs) + { + return !(lhs == rhs); + } +}; +using unordered_json = nlohmann::basic_json; + +// the entries "0" to "9", enumerated in ascending or in descending order +unordered_json make_unordered_object(const bool descending) +{ + unordered_json j = unordered_json::object_t(directed_less(descending)); + for (int i = 0; i < 10; ++i) + { + j[std::to_string(i)] = i; + } + return j; +} + +template +Json nest(Json j, const std::size_t depth) +{ + for (std::size_t i = 0; i < depth; ++i) + { + Json outer = Json::object(); + outer["x"] = std::move(j); + j = std::move(outer); + } + return j; +} +} // namespace + +TEST_CASE("equality of objects whose entries have no fixed order") +{ + // Values nested deeper than a bound are compared without the call stack, + // entry by entry. That must agree with the object type's own operator==, + // which for unordered_object_t (as for std::unordered_map) does not + // depend on the order of the entries, and for ordered_map does. + REQUIRE(make_unordered_object(true).begin().key() == "9"); + REQUIRE(make_unordered_object(false).begin().key() == "0"); + + for (const std::size_t depth : std::vector {0, 200}) + { + CAPTURE(depth); + + const unordered_json descending = nest(make_unordered_object(true), depth); + const unordered_json ascending = nest(make_unordered_object(false), depth); + CHECK(descending == ascending); + CHECK_FALSE(descending != ascending); + + // a copy is equal to its original + const unordered_json copy = descending; // NOLINT(performance-unnecessary-copy-initialization) + CHECK(copy == descending); + + // a different value, a different key, or another entry still count + unordered_json other_value = make_unordered_object(true); + other_value["5"] = 42; + CHECK_FALSE(nest(other_value, depth) == ascending); + + unordered_json other_key = make_unordered_object(true); + other_key.erase("5"); + other_key["50"] = 5; + CHECK_FALSE(nest(other_key, depth) == ascending); + + unordered_json more_entries = make_unordered_object(true); + more_entries["10"] = 10; + CHECK_FALSE(nest(more_entries, depth) == ascending); + CHECK_FALSE(ascending == nest(more_entries, depth)); + + // ordered_json compares its entries in sequence + const nlohmann::ordered_json ab = nest(nlohmann::ordered_json({{"a", 1}, {"b", 2}}), depth); + const nlohmann::ordered_json ba = nest(nlohmann::ordered_json({{"b", 2}, {"a", 1}}), depth); + CHECK_FALSE(ab == ba); + CHECK(ab != ba); + } +} + TEST_CASE("containers are compared element by element") { // Containers nested deeper than a bound are compared without the call