Программирование

Двойная ошибка!

Что нужно знать каждому инженеру об ошибках с плавающей запятой на каждом компьютере

Отказ от ответственности

Этот пост станет слишком техническим. Я мог бы выплюнуть делай это, а не то или объяснить, как это работает. Однако я не буду делать выбор за вас. Я дам вам опцию TL;DR в конце страницы. Не стесняйтесь переходить к последнему разделу, если у вас недостаточно времени, чтобы прочитать это, но этот контент не должен пугать инженера. Это просто немного длинно.

Если вы решите прочитать все, в разделе TL;DR не будет НИЧЕГО нового.

Если вы ищете, как правильно представлять и хранить десятичные числа, см. Реализация десятичных чисел.

Что такое число с плавающей запятой?

Согласно IEEE 754:2019, число с плавающей запятой — это конечное или бесконечное число, представленное в формате с плавающей запятой.

Формат с плавающей запятой — это стандартное приближение для представления чисел с конечным числом битов. Как и при любом приближении, это не без потери точности.

Стандарт основан на тройке, один бит для знака, определенное количество битов для экспоненты и остаток для мантиссы (также известной как значащая или дробная).

В качестве примера, вот 32-битное число с плавающей запятой (float):

64-битное число с плавающей запятой (двойное) будет иметь другое распределение битов для тройки:

Название «с плавающей запятой» происходит от того факта, что разница в представлении между двоичными числами, такими как 1.1101011101 и 111.01011101, будет зависеть только от того, где находится точка, определяемая показателем степени. Поэтому у нас есть точка, которая может перемещаться по разным позициям в мантиссе, определяя разные числа и изменяя точность числа.

Примечание
Определяет способ представления десятичных чисел в двоичных компьютерах, а не в Java, C++ или каком-либо другом языке. Вот как это работает в памяти, поэтому это влияет на любую двоичную вычислительную систему, будь то язык программирования или база данных.

Потеря точности

Потеря точности происходит из-за того, что мы не можем представить все десятичные числа в двоичной форме.

Если вы помните, что в десятичном виде 0,4 равно 4 x 10⁻¹, вы поймете, что в системе счисления 2 число 0,1 переводится как 1 x 2⁻¹, что в десятичном эквиваленте равно 0,5. Это означает, что, поскольку у нас есть только цифры 0 и 1, мы можем выразить в двоичном формате только десятичные числа, которые могут быть представлены как суммы степеней -2.

Пока эти цифры работают нормально:

Decimal   2.0    2.5
Binary   10.0   10.1

Если вы попытаетесь сохранить десятичное число 2,6, вы увидите проблему, потому что 0,6 нельзя представить в виде степени двойки:

10,1100 = 2,75, что больше 2,6.
10,1010 = 2,625, что все же больше 2,6.
10,1001 = 2,5625, что меньше 2,6.
Не существует комбинации любых конечных количество двоичных цифр, которые будут правильно представлять X.6 (где X — целая часть).

Если вы хотите увидеть, какие десятичные и двоичные значения на самом деле хранятся в памяти, когда вы пытаетесь сохранить одно из этих неподдерживаемых десятичных чисел, повеселитесь здесь: Базовое преобразование: IEEE 754 с плавающей запятой.

Кодирование вокруг потери точности

Итак, зная о потере точности, мы должны учитывать это при разработке кода, использующего представления с плавающей запятой.

Сравнение

Примечание
Если бы я мог выбрать ОДНУ вещь, которую вы должны запомнить из всего этого документа, это была бы она. Как сравнивать числа с плавающей запятой
.

Зная, что 3,003 x 2,002 = 6,012006, можно было бы ожидать, что следующее сравнение вернет значение true:

System.out.println(6.012006 == 3.003 * 2.002); //prints false

Но, зная ограничения представления с плавающей запятой, мы знаем, что некоторые из этих чисел могут (или не будут) точно представлены. На самом деле умножение 3,003 x 2,002 дает только 6,012006 в десятичном виде. В двоичном формате результат будет 6.0120059999999995. Следовательно, сравнение вернет false.

Правильный способ сравнить представления десятичных чисел с плавающей запятой — определить порог ошибки. Например, учитывая, что и 3,003, и 2,002 имеют только 3 знака после запятой, все, что меньше 10⁻⁷, можно отклонить, поэтому порог определяется как 10⁻⁶:

System.out.println(6.012006 - 3.003 * 2.002 < 0.000001); //prints true

Простые примеры с суммами и вычитаниями, например

System.out.printf("%.16f\r\n",0.7+0.1); // DOESN'T print 0.8. It prints 0.7999999999999999.
System.out.printf("%.16f\r\n",0.8-0.09); // DOESN'T print 0.71. It prints 0.7100000000000001.

Поскольку это суммы и вычитания, результат операций должен иметь ту же точность, что и исходные значения, поэтому вам не нужно суммировать количество десятичных знаков каждого из них. Просто возьмите наибольшее количество знаков после запятой, как правило.

System.out.println(0.8 - (0.7+0.1) < 0.1); // prints true
System.out.println(0.71 - (0.8-0.09) < 0.01); // prints true

Это был бы правильный способ сравнить эти числа. Поэтому никогда не сравнивайте числа с плавающей запятой напрямую, если они не представляют двоичные числа. Всегда определяйте порог.

Кроме того, обратите внимание на ›= и ‹=, так как эти операции также предполагают равенство. Вы можете ожидать, что сравнения могут вернуть true, когда числа равны, но они могут не быть равными (даже если должны).

Примечание
Практическое правило для определения порога:
Первый метод: Математически, если вы умножаете или делите числа, умножайте количество десятичных цифр каждого операнда. Если вы суммируете или вычитаете, берите наибольшее количество цифровых разрядов каждого операнда.
Второй метод: если ваше приложение не заботится о такой точности и существует стандартное количество цифр из-за бизнес-правил или ограничений, используйте это.

Какой метод вы должны применить, будет зависеть от контекста. Если вы отображаете денежные числа и ваше приложение определяет отображаемое значение, примените второй метод. Если вы находитесь в том же приложении, но определяете размер изображений, второй метод не будет актуален. Знайте, что означают цифры в вашем коде.

Суммирование

Суммирование чисел с плавающей запятой обычно простое, но оно легко может превратиться в кошмар. Проблема начинается с концепции сложения чисел, у которых очень большая разница в показателе степени.

Когда показатели степени разные, позиции мантиссы представляют разные степени числа 2. Например:

100 (exp) x 001 (мантисса) = 00,1 (двоичное) (что равно 0,5 в десятичной системе)
10 (exp) x 001 (мантисса) = 0,01 (двоичная) (что составляет 0,25 в десятичной системе)

Следовательно, показатели степени должны совпадать, прежде чем можно будет выполнить суммирование. Когда один из показателей слишком высок, а другой слишком низок, сопоставление вызовет сдвиг битов младшего, и могут быть потеряны значащие биты. На самом деле, все они могут быть потеряны. Только представьте, если небольшое число имеет всего несколько битов, установленных в 1 в конце мантиссы, и теперь вам нужно увеличить показатель степени. По мере увеличения показателя степени вам придется вводить нули слева, вытесняя единицы справа.

Хотя в некоторых случаях, например, когда вам просто нужно суммировать 2 очень разных значения, нет обходного пути, аккумуляторы могут потребовать особого обращения.

Аккумулятор — это когда вы накапливаете значения переменной внутри цикла, например:

while (condition) {
    accumulator = accumulator + value;
}

Проблема с этим подходом в том, что аккумулятор будет постоянно расти, и последние значения могут быть потеряны, если они слишком малы. Для этой цели существуют специальные методы, которые рассчитывают потери при каждой сумме, накапливают их отдельно и добавляют обратно в конце или используют другие стратегии для минимизации ошибки.

Если вам нужно такое решение, поищите библиотеки, которые предоставляют эту реализацию из коробки. Вам придется искать такие вещи, как Адаптивная точная арифметика с плавающей запятой и быстрые надежные геометрические предикаты Шевчука», Алгоритм суммирования Каана и Попарное суммирование.

Очень интересный Summarizer был размещен здесь: https://github.com/martinus/java-playground/blob/master/src/java/com/ankerl/math/Summarizer.java

Это порт из этой реализации Python: Двоичное суммирование с плавающей запятой с точностью до полной точности (рецепт Python). Однако в некоторых крайних случаях это не идеально.

Округление

Операции с числами, представленными с плавающей запятой, подлежат округлению (при необходимости). Стратегия округления заключается в округлении до ближайшего четного числа (или округлении до четного), поскольку этот метод статистически позволяет избежать накопления ошибок округления. Это также определено как часть спецификации IEEE 754:2019.

Проценты (метод наибольшего остатка)

Обычно можно увидеть проценты с частичными, которые в сумме составляют 99,99% или, реже, 100,01%. Часто это ошибки, вызванные неправильным округлением частичных чисел или ошибками представления с плавающей запятой. Если у вас есть такие ошибки в экранах, отчетах и ​​т. д., поищите реализацию Метода наибольшего остатка, так как он предназначен для исправления распределения путем минимально возможного изменения частичных значений и сохранения их пропорции как можно ближе. быть так, чтобы сумма могла получить ровно 100%.

Реализации на разных языках программирования и базах данных размещены здесь: Реализация десятичных чисел

TL;DR

Примечание
Если вы прочитали все до сих пор, вы можете пропустить это. Ничего нового не будет. Если вы пропустили этот раздел, знайте, что вы не только упустили причины, по которым вы должны делать эти вещи таким образом, но и некоторые другие указания о том, как выполнять менее распространенные операции.

Никогда не сравнивайте числа типа double или float напрямую.

Вы могли бы ожидать, что это правда, но 3,003 * 2,002 не даст 6,012006 (даже если ваш калькулятор так говорит).

System.out.println(6.012006 <= 3.003 * 2.002); //prints false

Всегда используйте порог:

System.out.println(6.012006 - 3.003 * 2.002 < 0.000001); //prints true

Эмпирическое правило для определения порога:

Первый способ. При выполнении математических операций, когда вы суммируете 2 числа, количество десятичных цифр в результате больше между двумя участками:

4 цифры (1,0001) + 2 цифры (1,01) = 4 цифры (2,0101).

При умножении вы суммируете количество цифр в посылках:

3 цифры (3,003) x 3 цифры (2,002) = 6 цифр ( 6,012006).

Итак, математически, если вы умножаете или делите числа, суммируйте количество десятичных цифр каждого операнда. Если вы суммируете или вычитаете, возьмите наибольшее количество цифровых разрядов каждого операнда.

Второй метод. Если ваше приложение не заботится о такой точности и существует стандартное количество цифр из-за бизнес-правил или ограничений, используйте его.

Какой метод вы должны применить, будет зависеть от контекста. Если вы отображаете денежные числа и ваше приложение определяет отображаемое значение, примените второй метод. Если вы находитесь в том же приложении, но определяете размер изображений, второй метод не будет актуален. Знайте, что означают цифры в вашем коде.

Накопления

while (condition) {
    accumulator = accumulator + value;
}

Когда вы накапливаете значения (суммируете значения в цикле) и либо переменная-аккумулятор, либо накопленные значения (или оба) объявляются как двойные или с плавающей запятой, это может привести к потере значений (значения не добавляются).
Существуют подходящие способы сделать это без потерь, такие как Адаптивная точная арифметика с плавающей запятой и быстрые надежные геометрические предикаты Шевчука, Алгоритм суммирования Кэхана и Попарное суммирование.

Реализации на разных языках программирования и базах данных размещены здесь: Реализация десятичных чисел

Если вам нравится эта история, нажмите на аплодисменты в конце, чтобы я знал, о чем вы хотите прочитать.

Я не зарабатываю на блоге ни копейки. Если вы хотите поддержать создание большего количества контента, поделитесь блогом со своими коллегами и подпишитесь на него, чтобы получать уведомления о новых историях!