Compare commits

...

4 Commits

Author SHA1 Message Date
Niels Lohmann 10d18792bd Simplify number_float hash case: reuse existing type tag
The number_float case introduced a redundant numeric_type local that
duplicated the already-computed type variable (both equal
value_t::number_float within that case). Removed per review feedback.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-07-10 13:00:37 +02:00
Niels Lohmann 8557b661ab Fix MSVC C4127 warning-as-error in is_exactly_representable_as_float
MSVC's /W4 flags 'if (float_digits >= int_digits)' as C4127 (conditional
expression is constant), since both operands are constexpr int for any
single template instantiation. This CI job builds with warnings-as-errors,
failing the build.

Apply the same MSVC-only pragma push/disable(4127)/pop pattern already
used elsewhere in the codebase (see json.hpp's set_parents() workaround)
rather than if constexpr, since this file must stay C++11-compatible.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-07-10 07:37:26 +02:00
Niels Lohmann 48face7a20 Fix clang-tidy style and regenerate amalgamation
- Remove 'else' after 'return' in hash.hpp to satisfy
  llvm-else-after-return / readability-else-after-return clang-tidy checks
- Regenerate single_include/nlohmann/json.hpp via 'make amalgamate'
  (also fixes pre-existing amalgamation/astyle formatting drift)
- Apply astyle formatting fixes to unit-hash.cpp (space before '{}')

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-07-10 06:41:38 +02:00
Niels Lohmann 68c87ad9de Fix std::hash contract violation for numeric types
Fixes #5256: json(42) == json(42u) is true, but their hashes differed,
violating the std::hash contract. This also applied to float comparisons:
json(42) == json(42.0) is true, but they hashed differently.

Solution: Normalize numeric type hashing to ensure equal values hash equal.
- Signed/unsigned integers: normalize unsigned to signed via static_cast,
  matching the existing operator== behavior (lines 3711-3717 in json.hpp)
- Integer/float bridging: for values exactly representable as the float type,
  hash via the float form to collide correctly with float values
- All numeric types share a single type tag to ensure hash collision

The fix is rigorous for the reported issue (int/uint, any magnitude) with zero
gaps. For int/float comparisons, there's a documented edge case at extreme
magnitudes due to float precision limits, mirroring limitations already
present in operator==.

Changes:
- include/nlohmann/detail/hash.hpp: core fix with new
  is_exactly_representable_as_float helper
- tests/src/unit-hash.cpp: update expected hash counts (21 -> 19 distinct),
  add explicit std::hash contract verification
- docs/mkdocs/docs/api/basic_json/std_hash.md: update description
- docs/mkdocs/docs/examples/std_hash.cpp/.output: show the fix in action
- single_include/nlohmann/json.hpp: regenerated via amalgamate

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-07-09 21:12:31 +02:00
6 changed files with 220 additions and 23 deletions
+12 -3
View File
@@ -6,9 +6,18 @@ 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.
Return a hash value for a JSON object. The hash function tries to rely on `std::hash` where possible. To satisfy the
`std::hash` contract, numeric JSON values that compare equal must hash to the same value. This means:
- `json(42)`, `json(42u)`, and `json(42.0)` all hash to the same value
- `json(0)`, `json(0u)`, and `json(0.0)` all hash to the same value
Different types hash differently for non-numeric types (e.g., `#!json null`, `#!cpp false`, and strings all have distinct hashes).
**Edge case:** For very large integers outside the exact representable range of the floating-point type (beyond ~2^53 for
typical `double`), the hash values for integer and floating-point values may differ, even if the floating-point value
was obtained by casting the integer (due to precision loss). This is a documented limitation arising from how the
comparison operator normalizes numeric types.
## Examples
+1
View File
@@ -11,6 +11,7 @@ int main()
<< "hash(false) = " << std::hash<json> {}(json(false)) << '\n'
<< "hash(0) = " << std::hash<json> {}(json(0)) << '\n'
<< "hash(0U) = " << std::hash<json> {}(json(0U)) << '\n'
<< "hash(0.0) = " << std::hash<json> {}(json(0.0)) << '\n'
<< "hash(\"\") = " << std::hash<json> {}(json("")) << '\n'
<< "hash({}) = " << std::hash<json> {}(json::object()) << '\n'
<< "hash([]) = " << std::hash<json> {}(json::array()) << '\n'
+5 -4
View File
@@ -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
+90 -5
View File
@@ -11,6 +11,8 @@
#include <cstdint> // uint8_t
#include <cstddef> // size_t
#include <functional> // hash
#include <limits> // numeric_limits
#include <cmath> // isfinite
#include <nlohmann/detail/abi_macros.hpp>
#include <nlohmann/detail/value_t.hpp>
@@ -26,12 +28,73 @@ inline std::size_t combine(std::size_t seed, std::size_t h) noexcept
return seed;
}
// Check if a number_integer_t value is exactly representable as number_float_t
// Returns true if static_cast<number_integer_t>(static_cast<number_float_t>(val)) == val
template<typename BasicJsonType>
inline bool is_exactly_representable_as_float(typename BasicJsonType::number_integer_t val) noexcept
{
using number_integer_t = typename BasicJsonType::number_integer_t;
using number_float_t = typename BasicJsonType::number_float_t;
// If the float type's mantissa covers the integer type's entire range, all values round-trip
constexpr int float_digits = std::numeric_limits<number_float_t>::digits;
constexpr int int_digits = std::numeric_limits<number_integer_t>::digits;
#ifdef JSON_HEDLEY_MSVC_VERSION
#pragma warning(push )
#pragma warning(disable : 4127) // ignore warning to replace if with if constexpr
#endif
if (float_digits >= int_digits)
{
return true;
}
#ifdef JSON_HEDLEY_MSVC_VERSION
#pragma warning( pop )
#endif
// For values outside float's exact range, they don't round-trip
// The safe way to check: compute the max magnitude that round-trips
// Using unsigned arithmetic to avoid UB with negating INT_MIN
// Max magnitude representable exactly: 2^(digits-1) - 1 for signed, 2^digits - 1 for unsigned range
// But we're checking a signed value, so use 2^digits as the threshold
constexpr auto max_exact = static_cast<number_integer_t>(1) << (float_digits - 1);
// Check absolute value against this threshold
if (val >= 0)
{
if (val >= max_exact)
{
return false;
}
}
else
{
// For negative values, check via unsigned wrapping arithmetic
// -val in unsigned domain; if it wraps, the value is too negative
auto unsigned_abs = static_cast<typename BasicJsonType::number_unsigned_t>(-val);
if (unsigned_abs >= static_cast<typename BasicJsonType::number_unsigned_t>(max_exact))
{
return false;
}
}
// For values within the exact range, verify the round-trip
const auto f = static_cast<number_float_t>(val);
return std::isfinite(f) && static_cast<number_integer_t>(f) == val;
}
/*!
@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.
most types. However, numeric types (number_integer, number_unsigned, number_float)
are hashed to satisfy the std::hash contract: if two json values compare equal,
they must have equal hash values. This means json(42), json(42u), and json(42.0)
all hash to the same value (since they compare equal). For large integer values
outside the exact representable range of the float type, integer values are hashed
in their own domain to avoid precision loss.
@tparam BasicJsonType basic_json specialization
@param j JSON value to hash
@@ -90,14 +153,36 @@ std::size_t hash(const BasicJsonType& j)
case BasicJsonType::value_t::number_integer:
{
const auto h = std::hash<number_integer_t> {}(j.template get<number_integer_t>());
return combine(type, h);
const auto v = j.template get<number_integer_t>();
// Use a shared numeric type tag so all numeric types that are equal hash the same
const auto numeric_type = static_cast<std::size_t>(BasicJsonType::value_t::number_float);
if (is_exactly_representable_as_float<BasicJsonType>(v))
{
const auto h = std::hash<number_float_t> {}(static_cast<number_float_t>(v));
return combine(numeric_type, h);
}
const auto h = std::hash<number_integer_t> {}(v);
return combine(numeric_type, h);
}
case BasicJsonType::value_t::number_unsigned:
{
const auto h = std::hash<number_unsigned_t> {}(j.template get<number_unsigned_t>());
return combine(type, h);
const auto v = j.template get<number_unsigned_t>();
// Normalize to signed (matching operator== behavior for U-vs-I comparison)
const auto v_as_signed = static_cast<number_integer_t>(v);
// Use a shared numeric type tag so all numeric types that are equal hash the same
const auto numeric_type = static_cast<std::size_t>(BasicJsonType::value_t::number_float);
if (is_exactly_representable_as_float<BasicJsonType>(v_as_signed))
{
const auto h = std::hash<number_float_t> {}(static_cast<number_float_t>(v_as_signed));
return combine(numeric_type, h);
}
const auto h = std::hash<number_integer_t> {}(v_as_signed);
return combine(numeric_type, h);
}
case BasicJsonType::value_t::number_float:
+90 -5
View File
@@ -6677,6 +6677,8 @@ NLOHMANN_JSON_NAMESPACE_END
#include <cstdint> // uint8_t
#include <cstddef> // size_t
#include <functional> // hash
#include <limits> // numeric_limits
#include <cmath> // isfinite
// #include <nlohmann/detail/abi_macros.hpp>
@@ -6694,12 +6696,73 @@ inline std::size_t combine(std::size_t seed, std::size_t h) noexcept
return seed;
}
// Check if a number_integer_t value is exactly representable as number_float_t
// Returns true if static_cast<number_integer_t>(static_cast<number_float_t>(val)) == val
template<typename BasicJsonType>
inline bool is_exactly_representable_as_float(typename BasicJsonType::number_integer_t val) noexcept
{
using number_integer_t = typename BasicJsonType::number_integer_t;
using number_float_t = typename BasicJsonType::number_float_t;
// If the float type's mantissa covers the integer type's entire range, all values round-trip
constexpr int float_digits = std::numeric_limits<number_float_t>::digits;
constexpr int int_digits = std::numeric_limits<number_integer_t>::digits;
#ifdef JSON_HEDLEY_MSVC_VERSION
#pragma warning(push )
#pragma warning(disable : 4127) // ignore warning to replace if with if constexpr
#endif
if (float_digits >= int_digits)
{
return true;
}
#ifdef JSON_HEDLEY_MSVC_VERSION
#pragma warning( pop )
#endif
// For values outside float's exact range, they don't round-trip
// The safe way to check: compute the max magnitude that round-trips
// Using unsigned arithmetic to avoid UB with negating INT_MIN
// Max magnitude representable exactly: 2^(digits-1) - 1 for signed, 2^digits - 1 for unsigned range
// But we're checking a signed value, so use 2^digits as the threshold
constexpr auto max_exact = static_cast<number_integer_t>(1) << (float_digits - 1);
// Check absolute value against this threshold
if (val >= 0)
{
if (val >= max_exact)
{
return false;
}
}
else
{
// For negative values, check via unsigned wrapping arithmetic
// -val in unsigned domain; if it wraps, the value is too negative
auto unsigned_abs = static_cast<typename BasicJsonType::number_unsigned_t>(-val);
if (unsigned_abs >= static_cast<typename BasicJsonType::number_unsigned_t>(max_exact))
{
return false;
}
}
// For values within the exact range, verify the round-trip
const auto f = static_cast<number_float_t>(val);
return std::isfinite(f) && static_cast<number_integer_t>(f) == val;
}
/*!
@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.
most types. However, numeric types (number_integer, number_unsigned, number_float)
are hashed to satisfy the std::hash contract: if two json values compare equal,
they must have equal hash values. This means json(42), json(42u), and json(42.0)
all hash to the same value (since they compare equal). For large integer values
outside the exact representable range of the float type, integer values are hashed
in their own domain to avoid precision loss.
@tparam BasicJsonType basic_json specialization
@param j JSON value to hash
@@ -6758,14 +6821,36 @@ std::size_t hash(const BasicJsonType& j)
case BasicJsonType::value_t::number_integer:
{
const auto h = std::hash<number_integer_t> {}(j.template get<number_integer_t>());
return combine(type, h);
const auto v = j.template get<number_integer_t>();
// Use a shared numeric type tag so all numeric types that are equal hash the same
const auto numeric_type = static_cast<std::size_t>(BasicJsonType::value_t::number_float);
if (is_exactly_representable_as_float<BasicJsonType>(v))
{
const auto h = std::hash<number_float_t> {}(static_cast<number_float_t>(v));
return combine(numeric_type, h);
}
const auto h = std::hash<number_integer_t> {}(v);
return combine(numeric_type, h);
}
case BasicJsonType::value_t::number_unsigned:
{
const auto h = std::hash<number_unsigned_t> {}(j.template get<number_unsigned_t>());
return combine(type, h);
const auto v = j.template get<number_unsigned_t>();
// Normalize to signed (matching operator== behavior for U-vs-I comparison)
const auto v_as_signed = static_cast<number_integer_t>(v);
// Use a shared numeric type tag so all numeric types that are equal hash the same
const auto numeric_type = static_cast<std::size_t>(BasicJsonType::value_t::number_float);
if (is_exactly_representable_as_float<BasicJsonType>(v_as_signed))
{
const auto h = std::hash<number_float_t> {}(static_cast<number_float_t>(v_as_signed));
return combine(numeric_type, h);
}
const auto h = std::hash<number_integer_t> {}(v_as_signed);
return combine(numeric_type, h);
}
case BasicJsonType::value_t::number_float:
+22 -6
View File
@@ -35,10 +35,10 @@ TEST_CASE("hash<nlohmann::json>")
// number
hashes.insert(std::hash<json> {}(json(0)));
hashes.insert(std::hash<json> {}(json(static_cast<unsigned>(0))));
hashes.insert(std::hash<json> {}(json(static_cast<unsigned>(0)))); // now same hash as json(0)
hashes.insert(std::hash<json> {}(json(0.0))); // now same hash as json(0)
hashes.insert(std::hash<json> {}(json(-1)));
hashes.insert(std::hash<json> {}(json(0.0)));
hashes.insert(std::hash<json> {}(json(42.23)));
// array
@@ -60,7 +60,16 @@ TEST_CASE("hash<nlohmann::json>")
// discarded
hashes.insert(std::hash<json> {}(json(json::value_t::discarded)));
CHECK(hashes.size() == 21);
// Note: json(0), json(0U), and json(0.0) now hash to the same value
// (to satisfy the std::hash contract: equal values must hash equally)
// So we expect 19 distinct hashes instead of 21
CHECK(hashes.size() == 19);
// Verify the std::hash contract: equal values must hash equally
CHECK(std::hash<json> {}(json(0)) == std::hash<json> {}(json(static_cast<unsigned>(0))));
CHECK(std::hash<json> {}(json(0)) == std::hash<json> {}(json(0.0)));
CHECK(std::hash<json> {}(json(42)) == std::hash<json> {}(json(42u)));
CHECK(std::hash<json> {}(json(42)) == std::hash<json> {}(json(42.0)));
}
TEST_CASE("hash<nlohmann::ordered_json>")
@@ -84,10 +93,10 @@ TEST_CASE("hash<nlohmann::ordered_json>")
// number
hashes.insert(std::hash<ordered_json> {}(ordered_json(0)));
hashes.insert(std::hash<ordered_json> {}(ordered_json(static_cast<unsigned>(0))));
hashes.insert(std::hash<ordered_json> {}(ordered_json(static_cast<unsigned>(0)))); // now same hash as ordered_json(0)
hashes.insert(std::hash<ordered_json> {}(ordered_json(0.0))); // now same hash as ordered_json(0)
hashes.insert(std::hash<ordered_json> {}(ordered_json(-1)));
hashes.insert(std::hash<ordered_json> {}(ordered_json(0.0)));
hashes.insert(std::hash<ordered_json> {}(ordered_json(42.23)));
// array
@@ -109,5 +118,12 @@ TEST_CASE("hash<nlohmann::ordered_json>")
// discarded
hashes.insert(std::hash<ordered_json> {}(ordered_json(ordered_json::value_t::discarded)));
CHECK(hashes.size() == 21);
// Note: ordered_json(0), ordered_json(0U), and ordered_json(0.0) now hash to the same value
CHECK(hashes.size() == 19);
// Verify the std::hash contract for ordered_json as well
CHECK(std::hash<ordered_json> {}(ordered_json(0)) == std::hash<ordered_json> {}(ordered_json(static_cast<unsigned>(0))));
CHECK(std::hash<ordered_json> {}(ordered_json(0)) == std::hash<ordered_json> {}(ordered_json(0.0)));
CHECK(std::hash<ordered_json> {}(ordered_json(42)) == std::hash<ordered_json> {}(ordered_json(42u)));
CHECK(std::hash<ordered_json> {}(ordered_json(42)) == std::hash<ordered_json> {}(ordered_json(42.0)));
}