Fix std::hash contract violation for numeric types (#5256) - #5262
Fix std::hash contract violation for numeric types (#5256)#5262nlohmann wants to merge 4 commits into
Conversation
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>
This comment was marked as outdated.
This comment was marked as outdated.
| 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) |
There was a problem hiding this comment.
Good catch, and thank you for flagging this. You're right — this normalization currently mirrors today's operator== U-vs-I wraparound cast specifically to stay contract-consistent with it, so it would need to be revisited once #5211 lands (which replaces the wraparound cast with a mathematically correct comparison).
I'll wait for #5211 to merge first, then rebase this hash normalization on top of it. The forward-compatible fix will check value_in_range_of<number_integer_t>(v) before normalizing, falling back to hashing in the pure number_unsigned_t domain when a value doesn't fit — avoiding any wraparound-based collision regardless of which comparison semantics are in effect.
— posted by Claude Code on behalf of @nlohmann
There was a problem hiding this comment.
(Sorry for the auto-generated message. Indeed let's wait for #5211 to land first. Can you check the open question to you there please?)
| hash(0) = 2654436221 | ||
| hash(0U) = 2654436221 | ||
| hash(0.0) = 2654436221 | ||
| hash("") = 11160318156688833227 |
There was a problem hiding this comment.
Why did the string hashes change?
There was a problem hiding this comment.
This isn't caused by the code change — verified by comparing against the original file: hash(null), hash(false), hash({}), and hash([]) (none of which touch the modified code paths) are byte-identical to the previously committed values. Re-running the exact same compiled binary repeatedly also gives identical hash("") results, which rules out per-process hash randomization.
The hash("") / hash({"hello": "world"}) values differ only because I regenerated this file locally (Apple Clang/libc++/ARM64), which is a different toolchain than whatever originally produced the checked-in values. std::hash<std::string> legitimately differs across standard library implementations — this is exactly the platform-dependence the docs already call out ("Note the output is platform-dependent").
Happy to regenerate on a Linux/GCC toolchain instead if you'd prefer the diff minimized to just the numeric lines that actually changed — let me know.
— posted by Claude Code on behalf of @nlohmann
There was a problem hiding this comment.
(Sorry for the auto-generated message - I need to learn how to restrict Claude here...)
- 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>
5b9e400 to
48face7
Compare
This comment was marked as outdated.
This comment was marked as outdated.
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>
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>
|
This pull request has been marked as stale because it has had no activity for 30 days. While we won’t close it automatically, we encourage you to update or comment if it is still relevant. Keeping pull requests active and up-to-date helps us review and merge changes more efficiently. Thank you for your contributions! |
|
CI is green here, but that is misleading: the new tests only exercise 1. UB — negating
|
| pair | == |
hashes equal |
|---|---|---|
json(42) vs json(42u) / json(42.0) |
yes | yes |
json(2^52) vs json(2^52 as double) |
yes | no |
json(2^52+1) vs json((double)(2^52+1)) |
yes | no |
json(2^53) vs json(2^53 as double) |
yes | no |
json(-2^52) vs json(-2^52 as double) |
yes | no |
json(UINT64_MAX) vs json((double)UINT64_MAX) |
yes | no |
json(2^63 unsigned) vs json((double)2^63) |
yes | no |
2^52 is about 4.5e15, which is not exotic — nanosecond timestamps land past it. The is_exactly_representable_as_float escape hatch is the violation: whenever it returns false, an integer that operator== considers equal to some float hashes differently.
4. The unsigned-to-signed premise is now stale
The comment "Normalize to signed (matching operator== behavior for U-vs-I comparison)" stopped being true when #5211 landed on develop (two days after this branch's last push): mixed comparison is now value-correct, so json(-1) != json(UINT64_MAX). The hash still maps UINT64_MAX to -1, so those two now collide — legal, but gratuitous, and it folds the whole upper half of the uint64 range onto negative int64 values. This branch needs a rebase regardless.
Suggestion: drop the special-casing entirely
operator== compares integer against float by casting the integer to number_float_t. So hashing every numeric value in the float domain is exactly consistent with equality — no helper, no threshold, no UB, no MSVC pragma, no <limits>/<cmath>:
case BasicJsonType::value_t::number_integer:
case BasicJsonType::value_t::number_unsigned:
case BasicJsonType::value_t::number_float:
{
// hash all numeric values in the number_float domain under a shared
// type tag, mirroring how operator== compares them across types
const auto numeric_type = static_cast<std::size_t>(BasicJsonType::value_t::number_float);
number_float_t v{};
if (j.type() == BasicJsonType::value_t::number_integer)
{
v = static_cast<number_float_t>(j.template get<number_integer_t>());
}
else if (j.type() == BasicJsonType::value_t::number_unsigned)
{
v = static_cast<number_float_t>(j.template get<number_unsigned_t>());
}
else
{
v = j.template get<number_float_t>();
}
const auto h = std::hash<number_float_t> {}(v);
return combine(numeric_type, h);
}I built this and re-ran the probe: every row in the table above passes, both "legal collision" rows stop colliding, UBSan is clean, and the set in unit-hash.cpp still yields exactly 19 distinct hashes — so the test changes in this PR stay valid as written. It is about 55 lines shorter than the current diff.
The trade-off is real but small: distinct integers at or above 2^53 that round to the same double collide. Below 2^53 the mapping is injective, so ordinary integer keys are unaffected — and this collision is forced by the lossy cross-type comparison in operator==. The only way to keep full integer hash precision would be to make integer/float equality exact, which is a separate and larger change to JSON_IMPLEMENT_OPERATOR.
Minor
std_hash.mdsays the edge case starts "beyond ~2^53", but the code uses 2^52. With the change above the edge-case paragraph can be replaced by a short note about large-integer collisions.std_hash.output:hash("")andhash({"hello": "world"})changed although no string or object hashing code changed — the file was regenerated on libc++ rather than libstdc++. I reproduced the committed values byte-for-byte on macOS, which confirms it.std_hash.testis excluded fromcheck_output_portable, so CI will not catch it, butmake check_outputlocally will. Thehash(0)/hash(0U)/hash(0.0)values are the same under both standard libraries (both map0.0to hash0), so only those three lines need to change; the previous string values should be kept.- Test coverage: nothing in the new tests goes above 42. Worth adding
INT64_MIN,UINT64_MAX,1ULL << 63, and the 2^52/2^53 boundaries — those are what turned all of this up. - Release note: every numeric hash value changes. Not an API or ABI break, but anyone persisting hashes across versions is affected.
— reviewed and written by Claude Code on behalf of @nlohmann
Summary
Fixes #5256: The C++
std::hashcontract requires that if two values compare equal, they must hash to the same value. This fix addresses the violation wherejson(42),json(42u), andjson(42.0)all compare equal but hashed differently.Changes
Core Fix:
include/nlohmann/detail/hash.hppis_exactly_representable_as_float<BasicJsonType>()helper to safely check if an integer value round-trips losslessly through the float typestatic_cast<number_integer_t>(), exactly matching theoperator==behaviorTests:
tests/src/unit-hash.cppstd::hashcontract verification tests for bothjsonandordered_jsonDocumentation
docs/mkdocs/docs/api/basic_json/std_hash.md: Updated to describe the new numeric hash unification behavior and edge casesdocs/mkdocs/docs/examples/std_hash.cpp: Addedhash(0.0)to show all three forms collidingdocs/mkdocs/docs/examples/std_hash.output: Regenerated output showing equal hashesBuild
single_include/nlohmann/json.hpp: Regenerated viatools/amalgamateto stay in syncTesting
✅ All 108 existing tests pass
✅ Hash tests specifically verify the std::hash contract
✅ No regressions detected
Notes
operator==