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 m/s") // пример: speed = 2.5 m/s
}
Обратите внимание: здесь уже есть потенциальная проблема — если 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ