Skip to content

Fix std::hash contract violation for numeric types (#5256)#5262

Open
nlohmann wants to merge 4 commits into
developfrom
claude/github-issue-review-b531d3
Open

Fix std::hash contract violation for numeric types (#5256)#5262
nlohmann wants to merge 4 commits into
developfrom
claude/github-issue-review-b531d3

Conversation

@nlohmann

@nlohmann nlohmann commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #5256: The C++ std::hash contract requires that if two values compare equal, they must hash to the same value. This fix addresses the violation where json(42), json(42u), and json(42.0) all compare equal but hashed differently.

Changes

Core Fix: include/nlohmann/detail/hash.hpp

  • Added is_exactly_representable_as_float<BasicJsonType>() helper to safely check if an integer value round-trips losslessly through the float type
  • Modified hash function to:
    • Normalize unsigned integers to signed via static_cast<number_integer_t>(), exactly matching the operator== behavior
    • Bridge values to float domain only when exactly representable (zero precision loss)
    • Use a shared numeric type tag so all numeric types that are equal hash identically

Tests: tests/src/unit-hash.cpp

  • Updated expected distinct hash count from 21 to 19 (json(0), json(0U), json(0.0) now collide)
  • Added explicit std::hash contract verification tests for both json and ordered_json

Documentation

  • docs/mkdocs/docs/api/basic_json/std_hash.md: Updated to describe the new numeric hash unification behavior and edge cases
  • docs/mkdocs/docs/examples/std_hash.cpp: Added hash(0.0) to show all three forms colliding
  • docs/mkdocs/docs/examples/std_hash.output: Regenerated output showing equal hashes

Build

  • single_include/nlohmann/json.hpp: Regenerated via tools/amalgamate to stay in sync

Testing

✅ All 108 existing tests pass
✅ Hash tests specifically verify the std::hash contract
✅ No regressions detected

Notes

  • This is a breaking change in observable behavior (hash values will differ from previous versions), but not in API/source compatibility
  • The fix is fully rigorous for signed/unsigned integers at any magnitude
  • For integer/float comparisons, there's a documented edge case at extreme magnitudes (beyond float exact range) due to float precision limits, mirroring limitations already in operator==

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>
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Jul 9, 2026
@github-actions

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will no longer match if #5211 is merged.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did the string hashes change?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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>
@nlohmann nlohmann force-pushed the claude/github-issue-review-b531d3 branch from 5b9e400 to 48face7 Compare July 10, 2026 04:42
@github-actions

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>
Comment thread include/nlohmann/detail/hash.hpp Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation L review needed It would be great if someone could review the proposed changes. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Int and uint compare equal but hashes do not

3 participants