JavaRush /Courses /Kotlin SELF /Double precision: epsilon comparison, NaN, and Infinity

Double precision: epsilon comparison, NaN, and Infinity

Kotlin SELF
Level 6, Lesson 2
Available

1. How Double is represented and the precision problem

When you first see that 0.1 + 0.2 suddenly is not equal to 0.3, you might suspect a conspiracy: the compiler, the CPU, a cat that walked over the keyboard. In reality, it is simpler and more boring: a computer stores Double in a format where many “nice” decimal fractions are represented not perfectly, but by the nearest available value.

This is like a real-life situation: you measure a table with a tape measure, but its markings go only down to millimeters. If the table length is actually 1000.0004 mm, you will still write down 1000 mm or 1001 mm. You are not lying — you are simply limited by the tool. With Double it is the same: the tool works in binary, and some decimal fractions do not fit into it exactly.

Why == for Double is a trap

If the numbers are approximate, the results of computations can also be approximate. And here the main problem appears: the == operator checks strict equality, meaning “every digit matches”, not “it’s close enough”. For integers (Int), this is usually what you want. For Double, often it is not.

For Float/Double comparisons, Kotlin follows the IEEE 754 standard rules, not its own. This implies special behavior for NaN, zeros, infinities, and comparisons.

Let’s look at the classic example that has generated more memes than “the programmer forgot to put a semicolon again”.

fun main() {
    val x = 0.1 + 0.2
    println("x = $x")                  // x = 0.30000000000000004 (for example)
    println("x == 0.3 -> ${x == 0.3}")  // x == 0.3 -> false (for example)
}

The comment “(for example)” matters here: the exact output may differ slightly, but the point is the same — the number is very close to 0.3, but it is not required to become exactly 0.3.

2. Epsilon comparison: “almost equal”

The basic idea of epsilon comparison

When you compare floating-point results, most of the time you do not need a bit-for-bit match. You need “the difference is so small that we consider the numbers the same”. This “acceptable error” is called epsilon (often written as eps). The logic is: two numbers are almost equal if the absolute value of their difference is less than eps.

Technically, we use abs (absolute value) so that the difference is positive (because we care about the distance between the numbers, not which one is larger).

fun main() {
    val x = 0.1 + 0.2
    val eps = 1e-9

    val almostEqual = kotlin.math.abs(x - 0.3) < eps
    println("almostEqual = $almostEqual") // almostEqual = true (usually)
}

This idea — “we compare closeness rather than equality” — is your main lifeline throughout working with fractions.

A small diagram (not because you won’t understand without it, but because diagrams sometimes save the brain from overheating):

flowchart TD
    A["There are two Doubles: a and b"] --> B["Compute the difference: d = abs(a - b)"]
    B --> C["Compare with tolerance eps"]
    C -->|d < eps| D["Consider: almost equal"]
    C -->|d >= eps| E["Consider: noticeably different"]

How to choose eps so it does not become magic

At this point, a beginner usually has a reasonable question: “Why 1e-9? Can I use 1e-3? Can I use 0.5 so it definitely works?” You can do anything — but the correct eps depends on the meaning of the task and the scale of the numbers.

If you compare results like 0.3, 1e-9 is a decent starting point for Double in educational tasks. But if you work with numbers on the order of a million, an absolute tolerance of 1e-9 becomes absurdly strict: you are demanding “nanometer-level” precision when measuring the length of a football field.

Sometimes a “combined rule” is used: the tolerance depends on the scale of the numbers. The idea is simple: compare abs(a - b) not just with eps, but with eps * max(abs(a), abs(b), 1.0). This is still not “higher math”, but it already looks like adult life.

fun main() {
    val a = 1_000_000.0 + 0.1
    val b = 1_000_000.0 + 0.10000000005

    val eps = 1e-9
    val scale = maxOf(kotlin.math.abs(a), kotlin.math.abs(b), 1.0)
    val almostEqual = kotlin.math.abs(a - b) < eps * scale

    println("almostEqual = $almostEqual") // almostEqual = true (usually)
}

In this lecture, what matters is not “memorizing the formula”, but the idea: eps is an agreement about precision, not a magic number.

3. Special values: NaN and Infinity

NaN: “not a number” that breaks checks

Now for special effects. In the Double world, there are values that look like numbers, live in a variable of type Double, but do not behave like “normal numbers”. The most famous is NaN (Not a Number, “not a number”).

It appears when the result of an arithmetic operation is mathematically “undefined”. The simplest example is 0.0 / 0.0.

fun main() {
    val nan = 0.0 / 0.0
    println("nan = $nan")                    // nan = NaN
    println("nan == nan -> ${nan == nan}")   // nan == nan -> false
    println("nan.isNaN() -> ${nan.isNaN()}") // nan.isNaN() -> true
}

Important point: NaN cannot be checked with ==, even against itself. The correct check is x.isNaN().

Kotlin nuance: comparison via Any

Kotlin has a subtlety: when the operands of comparison are not statically typed as Double/Float (for example, hidden inside Any), comparison may behave differently: in this mode, NaN can turn out to be equal to itself. Kotlin explicitly describes that for “non-floating-typed” operands a structural semantics applies that differs from IEEE 754, and among the differences it mentions that NaN is equal to itself.

This does not mean Kotlin “broke” — it means that in practice it is better not to hide Double inside Any if you want predictable arithmetic semantics.

Infinity: infinity as a value, not an error

In the integer world, division by zero is an almost guaranteed error and an abnormal program termination. In the Double world, you often get not an error, but a special value: Infinity (infinity). For example, 1.0 / 0.0 yields positive infinity.

fun main() {
    val inf = 1.0 / 0.0
    val negInf = -1.0 / 0.0

    println("inf = $inf")                              // inf = Infinity
    println("negInf = $negInf")                        // negInf = -Infinity
    println("inf.isInfinite() -> ${inf.isInfinite()}") // true
}

This value can be compared with numbers, and it really behaves like “very large”. But infinity has its own “chemistry”: some expressions turn it into NaN.

fun main() {
    val inf = 1.0 / 0.0
    val nan = inf - inf

    println("inf - inf = $nan")              // inf - inf = NaN
    println("nan.isNaN() = ${nan.isNaN()}")  // true
}

4. Practice: a mini speedometer and protection from NaN/Infinity

So that all this does not remain abstract philosophy about fractions, let’s write a small console helper app. Let it be a “Mini speedometer”: the user enters distance (in meters) and time (in seconds), and the program computes the average speed in m/s.

We will intentionally keep the input integer (Int), because parsing a Double from a string is a separate topic and we are not going into it now. Instead, we will show how to neatly get a Double result and how to check it for NaN/Infinity.

Why you cannot “just divide”

If you do distance / time as Int / Int, you get an Int, and the fractional part is lost. So we will force the expression to become floating-point by adding 1.0 * ... (this is a simple lifehack: if a Double participates in an expression, the result becomes Double).

fun main() {
    print("Enter distance (meters): ")
    val distance = readln().toInt()

    print("Enter time (seconds): ")
    val time = readln().toInt()

    val speed = 1.0 * distance / time
    println("speed = $speed m/s") // example: speed = 2.5 m/s
}

Note: there is already a potential problem here — if time equals zero, we will get Infinity or NaN, not a “normal speed”. And this is not a compiler error: it is the honest result of floating-point arithmetic.

Checking the result for NaN and Infinity

Now let’s add a simple variation: if the speed is not a normal finite number, we print a message about it. We use isNaN() and isInfinite() — these are basic tools that exist specifically for such cases.

fun main() {
    print("Distance (m): ")
    val distance = readln().toInt()

    print("Time (s): ")
    val time = readln().toInt()

    val speed = 1.0 * distance / time

    if (speed.isNaN() || speed.isInfinite()) {
        println("Cannot compute speed: check time (zero?)") // example
    } else {
        println("Average speed: $speed m/s")
    }
}

Comparing to a reference: “is the speed almost 10 m/s?”

Now let’s add a small element of “control logic”: suppose we want to verify that the speed is approximately equal to 10.0 (for example, for a test or a demo). Writing speed == 10.0 is bad practice. Instead, use epsilon comparison.

fun main() {
    val speed = 0.1 + 9.9
    val eps = 1e-9

    val ok = kotlin.math.abs(speed - 10.0) < eps
    println("speed = $speed")     // speed = 10.0 (or very close)
    println("ok = $ok")           // ok = true
}

The example is, of course, far from real life, but the point is different: in real formulas (especially those with lots of divisions and multiplications) rounding error appears unexpectedly, and epsilon comparison makes checks stable.

5. Common mistakes

Mistake #1: comparing computation results of Double with == as if it were Int.
Such a comparison breaks easily on completely “normal” expressions where math is perfect, but the computer introduces tiny error. If you are comparing the result of computations, use the “almost equal” approach via abs(a - b) < eps.

Mistake #2: choosing eps randomly and then believing it as absolute truth.
Too large an eps turns the comparison into a farce: almost everything is “equal”. Too small an eps brings you back to the strict == problem, only in a more disguised form. eps must match the meaning of the task and the scale of the numbers, not the programmer’s mood at 2 a.m.

Mistake #3: trying to check NaN using == or using “weird” comparisons (x > 0, x < 0).
NaN is a special value, and comparisons with it do not behave the way you expect from normal numbers. In correct code, the check must be explicit: x.isNaN(). It is also useful to remember that the comparison rules for NaN depend on whether the operands are statically typed as Double/Float or not — Kotlin describes this separately.

Mistake #4: assuming division by zero in Double “must crash with an error”.
In floating-point arithmetic, division by zero often yields Infinity, and 0.0 / 0.0 yields NaN. If you write formulas where the denominator can become zero, you must either validate inputs in advance, or at least check the result with isInfinite()/isNaN() and not continue calculations as if everything were fine.

Mistake #5: hiding Double inside Any (or mixing types so the compiler loses the “floating-point context”) and then being surprised by == behavior.
This is a rarer issue at the beginner level, but it happens even to experienced developers when data passes through “universal” containers. Kotlin separately emphasizes that comparing NaN and some other cases can behave differently if the operands are not statically Double/Float.

1
Task
Kotlin SELF, level 6, lesson 2
Locked
Calculator glitch
Calculator glitch
1
Task
Kotlin SELF, level 6, lesson 2
Locked
Precision check
Precision check
1
Task
Kotlin SELF, level 6, lesson 2
Locked
Digital anomaly
Digital anomaly
1
Task
Kotlin SELF, level 6, lesson 2
Locked
Division without panic
Division without panic
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION