Как справиться с проблемами точности чисел с плавающей точкой? - PullRequest
1 голос
/ 15 октября 2019

Я использую Firebird 3.0.4 (как в Windows, так и в Linux), и у меня есть следующая процедура, которая наглядно демонстрирует мою проблему с числами с плавающей запятой, а также демонстрирует возможный обходной путь:

create or alter procedure test_float returns (res double precision,
  res1 double precision,
  res2 double precision)
as

declare variable z1 double precision;
declare variable z2 double precision;
declare variable z3 double precision;

begin

  z1=15;
  z2=1.1;
  z3=0.49;
  res=z1*z2*z3; /* one expects res to be 8.085, but internally, inside the procedure
                   it is represented as 8.084999999999.
                   The procedure-internal representation is repaired when then
                   res is sent to the output of the procedure, but the procedure-internal
                   representation (which is worng) impacts the further calculations */
  res1=round(res, 2);
  res2=round(round(res, 8), 2);

  suspend;

end

Можно увидеть результат процедуры с:

  select proc.res, proc.res1, proc.res2
  from test_float proc

Результат равен

RES     RES1    RES2
8,085   8,08    8,09

Но можно ожидать, что RES2 должно быть 8,09.

Можно яснообратите внимание, что внутреннее представление res содержит 8.0849999 (например, можно присвоить res сообщению об исключении и затем вызвать это исключение), оно восстанавливается во время вывода, но это приводит к ошибочным вычислениям, когда такая переменная используется в дальнейших вычислениях.

RES2 демонстрирует исправление: я всегда могу применить ROUND(..., 8) для исправления внутреннего представления. Я готов пойти с этим решением, но мой вопрос - приемлемый ли это обходной путь (когда внешний ROUND имеет строго меньше 5 знаков после запятой) или есть лучший обходной путь.

Все мои тесты проходят с этимОбходной путь, но чувство плохое.

Конечно, я знаю минимум, который должен знать каждый программист о float (есть статья об этом), и я знаю, что не следует использовать double для бизнес-вычислений.

1 Ответ

2 голосов
/ 15 октября 2019

Это врожденная проблема с вычислениями с числами с плавающей запятой, которая не является специфичной для Firebird. Проблема в том, что вычисление 15 * 1.1 * 0.49 с использованием чисел двойной точности составляет , а не , а именно 8,085. Фактически, если вы сделаете 8.085 - RES, вы получите значение, которое (приблизительно) 1.776356839400251e-015 (хотя, скорее всего, ваш клиент просто представит его как 0.00000000).

Вы получите аналогичное значениерезультаты на разных языках. Например, в Java

DecimalFormat df = new DecimalFormat("#.00");
df.format(15 * 1.1 * 0.49);

также будет выдавать 8.08 по той же причине.

Кроме того, если вы измените порядок операций, вы получите другой результат. Например, использование 15 * 0.49 * 1.1 приведет к 8.085 и округлению до 8.09, поэтому фактические результаты будут соответствовать вашим ожиданиям.

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

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

cast(RES + 1e-10 as decimal(18,2))

Однако это все еще имеет проблемы с округлением, потому что невозможно различить значения, которые действительно равны 8.08499999999 (и должны быть округлены до 8.08),и значения, где результат расчета просто 8.08499999999 в плавающей запятой, в то время как в точных цифрах это будет 8,085 (и поэтому необходимо округлить до 8,09).

В аналогичном ключе вы можете попытаться использовать двойное приведение к decimal (например, cast(cast(res as decimal(18,3)) as decimal(18,2))) или приведение decimal, а затем округление (например, round(cast(res as decimal(18,3)), 2). Это было бы немного более последовательным, чем двойное округление, потому что первое приведение преобразуется в точные числовые значения, но опять-таки это имеет аналогичный недостаток, как упомянуто выше.

Хотя вы не хотите слышать этот ответ, если хотитеточная числовая семантика, вы не должны использовать типы с плавающей точкой.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...