Skip to content

Make std::hash<basic_json> consistent with operator== for numbers - #5447

Open
afonsojanu wants to merge 3 commits into
nlohmann:developfrom
afonsojanu:fix/hash-consistent-with-equality-for-numbers-5400
Open

afonsojanu wants to merge 3 commits into
nlohmann:developfrom
afonsojanu:fix/hash-consistent-with-equality-for-numbers-5400

Conversation

@afonsojanu

Copy link
Copy Markdown

Closes #5400.

operator== converts between number_integer, number_unsigned, and number_float before comparing, so json(0), json(0U), and json(0.0) all compare equal. hash() folded the specific value_t into the result for each of the three numeric cases, giving each a distinct hash and breaking the standard Hash requirement that a == b implies hash(a) == hash(b). A std::unordered_set could hold all three as separate elements even though they compare equal, which is exactly the reproduction in the issue.

hash() now treats all three numeric variants the same way: it converts the value to number_float_t and combines it with a single shared type tag, so any two numbers operator== considers equal hash identically regardless of which internal type actually holds them.

I also updated the accompanying test to check this consistency directly (including via an actual unordered_set) instead of asserting that 0, 0U, and 0.0 hash differently, since that assumption was the bug. The function's own doc comment and the std::hash API docs described the old behavior as intended, so I corrected those too, and regenerated the doc example's output since hash(0) and hash(0U) are no longer different values.

Tested locally by building the amalgamated header and compiling the full test suite (minus the parts that need the separately-downloaded test data submodule, which isn't available in my environment) plus unit-hash.cpp in isolation against both the fixed and unfixed code, confirming the new assertions fail against the original hash() with the exact numbers from the issue and pass with the fix.

operator== converts between number_integer, number_unsigned, and
number_float before comparing, so json(0), json(0U), and json(0.0)
all compare equal. hash() folded the specific value_t into the
result for each of the three numeric cases, giving each a distinct
hash and breaking the standard Hash requirement that a == b implies
hash(a) == hash(b). A std::unordered_set could therefore hold all
three as separate elements even though they compare equal.

hash() now treats all three numeric variants the same way: it
converts the value to number_float_t and combines it with a single
shared type tag, so any two numbers operator== considers equal hash
identically regardless of which internal type actually holds them.

Updated the accompanying test to check this consistency directly
(including via an actual unordered_set) instead of asserting that 0,
0U, and 0.0 hash differently, since that assumption was the bug.
Also corrected the function's own doc comment and the std::hash API
docs, which described the old behavior as intended.

Fixes nlohmann#5400
Merging the three numeric branches into one that only reads
number_float_t left these two aliases unused, which several CI
configurations treat as a build error under -Wunused-local-typedefs.
clang-tidy's misc-const-correctness check flagged it: the set is
never mutated after construction, only read via size().

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@afonsojanu Please fix the DCO requirements. Otherwise good to go.

@nlohmann nlohmann added the 🚀 ready to merge Ready to merge - just waiting for CI to complete. label Sep 2, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Sep 2, 2026
@nlohmann

nlohmann commented Sep 2, 2026

Copy link
Copy Markdown
Owner

This also fixes #5256, which is a duplicate report of the same std::hash contract violation (that issue's repro is json(42) == json(42u) but with different hashes — verified that this PR's approach makes them hash equal too).

There was a separate attempt at this in #5262, but it turned out to have a correctness bug of its own (hashing large integers in their native domain instead of through number_float_t breaks the std::hash contract precisely for the numbers where operator== relies on the float cast) — details in this comment. Recommending we go with this PR instead.

Fixes #5256

— posted by Claude Code on behalf of @nlohmann

@nlohmann nlohmann linked an issue Sep 2, 2026 that may be closed by this pull request
2 tasks

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The DCO requirements are still unmet. Please check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation M 🚀 ready to merge Ready to merge - just waiting for CI to complete. tests

Projects

None yet

2 participants