skip to content

How do == and equals() differ when comparing Double values, specifically for NaN and -0.0?

level: seniorimportance: should knowfreq 45%

answer

  1. Primitive Double -> IEEE 754: NaN != NaN, -0.0 == 0.0
  2. Boxed/generic/collection -> equals: NaN.equals(NaN) true, (-0.0).equals(0.0) false
  3. Two regimes flip at the boxing boundary
  4. Collections need total order for equals/hashCode contract
  5. Use isNaN(); never x == NaN

basics

~10 s

For 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 s

Kotlin 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
kotlin
// 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))        // true

go deeper

for a junior

May only know NaN behaves oddly; can state NaN == NaN is false for plain Doubles.

for a middle

Knows both the IEEE 754 and equals results and that -0.0/0.0 differ between them.

for a senior

Explains the boxing/generic boundary that switches regimes and why collections need total ordering for the equals/hashCode contract.

for a principal

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

context