diff --git a/docs/mkdocs/docs/api/basic_json/std_hash.md b/docs/mkdocs/docs/api/basic_json/std_hash.md index aaa49ed68..7be7772cc 100644 --- a/docs/mkdocs/docs/api/basic_json/std_hash.md +++ b/docs/mkdocs/docs/api/basic_json/std_hash.md @@ -7,8 +7,14 @@ namespace std { ``` Return a hash value for a JSON object. The hash function tries to rely on `std::hash` where possible. Furthermore, the -type of the JSON value is taken into account to have different hash values for `#!json null`, `#!cpp 0`, `#!cpp 0U`, and -`#!cpp false`, etc. +type of the JSON value is taken into account, so `#!json null`, `#!cpp false`, and numbers may hash differently from +each other. Numbers that compare equal under [`operator==`](operator_eq.md) always hash equally, regardless of +whether they are stored as signed integer, unsigned integer, or floating-point number. + +Numbers are hashed by their value converted to `number_float_t`. Converting an integer to `number_float_t` therefore +keeps its hash, but converting a floating-point number to an integer type is lossy and can change it: `#!cpp 0.5` +converts to `#!cpp 0`, which need not have the same hash. Unequal numbers can also share a hash value, for example two +large integers that convert to the same `number_float_t`. ## Examples @@ -26,7 +32,8 @@ type of the JSON value is taken into account to have different hash values for ` --8<-- "examples/std_hash.output" ``` - Note the output is platform-dependent. + The hash values shown are examples only. They depend on the platform, the compiler, and the compiler version, and + they can change between versions of this library. Do not persist them or rely on specific values. ## See also @@ -36,3 +43,5 @@ type of the JSON value is taken into account to have different hash values for ` - Added in version 1.0.0. - Extended for arbitrary basic_json types in version 3.10.5. +- Numbers that compare equal hash equally since version 3.13.0; before, `#!cpp 0`, `#!cpp 0U`, and `#!cpp 0.0` had + different hash values. diff --git a/docs/mkdocs/docs/examples/std_hash.cpp b/docs/mkdocs/docs/examples/std_hash.cpp index 9721910eb..184ddbbb3 100644 --- a/docs/mkdocs/docs/examples/std_hash.cpp +++ b/docs/mkdocs/docs/examples/std_hash.cpp @@ -11,6 +11,7 @@ int main() << "hash(false) = " << std::hash {}(json(false)) << '\n' << "hash(0) = " << std::hash {}(json(0)) << '\n' << "hash(0U) = " << std::hash {}(json(0U)) << '\n' + << "hash(0.0) = " << std::hash {}(json(0.0)) << '\n' << "hash(\"\") = " << std::hash {}(json("")) << '\n' << "hash({}) = " << std::hash {}(json::object()) << '\n' << "hash([]) = " << std::hash {}(json::array()) << '\n' diff --git a/docs/mkdocs/docs/examples/std_hash.output b/docs/mkdocs/docs/examples/std_hash.output index 521d2b4b8..ca3207c0a 100644 --- a/docs/mkdocs/docs/examples/std_hash.output +++ b/docs/mkdocs/docs/examples/std_hash.output @@ -1,8 +1,9 @@ hash(null) = 2654435769 hash(false) = 2654436030 -hash(0) = 2654436095 -hash(0U) = 2654436156 -hash("") = 6142509191626859748 +hash(0) = 2654436221 +hash(0U) = 2654436221 +hash(0.0) = 2654436221 +hash("") = 11160318156688833227 hash({}) = 2654435832 hash([]) = 2654435899 -hash({"hello": "world"}) = 4469488738203676328 +hash({"hello": "world"}) = 3701319991624763853 diff --git a/include/nlohmann/detail/hash.hpp b/include/nlohmann/detail/hash.hpp index 20a971886..fd1326b5d 100644 --- a/include/nlohmann/detail/hash.hpp +++ b/include/nlohmann/detail/hash.hpp @@ -35,8 +35,10 @@ std::size_t hash_iteratively(const BasicJsonType& j); @brief hash a JSON value The hash function tries to rely on std::hash where possible. Furthermore, the -type of the JSON value is taken into account to have different hash values for -null, 0, 0U, and false, etc. +type of the JSON value is taken into account, so null, false, and numbers may +hash differently from each other, but any two numbers that compare equal +under operator== hash equally regardless of which of number_integer, +number_unsigned, or number_float actually holds the value. Hashing an array or an object hashes its elements, which used to call this function again once per nesting level, so a value nested deeply enough @@ -55,8 +57,6 @@ template std::size_t hash(const BasicJsonType& j, const std::size_t depth = 0) { using string_t = typename BasicJsonType::string_t; - using number_integer_t = typename BasicJsonType::number_integer_t; - using number_unsigned_t = typename BasicJsonType::number_unsigned_t; using number_float_t = typename BasicJsonType::number_float_t; const auto type = static_cast(j.type()); @@ -113,21 +113,24 @@ std::size_t hash(const BasicJsonType& j, const std::size_t depth = 0) } case BasicJsonType::value_t::number_integer: - { - const auto h = std::hash {}(j.template get()); - return combine(type, h); - } - case BasicJsonType::value_t::number_unsigned: - { - const auto h = std::hash {}(j.template get()); - return combine(type, h); - } - case BasicJsonType::value_t::number_float: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + // operator== compares numbers by their mathematical value across + // number_integer, number_unsigned, and number_float, so equal + // numbers of different internal types (0, 0U, 0.0) must hash the + // same. Two equal numbers have the same value, which converts to + // the same number_float_t, so all numbers share one type tag and + // hash that converted value. Adding zero turns -0.0 (equal to 0) + // into 0.0, as std::hash need not map both to the same hash. + // The converse does not hold: converting a number_float_t value + // to an integer type is lossy, so the result can hash + // differently, and unequal numbers that convert to the same + // number_float_t (e.g., 2^53 and 2^53 + 1) share a hash. + const auto number_type = static_cast(BasicJsonType::value_t::number_float); + const auto value = j.template get() + static_cast(0); + const auto h = std::hash {}(value); + return combine(number_type, h); } case BasicJsonType::value_t::binary: diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 0119b01cc..4f1ded530 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -7583,8 +7583,10 @@ std::size_t hash_iteratively(const BasicJsonType& j); @brief hash a JSON value The hash function tries to rely on std::hash where possible. Furthermore, the -type of the JSON value is taken into account to have different hash values for -null, 0, 0U, and false, etc. +type of the JSON value is taken into account, so null, false, and numbers may +hash differently from each other, but any two numbers that compare equal +under operator== hash equally regardless of which of number_integer, +number_unsigned, or number_float actually holds the value. Hashing an array or an object hashes its elements, which used to call this function again once per nesting level, so a value nested deeply enough @@ -7603,8 +7605,6 @@ template std::size_t hash(const BasicJsonType& j, const std::size_t depth = 0) { using string_t = typename BasicJsonType::string_t; - using number_integer_t = typename BasicJsonType::number_integer_t; - using number_unsigned_t = typename BasicJsonType::number_unsigned_t; using number_float_t = typename BasicJsonType::number_float_t; const auto type = static_cast(j.type()); @@ -7661,21 +7661,24 @@ std::size_t hash(const BasicJsonType& j, const std::size_t depth = 0) } case BasicJsonType::value_t::number_integer: - { - const auto h = std::hash {}(j.template get()); - return combine(type, h); - } - case BasicJsonType::value_t::number_unsigned: - { - const auto h = std::hash {}(j.template get()); - return combine(type, h); - } - case BasicJsonType::value_t::number_float: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + // operator== compares numbers by their mathematical value across + // number_integer, number_unsigned, and number_float, so equal + // numbers of different internal types (0, 0U, 0.0) must hash the + // same. Two equal numbers have the same value, which converts to + // the same number_float_t, so all numbers share one type tag and + // hash that converted value. Adding zero turns -0.0 (equal to 0) + // into 0.0, as std::hash need not map both to the same hash. + // The converse does not hold: converting a number_float_t value + // to an integer type is lossy, so the result can hash + // differently, and unequal numbers that convert to the same + // number_float_t (e.g., 2^53 and 2^53 + 1) share a hash. + const auto number_type = static_cast(BasicJsonType::value_t::number_float); + const auto value = j.template get() + static_cast(0); + const auto h = std::hash {}(value); + return combine(number_type, h); } case BasicJsonType::value_t::binary: diff --git a/tests/src/unit-hash.cpp b/tests/src/unit-hash.cpp index 382dfcaa2..e70b39b5a 100644 --- a/tests/src/unit-hash.cpp +++ b/tests/src/unit-hash.cpp @@ -12,8 +12,10 @@ using json = nlohmann::json; using ordered_json = nlohmann::ordered_json; +#include #include #include +#include namespace { @@ -91,6 +93,9 @@ TEST_CASE("hash") // Collect hashes for different JSON values and make sure that they are distinct // We cannot compare against fixed values, because the implementation of // std::hash may differ between compilers. + // + // numbers that compare equal under operator== (0 == 0U == 0.0) must hash + // equally, so they are only inserted once below and checked separately. std::set hashes; @@ -107,10 +112,7 @@ TEST_CASE("hash") // number hashes.insert(std::hash {}(json(0))); - hashes.insert(std::hash {}(json(static_cast(0)))); - hashes.insert(std::hash {}(json(-1))); - hashes.insert(std::hash {}(json(0.0))); hashes.insert(std::hash {}(json(42.23))); // array @@ -132,7 +134,36 @@ TEST_CASE("hash") // discarded hashes.insert(std::hash {}(json(json::value_t::discarded))); - CHECK(hashes.size() == 21); + CHECK(hashes.size() == 19); + + // numbers that compare equal under operator== must hash equally, + // regardless of which of number_integer, number_unsigned, or + // number_float actually holds the value + CHECK(json(0) == json(static_cast(0))); + CHECK(json(0) == json(0.0)); + CHECK(std::hash {}(json(0)) == std::hash {}(json(static_cast(0)))); + CHECK(std::hash {}(json(0)) == std::hash {}(json(0.0))); + CHECK(std::hash {}(json(-1)) == std::hash {}(json(-1.0))); + + // a std::unordered_set relies on this same consistency between == and hash + const std::unordered_set numbers {json(0), json(static_cast(0)), json(0.0)}; + CHECK(numbers.size() == 1); + + // -0.0 compares equal to 0 and 0.0 + CHECK(json(-0.0) == json(0)); + CHECK(std::hash {}(json(-0.0)) == std::hash {}(json(0))); + CHECK(std::hash {}(json(-0.0)) == std::hash {}(json(0.0))); + + // the ends of the integer ranges, which equal floats exactly + const auto int_min = (std::numeric_limits::min)(); + const auto int_max = (std::numeric_limits::max)(); + const auto two_63 = json::number_unsigned_t(1) << 63U; + CHECK(json(int_min) == json(-9223372036854775808.0)); + CHECK(std::hash {}(json(int_min)) == std::hash {}(json(-9223372036854775808.0))); + CHECK(json(two_63) == json(9223372036854775808.0)); + CHECK(std::hash {}(json(two_63)) == std::hash {}(json(9223372036854775808.0))); + CHECK(json(json::number_unsigned_t(int_max)) == json(int_max)); + CHECK(std::hash {}(json(json::number_unsigned_t(int_max))) == std::hash {}(json(int_max))); } TEST_CASE("hash") @@ -156,10 +187,7 @@ TEST_CASE("hash") // number hashes.insert(std::hash {}(ordered_json(0))); - hashes.insert(std::hash {}(ordered_json(static_cast(0)))); - hashes.insert(std::hash {}(ordered_json(-1))); - hashes.insert(std::hash {}(ordered_json(0.0))); hashes.insert(std::hash {}(ordered_json(42.23))); // array @@ -181,7 +209,10 @@ TEST_CASE("hash") // discarded hashes.insert(std::hash {}(ordered_json(ordered_json::value_t::discarded))); - CHECK(hashes.size() == 21); + CHECK(hashes.size() == 19); + + CHECK(std::hash {}(ordered_json(0)) == std::hash {}(ordered_json(static_cast(0)))); + CHECK(std::hash {}(ordered_json(0)) == std::hash {}(ordered_json(0.0))); } TEST_CASE("hash of deeply nested values")