Skip to content

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

Merged
nlohmann merged 5 commits into
developfrom
fix/hash-numbers-5400
Oct 7, 2026
Merged

nlohmann merged 5 commits into
developfrom
fix/hash-numbers-5400

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Closes #5400.
Closes #5256.

std::hash<basic_json> gave different hashes to numbers that compare equal (json(0), json(0U), json(0.0)), so a std::unordered_set<json> could hold all three. That breaks the requirement that a == b implies hash(a) == hash(b).

This continues #5447 by @afonsojanu, who closed it on 10-05 to start fresh. Their three commits are cherry-picked unchanged (authorship kept), so the credit for the fix is theirs. It also supersedes my earlier attempt #5262.

Changes

  • Fix (@afonsojanu): hash() hashes every number as its number_float_t value under one shared type tag. Since Compare integers with floats exactly instead of widening the integer #5459, operator== compares numbers by exact mathematical value. Two equal numbers therefore have the same value and convert to the same number_float_t, so they hash equally, including at the range ends. The tests now check this consistency, including through an unordered_set. The docs no longer describe the old behavior as intended.
  • Follow-up:
    • Maps -0.0 to 0.0 before hashing, because -0.0 == 0 and std::hash is not required to treat them alike.
    • Updates the code comment to say exact comparison instead of "converts before comparing".
    • Tests -0.0, INT64_MIN, 2^63 as unsigned, and INT64_MAX stored as unsigned.
    • Adds hash(0.0) to the docs example, regenerates its output, adds a version-history note, and re-amalgamates.

Unequal but nearby large integers (for example 2^53 and 2^53+1) now share a hash. That is allowed, and such collisions are rare in practice.

Breaking changes

There is no API or ABI change. Hash values change for every number, and for every container that holds one. Code that relied on 0, 0U and 0.0 hashing differently, or that persisted hash values, will see different results. Hash values were never stable across platforms or versions.

Written by Claude Code on behalf of @nlohmann.

🤖 Generated with Claude Code

@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Oct 6, 2026
`#!cpp false`, etc.
type of the JSON value is taken into account, so `#!json null`, `#!cpp false`, and numbers all hash differently from
each other. Numbers that compare equal under [`operator==`](operator_eq.md) always hash equally, regardless of
whether they are stored as `#!cpp 0`, `#!cpp 0U`, or `#!cpp 0.0`.

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.

Would it be clearer to say stored as signed integer, unsigned integer, or floating point?

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.

Yes, that reads better. Changed in cf354aa to "stored as signed integer, unsigned integer, or floating-point number".

Written by Claude Code on behalf of @nlohmann.

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.

Expand the note here to explicitly say that these output values are for example only, and that they are not even guaranteed to be consistent across different compilers or compiler versions (in addition to platforms)?

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.

Done in cf354aa. The note now says the values are examples only: they depend on the platform, the compiler, and the compiler version, can change between library versions, and should not be persisted or relied on.

Written by Claude Code on behalf of @nlohmann.

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 all
hash differently from each other, but any two numbers that compare equal

@gregmarr gregmarr Oct 6, 2026 •

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.

Is it too pedantic to say that they may hash differently from each other, because it is theoretically possible to have a number that hashes the same as null or false?

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.

Not too pedantic, it's correct. Changed to "may hash differently" in both the docs and the code comment (cf354aa).

Written by Claude Code on behalf of @nlohmann.

// 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.

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.

Should the docs say something like the following, or is that self-evident?

While hash(uval) == hash(number_float_t(uval)) because the hash is done by converting to number_float_t, it is not necessarily true that hash(floatval) == hash(number_unsigned_t(floatval)) because the conversion from number_float_t to number_unsigned_t is not lossless.

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.

I think it's worth stating. cf354aa adds a paragraph to the docs and a sentence to the code comment: converting an integer to number_float_t keeps its hash, but converting a float to an integer type loses information (0.5 becomes 0), so the result need not hash the same. It also notes that unequal numbers can share a hash, for example 2^53 and 2^53 + 1.

Written by Claude Code on behalf of @nlohmann.

afonsojanu and others added 5 commits October 7, 2026 16:40
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 #5400

Signed-off-by: Afonso Januário <afonso-januario@hotmail.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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.

Signed-off-by: Afonso Januário <afonso-januario@hotmail.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
clang-tidy's misc-const-correctness check flagged it: the set is
never mutated after construction, only read via size().

Signed-off-by: Afonso Januário <afonso-januario@hotmail.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
operator== compares numbers exactly since #5459, so equal numbers
share one value and convert to the same number_float_t. Update the
comment accordingly, map -0.0 to 0.0 before hashing (std::hash need
not do that), and test -0.0 and the ends of the integer ranges. Show
hash(0.0) in the docs example and note the change in the version
history.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- Say "may hash differently" for null, false, and numbers, since a
  collision across types is possible.
- Name the storage types (signed integer, unsigned integer,
  floating-point number) instead of example literals.
- Explain that the hash survives converting an integer to
  number_float_t but not the lossy conversion back, and that unequal
  numbers may share a hash.
- State that the example hash values are illustrative only and vary by
  platform, compiler, compiler version, and library version.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the fix/hash-numbers-5400 branch from cf354aa to f1027d8 Compare October 7, 2026 14:44
@nlohmann nlohmann added 🚀 ready to merge Ready to merge - just waiting for CI to complete. and removed review needed It would be great if someone could review the proposed changes. labels Oct 7, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 7, 2026
@nlohmann
nlohmann merged commit a5f5d30 into develop Oct 7, 2026
5 of 168 checks passed
@nlohmann
nlohmann deleted the fix/hash-numbers-5400 branch October 7, 2026 17:18
nlohmann added a commit that referenced this pull request Oct 8, 2026
#5772 added functional casts that clang-tidy reports as C-style casts
(google-readability-casting). Also append a char instead of a
one-character string in unit-large_json.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann added a commit that referenced this pull request Oct 8, 2026
* Use the with_*_t aliases in tests, examples, and docs

Replace spelled-out basic_json<...> instantiations that only change one
or two template parameters with nlohmann::json::with_*_t (or
ordered_json::with_*_t when the object type is ordered_map). Types that
change all three number types chain with_integers_t and with_float_t.

The raw basic_json<...> spelling stays where the template parameter
list itself is the subject: the alias tests in unit-udt.cpp, explicit
instantiations, and the ordered_json/compile-time docs.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix unit-large_json for clang and JSON_DIAGNOSTICS

Two test problems from #5781 broke CI on develop: CAPTURE(depth); trips
clang's -Wextra-semi-stmt, and the type_error.321 messages did not
account for the diagnostics path prefix.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Use static_cast in unit-hash for clang-tidy

#5772 added functional casts that clang-tidy reports as C-style casts
(google-readability-casting). Also append a char instead of a
one-character string in unit-large_json.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Declare the expected message prefix const in unit-large_json

Without JSON_DIAGNOSTICS the prefix was never modified, which
clang-tidy reports (misc-const-correctness).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

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 🚀 ready to merge Ready to merge - just waiting for CI to complete. tests

Projects

None yet

3 participants