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.0 — NaN. Якщо ви пишете формули, де знаменник може стати нулем, вам потрібно або перевіряти вхідні дані заздалегідь, або хоча б перевіряти результат на isInfinite()/isNaN() і не продовжувати обчислення так, ніби все нормально.
Помилка №5: ховати Double у Any (або змішувати типи так, що компілятор втрачає «дійсний контекст»), а потім дивуватися поведінці ==.
Це рідкісніша проблема на початковому рівні, але вона трапляється навіть у досвідчених розробників, коли дані проходять через «універсальні» контейнери. Kotlin окремо підкреслює, що порівняння NaN й деякі інші випадки можуть поводитися інакше, якщо операнди статично не є Double/Float.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ