How do == and equals() differ when comparing Double values, specifically for NaN and -0.0?
answer
- Primitive Double -> IEEE 754: NaN != NaN, -0.0 == 0.0
- Boxed/generic/collection -> equals: NaN.equals(NaN) true, (-0.0).equals(0.0) false
- Two regimes flip at the boxing boundary
- Collections need total order for equals/hashCode contract
- Use isNaN(); never x == NaN
basics
~10 sFor Doubles used directly, == follows IEEE 754: NaN == NaN is false and -0.0 == 0.0 is true. But equals() (and boxed/generic comparisons) flips this: NaN.equals(NaN) is true and (-0.0).equals(0.0) is false.
solid answer
~40 sKotlin has two rule sets for floating point. When the **static type is the primitive `Double`/`Float`**, `==` and `<` use **IEEE 754** semantics: `Double.NaN == Double.NaN` is `false`, and `0.0 == -0.0` is `true`. But when comparison goes through **`equals()`** — which happens when the value is **boxed** (`Double?`, `Any`, used as a generic type parameter `T`, or stored in a collection) — Kotlin switches to **total ordering**: `NaN.equals(NaN)` is `true` and `(-0.0).equals(0.0)` is `false`. This makes `NaN` usable as a `HashSet`/`HashMap` key and gives collections a consistent equals/hashCode contract. The trap: `setOf(Double.NaN).contains(Double.NaN)` returns `true`, but `Double.NaN == Double.NaN` is `false`. So the same two values can compare unequal as primitives yet equal inside a collection. Always check intent — use `isNaN()` explicitly when you mean it.
code
kotlin · 11 lines// Primitive: IEEE 754
println(Double.NaN == Double.NaN) // false
println(0.0 == -0.0) // true
// Boxed/equals: total order
println(Double.NaN.equals(Double.NaN)) // true
println((-0.0).equals(0.0)) // false
// Collection uses equals semantics
val s = hashSetOf(Double.NaN)
println(s.contains(Double.NaN)) // truego deeper
May only know NaN behaves oddly; can state NaN == NaN is false for plain Doubles.
Knows both the IEEE 754 and equals results and that -0.0/0.0 differ between them.
Explains the boxing/generic boundary that switches regimes and why collections need total ordering for the equals/hashCode contract.
Weighs API/correctness implications: choosing key types, defining custom comparators, and auditing numeric equality across module boundaries to avoid silent regime switches.
## Two comparison regimes for floating point Kotlin deliberately uses **different equality semantics** for floats depending on whether the value is a **primitive** or **boxed/generic**. ### 1. Statically-typed primitive `Double`/`Float` -> IEEE 754 When the compiler knows the type is the primitive `Double` (or `Float`), `==`, `<`, `>` etc. follow the **IEEE 754** standard: - **`NaN`** (Not a Number, e.g. `0.0 / 0.0`) is **unordered**: `NaN == NaN` is `false`, and `NaN < x`, `NaN > x` are all `false`. - **Signed zero**: `-0.0` and `+0.0` are considered **equal**: `0.0 == -0.0` is `true`. ```kotlin val a = Double.NaN println(a == a) // false (IEEE 754) println(0.0 == -0.0) // true (IEEE 754) ``` ### 2. Boxed / generic / `equals()` -> total ordering When the value is **boxed** — its static type is `Double?`, `Any`, a generic `T`, or it lives in a collection — comparison goes through `Double.equals()`/`compareTo`, which imposes a **total order** (every value, including `NaN`, has a well-defined place): - `NaN.equals(NaN)` is **`true`**. - `(-0.0).equals(0.0)` is **`false`** (signed zeros are distinct here). ```kotlin val x: Any = Double.NaN val y: Any = Double.NaN println(x == y) // true (boxed -> equals) println((-0.0).equals(0.0)) // false (boxed -> equals) ``` ## Why two rules? IEEE 754 makes numeric math correct, but it **breaks the `equals`/`hashCode` contract** that collections require (a key must equal itself; `NaN` failing that would make it impossible to retrieve). Total ordering fixes this so `NaN` can be a stable `HashMap`/`HashSet` key and `sortedBy {}` is well-defined. ## The practical trap ```kotlin val set = hashSetOf(Double.NaN) println(set.contains(Double.NaN)) // true (uses equals) println(Double.NaN == Double.NaN) // false (primitive IEEE 754) ``` The **same two `NaN` values** compare unequal directly but equal inside a collection. The boxing boundary silently changes semantics. ## How to be explicit - Test for NaN with **`x.isNaN()`**, never `x == Double.NaN` (always false). - Distinguish signed zeros deliberately, e.g. `1.0 / x` (gives `+Infinity` vs `-Infinity`) or `x.equals(-0.0)`. - Avoid `Double` as a map key unless you understand the total-order behavior.
- Can you use Double.NaN as a HashMap key and retrieve it?Yes. Maps use equals()/hashCode(), where NaN.equals(NaN) is true, so the key is retrievable — unlike the primitive == which would say NaN != NaN.
- Why doesn't Kotlin just use IEEE 754 everywhere?IEEE 754 violates the reflexivity required by the equals/hashCode contract (NaN != NaN), which would corrupt hashed collections and sorting; total ordering restores it for boxed values.
- How should you test whether a Double is NaN?Use d.isNaN() (or d != d as a low-level idiom). Comparing d == Double.NaN is always false and is a common bug.
saying these in an interview costs you the question
- Saying NaN == NaN is true for primitive Doubles
- Believing -0.0 and 0.0 are always distinct, or always equal, regardless of context
- Not knowing boxing changes the semantics
- Testing NaN with == Double.NaN
- Claiming NaN cannot be a map key in Kotlin