Многие программисты годами пишут свои программы, не понимая, что такое числа с плавающей запятой, и чем они отличаются от "обычных", целых чисел. Это не мешает им создавать хорошие программы. Но в конце концов каждый сталкивается с "необъяснимым" явлением:
$a = 1.1 - 1; $b = 0.1; if ($a == $b) { print "$a равно $b"; } else { print "$a не равно $b"; }
Эта программа печатает "0.1 не равно 0.1". В чём дело? Напрашивается вывод, что в языке программирования что-то не в порядке. В сети можно найти немало переписок с разработчиками языков о подобных "ошибках". На самом же деле, этот пример демонстрирует некоторые важные свойства чисел с плавающей запятой — их эта статья и попытается объяснить.
Как узнать, что используются числа с плавающей запятой?
В языках программирования со строгой типизацией существуют, как правило, специальные типы данных для чисел с плавающей запятой (float/double/long double в Си, single/double/extended в Паскале). Если в вычислении участвует хотя бы одна переменная или константа с плавающей запятой, все другие числа тоже преобразовываются к этому типу.
В языках без строгой типизации, как Perl, PHP или JavaScript, заметить использование чисел с плавающей запятой сложнее. Для программиста все числа выглядят одинаково, переключение с целочисленных типов на типы с плавающей запятой происходит автоматически. Можно исходить из того, что используются операции для чисел с плавающей запятой, если какая-нибудь из участвующих переменных содержит дробную часть или её значение выходит за пределы диапазона целых чисел. Но бывают и случаи, когда числа с плавающей запятой используются для целочисленных значений:
$a = 0.5; $a *= 2;
Здесь переменная $a
равна единице (это покажет и сравнение, в отличие от примера в начале статьи), но её значение всё равно хранится как число с плавающей запятой потому, что её значение раньше содержало дробную часть.
Некоторые языки, к примеру dBase, содержат дополнительный тип данных для чисел с фиксированной запятой. Они предназначены для более точных расчётов, к примеру в бухгалтерии. По сути, это "обычные" целые числа, у которых несколько последних знаков определены, как знаки после запятой. Соответственно, они и ведут себя так же, как целые числа. Всё, что написано в этой статье — не о них.
Откуда берётся неточность?
Основная причина неточности при использовании чисел с плавающей запятой в том, что компьютер не может работать с бесконечными дробями, которые мы знаем из математики — для них понадобилось бы бесконечное количество памяти. Это и понятно, мы тоже округляем числа до какого-то знака, когда имеем дело с десятичными дробями. Но это не объясняет приведённого в начале статьи примера — ведь там всего один знак после запятой?
Существует ещё один фактор — компьютер считает не в десятичной системе, а в двоичной. А если представить 0.1 как двоичную дробь, то она окажется периодической: 0.0(0011)
. Соответственно, в памяти компьютера число 0.1 представлено как1.1001100110011001100110011001100110011001100110011010b * 2^(-4)
. Обратите внимание на округление в конце числа. Если перевести его обратно в десятичную систему, то получится 0.10000000000000000555111512.
Почему тогда показывается не это число, а 0.1? Дело в том, что числа с плавающей запятой на выводе всегда округляются. Perl, к примеру, по умолчанию выводит только пятнадцать значащих знаков. Но, если использовать функцию printf()
, можно вывести до семнадцати значащих знаков, и тогда мы вдруг увидим 0.10000000000000001. В некоторых браузерах метод Number.toPrecision()
JavaScript'а позволяет выводить числа даже с пятьюдесятью значащими знаками.
Так почему всё-таки 1.1 ? 1 не равно 0.1? Если посмотреть значение числа 1.1 ? 1, то мы увидим 0.10000000000000008881784197. Оно, очевидно, не равно компьютерному представлению числа 0.1, хотя при стандартном округлении и выглядит точно так же. Разница объясняется тем, что мы считали с округлённой версией числа 1.1.
Как бороться с погрешностями?
Если использовать числа с плавающей запятой, то погрешность результатов оценить сложно. До сих пор не существует удовлетворительной математической теории, которая позволяла бы это делать. С другой стороны, погрешность при операциях с целыми числами оценивается легко. Поэтому одна возможность избавиться от неточностей — это использование чисел с фиксированной запятой, упомянутых выше. Эта возможность нашла наиболее широкое применении в финансовых программах, где заранее известно нужное количество знаков после запятой. Для других же приложений ограничения чисел с фиксированной запятой часто оказываются проблематичными — с их помощью нельзя представить ни очень большие числа, ни очень маленькие.
Вместо этого можно дальше использовать числа с плавающей запятой, но при этом всегда учитывать, что возможны неточности. Так, при выводе результаты надо непременно округлять, причём часто автоматического округления недостаточно, и округление приходится задавать явно. Также надо быть осторожным с операцией сравнения. Можно округлять числа перед сравнением, либо, что более эффективно, смотреть на разницу чисел:
if (abs($a - $b) < 0.000001 * min($a, $b)) { print "$a равно $b"; } else { print "$a не равно $b"; }
Проблема здесь, опять же, состоит в том, что сложно оценить размер возможных погрешностей — неизвестно, с какой максимальной точностью можно считать, чтобы погрешности не попали в результат. Выражать это число через размер переменных, с которыми мы работаем, как это сделано в примере — первый шаг, но он не решает всех проблем.
Бесконечность и прочие вкусности
Для чисел с плавающей запятой определены несколько специальных значений, которые весьма непривычны для программистов, привыкших к целочисленным операциям. Так, если взять самое большое целое число и прибавить к нему единицу, произойдёт переполнение, и число станет отрицательным. Если же прибавить единицу к самому большому числу с плавающей запятой, то не произойдёт ровным счётом ничего; в результате мы получим то же самое число. Это явление объясняетсяниже. Переполнения можно добиться, к примеру, умножив это число на два. Но результат будет несколько необычным — "число" Inf
(от англ. infinity = бесконечность). Аналогичным образом можно получить отрицательную бесконечность — -Inf
.
Бесконечность получается и при делении на ноль, причём и здесь она может быть как положительной, так и отрицательной (никакого исключения, как при работе с целыми числами, не возникает). И с ней действительно можно решать! Так, если разделить любое число на бесконечность, получится ноль. Произведение двух бесконечностей опять даёт бесконечность, как и сумма бесконечностей с одинаковым знаком.
А вот сумма бесконечностей с разными знаками не определена, результатом получается NaN
, другое специальное значение (от англ. Not a Number = не число). То же самое выйдет, если попытаться умножить бесконечность на ноль или поделить ноль на ноль. В некоторых языках программирования NaN
является ещё и результатом неудачного преобразования строки в число. С NaN
тоже можно решать, но результат любой операции будет опять же NaN
.
Ну и под конец ещё одно необычное явление: если в JavaScript написать 1/0
, то результатом будет Inf
, а вот 1/-0
вернёт -Inf
. Для чисел с плавающей запятой действительно определены два нуля: положительный и отрицательный! К счастью, в программе это обычно не нужно учитывать. Оба нуля при сравнении равны и на выводе они, в большинстве языков программирования, тоже выглядят одинаково. Знак нуля важен только для операций деления и умножения. Поэтому во многих языках программирования нельзя даже определить константу со значением ?0, она автоматически преобразуется в положительный ноль (именно по этой причине пришлось использовать JavaScript в примере).