JavaRush /Курси /Kotlin SELF /Точність Double: epsilon‑порівняння, NaN і Infinity

Точність Double: epsilon‑порівняння, NaN і Infinity

Kotlin SELF
Рівень 6 , Лекція 2
Відкрита

1. Подання Double і проблема точності

Коли ви вперше бачите, що 0.1 + 0.2 раптом не дорівнює 0.3, хочеться запідозрити змову: компілятор, процесор, кіт, який пройшовся по клавіатурі. Насправді все простіше й нудніше: компʼютер зберігає Double у форматі, де багато «гарних» десяткових дробів подаються не ідеально, а найближчим доступним значенням.

Це схоже на ситуацію з реального життя: ви вимірюєте стіл рулеткою, але поділки в неї лише до міліметра. Якщо довжина столу насправді 1000,0004 мм, ви все одно запишете 1000 мм або 1001 мм. Ви не брешете — ви просто обмежені інструментом. З Double те саме: інструмент працює в двійковій системі, і деякі десяткові дроби просто в неї не вкладаються.

Чому == для Double — пастка

Якщо числа приблизні, то й результат обчислень теж може бути приблизним. І ось тут зʼявляється головна проблема: оператор == перевіряє строгу рівність, тобто «кожна цифра збіглася», а не «майже збіглося». Для цілих чисел (Int) це зазвичай саме те, що треба. Для Double — часто ні.

Kotlin для порівняння Float/Double дотримується правил стандарту IEEE 754, а не «своїх власних». Це означає особливу поведінку для NaN, нулів, нескінченностей і самих порівнянь.

Подивімося на класичний приклад, який породив більше мемів, ніж фраза «програміст знову забув поставити крапку з комою».

fun main() {
    val x = 0.1 + 0.2
    println("x = $x")                  // x = 0.30000000000000004 (наприклад)
    println("x == 0.3 -> ${x == 0.3}")  // x == 0.3 -> false (наприклад)
}

Коментар «(наприклад)» тут важливий: конкретний вивід може трохи відрізнятися, але суть одна — число виходить дуже близьким до 0.3, проте не зобовʼязане бути рівно 0.3.

2. Epsilon‑порівняння: «майже дорівнює»

Базова ідея epsilon‑порівняння

Коли ви порівнюєте дійсні результати, найчастіше вам не потрібен збіг «до біта». Потрібно інше: «різниця настільки мала, що ми вважаємо числа однаковими». Цю «допустиму похибку» називають epsilon (зазвичай пишуть eps). Логіка така: два числа майже рівні, якщо модуль їхньої різниці менший за eps.

Технічно ми використовуємо abs (модуль числа), щоб різниця була додатною. Адже нам важлива відстань між числами, а не те, яке з них більше.

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 (зазвичай)
}

Ця ідея — «порівнюємо не рівність, а близькість» — ваш головний рятувальний круг на всьому шляху роботи з дробами.

Невелика схема (не тому, що ви не зрозумієте без неї, а тому, що схеми інколи рятують мозок від перегріву):

flowchart TD
    A["Є два Double: a і b"] --> B["Рахуємо різницю: d = abs(a - b)"]
    B --> C["Порівнюємо з допуском eps"]
    C -->|d < eps| D["Вважаємо: майже дорівнює"]
    C -->|d >= eps| E["Вважаємо: помітно відрізняється"]

Як вибирати eps, щоб це не стало магією

У цьому місці в новачка зазвичай виникає цілком резонне питання: «А чому 1e-9? А можна 1e-3? А можна 0.5, щоб точно спрацювало?» Можна все. Але правильний eps залежить від змісту задачі та масштабу чисел.

Якщо ви порівнюєте результати на кшталт 0.3, то 1e-9 — непогана стартова точка для Double у навчальних задачах. Але якщо ви працюєте з числами порядку мільйона, абсолютний допуск 1e-9 стає абсурдно суворим. Ви фактично вимагаєте точності «до нанометра» у вимірюваннях довжини футбольного поля.

Іноді використовують «комбіноване правило», коли допуск залежить від масштабу чисел. Ідея проста: порівнювати abs(a - b) не просто з eps, а з eps * max(abs(a), abs(b), 1.0). Це все ще не «вища математика», але вже дуже схоже на доросле життя.

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 (зазвичай)
}

У цій лекції важливо запамʼятати не «формулу напамʼять», а думку: eps — це домовленість про точність, а не чарівне число.

3. Спеціальні значення: NaN і Infinity

NaN: «не число», яке ламає перевірки

Тепер — до спецефектів. У світі Double є значення, які виглядають як числа, живуть у змінній типу Double, але поводяться не як «звичайні числа». Найвідоміше з них — NaN (Not a Number, «не число»).

Воно зʼявляється, коли результат арифметичної операції математично «не визначений». Найпростіший приклад — 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
}

Важливий момент: NaN не можна перевіряти через ==, навіть «із самим собою». Правильна перевірка — x.isNaN().

Нюанс Kotlin: порівняння через Any

У Kotlin є тонкість: коли операнди порівняння статично не мають типу Double/Float (наприклад, «сховані» в Any), порівняння може поводитися інакше. У такому режимі NaN може виявитися рівним самому собі. Kotlin прямо описує, що для операндів без «дійсної» статичної типізації діє структурна семантика, відмінна від IEEE 754. Серед відмінностей згадується й те, що NaN дорівнює сам собі.

Це не означає, що Kotlin «зламався». Це радше підказка: на практиці краще не ховати Double у Any, якщо ви хочете передбачувану арифметичну семантику.

Infinity: нескінченність як значення, а не як помилка

У цілочисельному світі ділення на нуль — це майже гарантована помилка й аварійне завершення програми. У світі Double часто виходить не помилка, а спеціальне значення: Infinity (нескінченність). Наприклад, 1.0 / 0.0 дає додатну нескінченність.

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
}

Це значення можна порівнювати з числами, і воно справді поводиться як «дуже велике». Але в нескінченності є своя «хімія»: деякі вирази перетворюють її на 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. Практика: міні‑спідометр і захист від NaN/Infinity

Щоб це все не лишилося абстрактною філософією про дроби, напишімо маленький консольний застосунок‑помічник. Нехай це буде «Міні‑спідометр»: користувач вводить відстань (у метрах) і час (у секундах), а програма рахує середню швидкість у м/с.

Ми спеціально залишимо введення цілочисельним (Int). Адже розбір Double з рядка — окрема тема, і зараз ми в неї не заглиблюємося. Зате покажемо, як акуратно отримати Double-результат і як перевіряти його на NaN/Infinity.

Чому «просто поділити» не вийде

Якщо зробити distance / time як Int / Int, вийде Int, а дробова частина втратиться. Тому ми змусимо вираз стати дійсним, додавши 1.0 * ... (це простий прийом: якщо у виразі бере участь Double, результат теж стає Double).

fun main() {
    print("Введіть відстань (метри): ")
    val distance = readln().toInt()

    print("Введіть час (секунди): ")
    val time = readln().toInt()

    val speed = 1.0 * distance / time
    println("speed = $speed м/с") // приклад: speed = 2.5 м/с
}

Зверніть увагу: тут уже є потенційна проблема. Якщо time дорівнює нулю, ми отримаємо Infinity або NaN, а не «нормальну швидкість». І це не помилка компілятора — це чесний результат дійсної арифметики.

Перевірка результату на NaN і Infinity

Тепер додамо просту варіацію: якщо швидкість не є звичайним скінченним числом, виведемо повідомлення про це. Ми використаємо методи isNaN() і isInfinite() — це базові інструменти, які існують саме для таких випадків.

fun main() {
    print("Відстань (м): ")
    val distance = readln().toInt()

    print("Час (с): ")
    val time = readln().toInt()

    val speed = 1.0 * distance / time

    if (speed.isNaN() || speed.isInfinite()) {
        println("Швидкість обчислити не можна: перевірте час (нуль?)") // приклад
    } else {
        println("Середня швидкість: $speed м/с")
    }
}

Порівняння з еталоном: «швидкість майже 10 м/с?»

Тепер додамо маленький елемент «контрольної логіки». Припустімо, ми хочемо перевірити, що швидкість приблизно дорівнює 10.0 (наприклад, для тесту або демонстрації). Писати speed == 10.0 — погана практика. Замість цього використаємо epsilon‑порівняння.

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 (або дуже близько)
    println("ok = $ok")           // ok = true
}

Приклад, звісно, далекий від життя, але сенс в іншому: у реальних формулах (особливо там, де багато ділення й множення) похибка спливає несподівано. А epsilon‑порівняння робить перевірки стабільнішими.

5. Типові помилки

Помилка №1: порівнювати результати обчислень Double через ==, ніби це Int.
Таке порівняння легко ламається на цілком «звичайних» виразах: у математиці все ідеально, а в компʼютері зʼявляється мікропохибка. Якщо ви порівнюєте саме результат обчислень, використовуйте підхід «майже дорівнює» через abs(a - b) < eps.

Помилка №2: вибирати eps навмання, а потім вірити йому як істині.
Надто великий eps перетворює порівняння на профанацію: майже все «дорівнює». Надто малий eps повертає вас до проблеми строгого ==, лише в краще замаскованому вигляді. eps має відповідати змісту задачі та масштабу чисел, а не настрою програміста о 2 ночі.

Помилка №3: намагатися перевіряти NaN через == або через «дивні» порівняння (x > 0, x < 0).
NaN — особливе значення, і порівняння з ним поводяться не так, як очікується від звичайних чисел. У коректному коді перевірка має бути явною: x.isNaN(). Також корисно памʼятати, що правила порівняння для NaN залежать від того, чи є операнди статично Double/Float чи ні — Kotlin описує це окремо.

Помилка №4: вважати, що ділення на нуль у Double «обовʼязково впаде з помилкою».
У дійсній арифметиці ділення на нуль часто дає Infinity, а 0.0 / 0.0NaN. Якщо ви пишете формули, де знаменник може стати нулем, вам потрібно або перевіряти вхідні дані заздалегідь, або хоча б перевіряти результат на isInfinite()/isNaN() і не продовжувати обчислення так, ніби все нормально.

Помилка №5: ховати Double у Any (або змішувати типи так, що компілятор втрачає «дійсний контекст»), а потім дивуватися поведінці ==.
Це рідкісніша проблема на початковому рівні, але вона трапляється навіть у досвідчених розробників, коли дані проходять через «універсальні» контейнери. Kotlin окремо підкреслює, що порівняння NaN й деякі інші випадки можуть поводитися інакше, якщо операнди статично не є Double/Float.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ