Как справиться с проблемами точности чисел с плавающей запятой?

Я использую 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) для восстановления внутреннего представления. Я готов пойти с этим решением, но мой вопрос в том, является ли это приемлемым обходным путем (когда внешний ОКРУГ имеет строго менее 5 знаков после запятой) или есть лучший обходной путь.

Все мои тесты проходят с этим обходным путем, но ощущение плохое.

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


person TomR    schedule 15.10.2019    source источник


Ответы (1)


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

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

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)). Это будет немного более последовательно, чем двойное округление, потому что первое преобразование будет преобразовано в точные числа, но опять же, это имеет тот же недостаток, что и упомянутый выше.

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

person Mark Rotteveel    schedule 15.10.2019