В одном из ИТ-пабликов мы увидели такой код на JavaScript:
> 7110 / 100 * 100 === 7110
< false
> 7120 / 100 * 100 === 7120
> true
Читается это так: сначала число 7110 делится на 100 и умножается на сто. Результат деления сравнивается с числом 7110, и JavaScript говорит, что результат не равен. Как будто если разделить на 100 и тут же умножить на 100, ты получишь не то же самое число, с которого начинал.
Во втором примере то же самое, но с числом 7120. Если его разделить на 100 и умножить на 100, получится ровно 7120. Получается, что одни и те же математические действия в двух случаях дали разные результаты. Как такое возможно?
Если разобрать этот код, мы увидим, что ошибки нет, — но нужно понимать, как работает JavaScript.
Деление и дробные числа
Когда мы делим одно число на другое и они не делятся нацело, то получаем дробное число — целую часть и то, что идёт после запятой. Но компьютер не использует стандартное школьное деление в столбик — вместо этого он представляет число в виде последовательности нулей и единиц и использует побитовые операции для деления.
Это значит, что он не останавливается, например, после точного вычисления 7110 / 100 = 71,1, а работает со всеми битами сразу. После такого деления у компьютера получается последовательность, например 11101011011011101, где 11010 — это целая часть, а всё остальное — дробная. Если ему понадобится в целой части хранить число побольше, то он просто возьмёт дополнительное место за счёт дробной части.
Получается, что запятая в такой переменной как бы плавает в зависимости от знаков до запятой, отсюда и название — «число с плавающей запятой» (floating point по-английски). Но когда компьютер забирает разряды у дробной части, он иногда может этим внести небольшую погрешность, например потерять последнюю цифру в дробной части (например, одну миллиардную).
Как точность деления влияет на умножение
Когда мы после деления умножаем результат на 100, то с точки зрения компьютера это просто побитовый сдвиг точки вправо на несколько разрядов. Если у нас всё было посчитано точно, то результат будет таким же, что и до деления.
Но всё дело в том, что иногда компьютер не может что-то поделить, хотя с точки зрения математики там всё просто. В этом случае он заполняет результатами вычисления все доступные нули и единицы в переменной, а остальное отбрасывает. Это значит, что результат уже получился неточный, а приблизительный, и дальше ошибка будет только расти.
Давайте посмотрим, что получается в каждом случае после деления:
В первом случае компьютер не смог поделить 7110 на 100 без остатка, поэтому при умножении он потащил за собой девятки после запятой. Отсюда и неточность при сравнении.
Как исправить
В JavaScript есть объект Math, который занимается всякой полезной математикой. И у этого объекта есть метод .round(), который может корректно округлить число до ближайшего целого. Зная о возможной ошибке в коде, нам стоит использовать это округление:
>Math.round(7110/100*100)===7110
<true
>Math.round(7120/100*100)===7120
<true
Где это может пригодиться
Обратите внимание на этот эффект, если пишете программу, в которой используется деление непредсказуемых чисел — например, если пользователь вводит что-то с клавиатуры, а вы потом совершаете с этими числами свои операции. Например, вы получили рост человека, поделили его на какой-то внутренний коэффициент и сравниваете со своими референсными значениями. Сделайте поправку на то, что при делении могла сломаться точность, и либо округляйте число, либо предусматривайте запасы при сравнении.
Кстати, ровно для этого и существуют тестировщики. Их задача, в частности, в том, чтобы прогнать сквозь программу все возможные варианты значений, которые может ввести пользователь, и отловить вот такие ошибки вычислений. Чаще всего для такого используют автоматические тесты — но чтобы понимать, что тестировать, нужно знать, где может быть ошибка. Теперь вы знаете.
Бонус: строгое сравнение ===
В нашем коде используется строгое сравнение, которое в JavaScript обозначается тремя знаками равенства. Строгое сравнение означает, что сравниваются не только значения, но и типы сравниваемых данных. Грубо говоря, число 1 и строка с символом ‘1’ с точки зрения строгого сравнения — разные вещи, хотя для людей внешне это одно и то же.
Есть ещё нестрогий оператор ==. Вот как он работает:
- Берёт оба аргумента сравнения.
- Смотрит, к какому единому общем типу данных их можно привести. Например, сделать оба аргумента строками, числами или превратить их в логические элементы.
- Сравнивает данные одного типа и понимает, равны они или нет.
В обычных ситуациях сравнение работает хорошо и мы даже не задумываемся о том, как оно устроено внутри. Но иногда нужно точно выяснить, одинаковые ли у нас аргументы по всем параметрам или нет — вот для этого и используется оператор строгого сравнения ===. Он работает так:
- Берёт первый аргумент и выясняет его тип — целое число, дробное, логический тип и так далее.
- Делает то же самое со вторым аргументом.
- Сравнивает их типы между собой. Если они равны — переходит к дальнейшему сравнению. Если не равны — возвращает false.
- Если типы равны, то сравнивает значения — и тоже возвращает false, если значения не совпадают между собой.