Реалистичный пример, где использование BigDecimal для валюты строго лучше, чем использование double
Мы знаем, что использование double
для валюты подвержено ошибкам и не рекомендуется. Тем не менее, я еще не видел пример реалистичный, где BigDecimal
работает, пока double
терпит неудачу и не может быть просто исправлен некоторым округлением.
Заметим, что тривиальные задачи
double total = 0.0;
for (int i = 0; i < 10; i++) total += 0.1;
for (int i = 0; i < 10; i++) total -= 0.1;
assertTrue(total == 0.0);
не учитываются, поскольку они тривиально решаются путем округления (в этом примере все должно быть от нуля до шестнадцати знаков после запятой).
Вычисления с суммированием больших значений могут потребовать некоторого промежуточного рутинга, но при условии, что общая сумма валюты равна USD 1e12
, Java double
( т.е. стандартная двойная точность IEEE) с ее 15 десятичными цифрами по-прежнему является достаточным событием для центов.
Вычисления с делением вообще неточны даже при BigDecimal
. Я могу построить вычисление, которое не может быть выполнено с помощью double
s, но может быть выполнено с помощью BigDecimal
с использованием шкалы 100, но это не то, с чем вы можете столкнуться в действительности.
Я не утверждаю, что такого реалистического примера не существует, просто я этого еще не видел.
Я также уверен, что использование double
более подвержено ошибкам.
Пример
То, что я ищу, - это метод, подобный следующему (на основе ответа Роланда Иллига)
/**
* Given an input which has three decimal places,
* round it to two decimal places using HALF_EVEN.
*/
BigDecimal roundToTwoPlaces(BigDecimal n) {
// To make sure, that the input has three decimal places.
checkArgument(n.scale() == 3);
return n.round(new MathContext(2, RoundingMode.HALF_EVEN));
}
вместе с тестом вроде
public void testRoundToTwoPlaces() {
final BigDecimal n = new BigDecimal("0.615");
final BigDecimal expected = new BigDecimal("0.62");
final BigDecimal actual = roundToTwoPlaces(n);
Assert.assertEquals(expected, actual);
}
Когда это становится наивно переписанным с помощью double
, тогда тест может потерпеть неудачу (он не для данного ввода, но для других). Однако это можно сделать правильно:
static double roundToTwoPlaces(double n) {
final long m = Math.round(1000.0 * n);
final double x = 0.1 * m;
final long r = (long) Math.rint(x);
return r / 100.0;
}
Он уродливый и подверженный ошибкам (и, вероятно, может быть упрощен), но его можно легко инкапсулировать где-то. Вот почему я ищу больше ответов.
Ответы
Ответ 1
Я вижу четыре основных способа, которыми double
может вас обмануть при работе с валютными расчетами.
Мантисса слишком маленькая
С ~ 15 десятичными цифрами точности в мантиссе вы получите неправильный результат, когда будете иметь дело с суммами, превышающими эту. Если вы отслеживаете центы, проблемы начнут возникать до 10 13 (десяти триллионов) долларов.
Хотя это большое число, оно не такое большое. ВВП США на 18 триллионов долларов превышает его, поэтому все, что касается стран или даже размеров корпораций, может легко дать неправильный ответ.
Кроме того, существует множество способов, которыми намного меньшие суммы могут превышать этот порог во время расчета. Возможно, вы делаете прогноз роста или на несколько лет, что приводит к большой конечной стоимости. Возможно, вы выполняете анализ сценария "что если", в котором рассматриваются различные возможные параметры, и некоторая комбинация параметров может привести к очень большим значениям. Возможно, вы работаете в соответствии с финансовыми правилами, которые допускают доли цента, которые могут отбить еще два порядка или более от вашего диапазона, что примерно соответствует уровню богатства простых людей в долларах США.
Наконец, давайте не будем ориентироваться на вещи в США. А как насчет других валют? Один доллар США стоит приблизительно 13 000 индонезийских рупий, так что еще на 2 порядка вам нужно отслеживать суммы в валюте в этой валюте (при условии, что нет "центов"!). Вы почти сводитесь к суммам, которые представляют интерес для простых смертных.
Вот пример, где расчет прогноза роста, начинающийся с 1e9 при 5%, идет не так:
method year amount delta
double 0 $ 1,000,000,000.00
Decimal 0 $ 1,000,000,000.00 (0.0000000000)
double 10 $ 1,628,894,626.78
Decimal 10 $ 1,628,894,626.78 (0.0000004768)
double 20 $ 2,653,297,705.14
Decimal 20 $ 2,653,297,705.14 (0.0000023842)
double 30 $ 4,321,942,375.15
Decimal 30 $ 4,321,942,375.15 (0.0000057220)
double 40 $ 7,039,988,712.12
Decimal 40 $ 7,039,988,712.12 (0.0000123978)
double 50 $ 11,467,399,785.75
Decimal 50 $ 11,467,399,785.75 (0.0000247955)
double 60 $ 18,679,185,894.12
Decimal 60 $ 18,679,185,894.12 (0.0000534058)
double 70 $ 30,426,425,535.51
Decimal 70 $ 30,426,425,535.51 (0.0000915527)
double 80 $ 49,561,441,066.84
Decimal 80 $ 49,561,441,066.84 (0.0001678467)
double 90 $ 80,730,365,049.13
Decimal 90 $ 80,730,365,049.13 (0.0003051758)
double 100 $ 131,501,257,846.30
Decimal 100 $ 131,501,257,846.30 (0.0005645752)
double 110 $ 214,201,692,320.32
Decimal 110 $ 214,201,692,320.32 (0.0010375977)
double 120 $ 348,911,985,667.20
Decimal 120 $ 348,911,985,667.20 (0.0017700195)
double 130 $ 568,340,858,671.56
Decimal 130 $ 568,340,858,671.55 (0.0030517578)
double 140 $ 925,767,370,868.17
Decimal 140 $ 925,767,370,868.17 (0.0053710938)
double 150 $ 1,507,977,496,053.05
Decimal 150 $ 1,507,977,496,053.04 (0.0097656250)
double 160 $ 2,456,336,440,622.11
Decimal 160 $ 2,456,336,440,622.10 (0.0166015625)
double 170 $ 4,001,113,229,686.99
Decimal 170 $ 4,001,113,229,686.96 (0.0288085938)
double 180 $ 6,517,391,840,965.27
Decimal 180 $ 6,517,391,840,965.22 (0.0498046875)
double 190 $ 10,616,144,550,351.47
Decimal 190 $ 10,616,144,550,351.38 (0.0859375000)
Дельта (разница между double
и BigDecimal
впервые достигла> 1 цента в 160 году, около 2 триллионов (что может быть не так уж много через 160 лет), и, конечно, только продолжает ухудшаться.
Конечно, 53 бита Mantissa означают, что относительная погрешность для такого рода расчетов, вероятно, будет очень мала (надеюсь, вы не потеряете свою работу более чем на 1 цент из 2 триллионов). Действительно, относительная ошибка в основном остается достаточно устойчивой в большинстве примеров. Вы можете, конечно, организовать это так, чтобы вы (например) вычитали два различных с потерей точности в мантиссе, что приводило к сколь угодно большой ошибке (упражнение до читателя).
Изменение семантики
Таким образом, вы думаете, что вы достаточно умны, и вам удалось придумать схему округления, которая позволит вам использовать double
и полностью протестировать ваши методы на вашей локальной JVM. Идите вперед и разверните его. Завтра или на следующей неделе, или когда вам будет хуже, результаты меняются, а ваши уловки ломаются.
В отличие от почти всех других выражений базового языка и, конечно, от целочисленной или арифметики BigDecimal
, по умолчанию результаты многих выражений с плавающей запятой не имеют единого стандартного значения из-за функции strictfp. Платформы могут по своему усмотрению использовать промежуточные звенья с более высокой точностью, что может привести к разным результатам на разных аппаратных средствах, версиях JVM и т.д. Результат для одних и тех же входных данных может даже меняться во время выполнения, когда метод переключается с интерпретированного на JIT-скомпилированный!
Если бы вы написали свой код в предшествующие Java 1.2 дни, вы бы очень разозлились, когда в Java 1.2 неожиданно появилось поведение переменной FP по умолчанию. У вас может возникнуть соблазн просто использовать strictfp
везде и надеяться, что вы не столкнетесь ни с одним из множества связанных ошибок - но на некоторых платформах вы потеряете большую часть производительности, которая была куплена вдвое. Вы в первую очередь.
Нет ничего, что говорило бы о том, что спецификация JVM не изменится в будущем, чтобы приспособиться к дальнейшим изменениям в оборудовании FP, или что разработчики JVM не будут использовать веревку, которую стандартное поведение non-strictfp дает им, чтобы сделать что-то хитрое.
Неточные представления
Как отметил Роланд в своем ответе, ключевая проблема с double
состоит в том, что он не имеет точных представлений для некоторых нецелых значений. Хотя одно неточное значение, такое как 0.1
, в некоторых сценариях (например, Double.toString(0.1).equals("0.1")
) часто будет "в прямом и обратном направлении", но как только вы вычислите эти неточные значения, ошибка может сложиться, и это может быть неустранимо.
В частности, если вы "близки" к точке округления, например ~ 1,005, вы можете получить значение 1,00499999..., если истинное значение равно 1,0050000001..., или наоборот. Поскольку ошибки идут в обоих направлениях, нет заклинаний, которые могли бы это исправить. Невозможно определить, следует ли увеличить значение 1,004999999... или нет. Ваш метод roundToTwoPlaces()
(тип двойного округления) работает только потому, что он обрабатывал случай, когда 1.0049999 должен быть увеличен, но он никогда не сможет пересечь границу, например, если из-за кумулятивных ошибок 1.0050000000001 превратится в 1.00499999999999. не могу это исправить.
Вам не нужны большие или маленькие цифры, чтобы поразить это. Вам нужно всего лишь немного математики, чтобы результат приблизился к границе. Чем больше математики вы делаете, тем больше возможных отклонений от истинного результата и тем больше шансов преодолеть границу.
Как и было запрошено здесь, поисковый тест, который выполняет простой расчет: amount * tax
и округляет его до 2 десятичных знаков (то есть долларов и центов). Там есть несколько методов округления, один из которых в настоящее время используется, roundToTwoPlacesB
является вашей верной версией 1 (увеличивая множитель для n
в первом округлении, вы делаете его намного более чувствительным - оригинальная версия сразу дает сбой на тривиальных входах).
Тест выплевывает обнаруженные ошибки, и они приходят группами. Например, первые несколько сбоев:
Failed for 1234.57 * 0.5000 = 617.28 vs 617.29
Raw result : 617.2850000000000000000000, Double.toString(): 617.29
Failed for 1234.61 * 0.5000 = 617.30 vs 617.31
Raw result : 617.3050000000000000000000, Double.toString(): 617.31
Failed for 1234.65 * 0.5000 = 617.32 vs 617.33
Raw result : 617.3250000000000000000000, Double.toString(): 617.33
Failed for 1234.69 * 0.5000 = 617.34 vs 617.35
Raw result : 617.3450000000000000000000, Double.toString(): 617.35
Обратите внимание, что "необработанный результат" (то есть точный необоснованный результат) всегда близок к границе x.xx5000
. Ваш метод округления ошибочен как на высоких, так и на низких сторонах. Вы не можете исправить это в общем.
Неточные расчеты
Некоторые из методов java.lang.Math
не требуют правильно округленных результатов, но допускают ошибки до 2,5 ulp. Конечно, вы, вероятно, не собираетесь использовать гиперболические функции с валютой, но такие функции, как exp()
и pow()
, часто находят применение в расчетах валюты, и они имеют точность только 1 ulp. Таким образом, номер уже "неправильный", когда он возвращается.
Это связано с проблемой "неточного представления", поскольку этот тип ошибки гораздо серьезнее, чем из обычных математических операций, которые по крайней мере выбирают наилучшее возможное значение из представляемой области double
. Это означает, что при использовании этих методов вы можете иметь гораздо больше событий пересечения границы.
Ответ 2
Когда вы округлите double price = 0.615
до двух знаков после запятой, вы получите 0,61 (округленное вниз), но, вероятно, ожидаете 0,62 (округленное вверх, из-за 5).
Это связано с тем, что double 0.615 фактически равен 0.6149999999999999911182158029987476766109466552734375.
Ответ 3
Основные проблемы, с которыми вы сталкиваетесь на практике, связаны с тем, что round(a) + round(b)
не обязательно равно round(a+b)
. Используя BigDecimal
, вы имеете прекрасный контроль над процессом округления и, следовательно, можете сделать свои суммы правильными.
Когда вы вычисляете налоги, скажем, 18% НДС, легко получить значения, которые имеют более двух десятичных знаков, если они представлены точно. Таким образом, округление становится проблемой.
Давайте предположим, что вы покупаете 2 статьи за $1.3 каждый
Article Price Price+VAT (exact) Price+VAT (rounded)
A 1.3 1.534 1.53
B 1.3 1.534 1.53
sum 2.6 3.068 3.06
exact rounded 3.07
Итак, если вы выполняете вычисления с двойным и единственным раундом, чтобы распечатать результат, вы получите в общей сложности 3.07, тогда как сумма на счете должна быть 3.06.
Ответ 4
Давайте дадим здесь "менее технический, более философский" ответ: почему вы думаете, что "Cobol" не использует арифметику с плавающей запятой при работе с валютой?!
( "Cobol" в кавычках, как в: существующие устаревшие подходы к решению бизнес-задач реального мира).
Значение: почти 50 лет назад, когда люди начали использовать компьютеры для бизнеса, а также финансовую работу, они быстро поняли, что представление "с плавающей запятой" не будет работать для финансовой отрасли (возможно, ожидаются некоторые редкие нишевые углы, как указано в вопросе).
И имейте в виду: тогда абстракции были действительно дорогими! Это было достаточно дорого, чтобы немного поиграть здесь и зарегистрироваться; и все же это быстро становится очевидным для гигантов, на чьих плечах мы стоим... что использование "плавающих точек" не решит их проблемы; и что им приходится полагаться на что-то другое; более абстрактные - дороже!
В нашей отрасли было более 50 лет, чтобы придумать "с плавающей точкой, которая работает для валюты", и общий ответ по-прежнему: не делайте этого. Вместо этого вы переходите к таким решениям, как BigDecimal.
Ответ 5
Вам не нужен пример. Вам просто нужна математика четвертого класса. Фракции в плавающей запятой представлены в двоичном базисе, а бинарный радиус несоизмерим с десятичным основанием. Десятый класс.
Поэтому всегда будет округление и аппроксимация, и ни один из них не является приемлемым в бухгалтерском учете каким-либо образом, формой или формой. Книги должны балансировать до последнего процента, и поэтому FYI делает филиал банка в конце каждого дня, а весь банк через регулярные промежутки времени.
выражение, страдающее от ошибок округления, не считается "
Смешной. Это проблема. Исключение ошибок округления исключает всю проблему.
Ответ 6
Предположим, что у вас есть 1000000000001.5 (это в диапазоне 1e12). И вы должны рассчитать 117% от него.
В двойном, он становится 1170000000001.7549 (это число уже неточно). Затем примените свой круглый алгоритм, и он станет 1170000000001.75.
В точной арифметике она становится 1170000000001.7550, которая округляется до 1170000000001.76. Ой, вы потеряли 1 цент.
Я думаю, что это реалистичный пример, где double уступает точной арифметике.
Конечно, вы можете это исправить (даже, вы можете реализовать BigDecimal, используя двойную арифметику, поэтому в двоичном коде можно использовать все, и это будет точно), но какая точка?
Вы можете использовать double для целочисленной арифметики, если числа меньше 2 ^ 53. Если вы можете выразить свою математику в рамках этих ограничений, то расчет будет точным (разделение требует особого ухода, конечно). Как только вы покинете эту территорию, ваши расчеты могут быть неточными.
Как вы можете видеть, 53 бит недостаточно, double недостаточно. Но, если вы храните деньги в десятичной фиксированной точке (я имею в виду, сохраните номер money*100
, если вам нужна точность центов), тогда может быть достаточно 64 бит, поэтому вместо .
Ответ 7
Использование BigDecimal было бы наиболее необходимо при работе с высокоценными цифровыми формами валюты, такими как cyprtocurrency (BTC, LTC и т.д.), запасами и т.д. В подобных ситуациях много раз вы будете иметь дело с очень конкретными значениями на 7 или 8 значащих цифр. Если ваш код случайно вызывает ошибку округления на рис. 3 или 4 сиг, то потери могут быть чрезвычайно значительными. Потеря денег из-за ошибки округления не будет забавой, особенно если это для клиентов.
Конечно, возможно, вам удастся использовать Double для всех, если вы обязательно сделаете все правильно, но, вероятно, лучше не рисковать, особенно если вы начинаете с нуля.
Ответ 8
Нижняя строка вверх:
Простой реалистичный пример, когда double
не работает:
Все более крупные числовые типы могут быть отлично смоделированы меньшими численными типами, используя списки меньших типов номеров и сохраняя запись таких вещей, как знак и десятичное место. Таким образом, числовой тип только терпит неудачу при его использовании, что соответствует более высокой сложности кода и/или более медленной скорости.
BigDecimal
не сильно снижает сложность кода, когда вы знаете, как обрабатывать умножения и деления double
во избежание недополнения. Однако могут быть ситуации, когда BigDecimal
потенциально быстрее, чем double
.
Однако не должно быть случая, когда он strictly
лучше (в математическом смысле), чем двойной. Зачем? поскольку вычисления double
реализованы как единичные операции в современных процессорах (в течение одного цикла), и поэтому любой эффективный расчет с плавающей запятой большой точности, по своему усмотрению, использует какой-то тип double-esque
числовой тип или медленнее, чем оптимальный,
Другими словами, если double - это кирпич, BigDecimal представляет собой стек кирпичей.
Итак, сначала определите, что означает "плохое" в контексте "double
, плохо для финансового анализа".
A double
число с плавающей запятой - это список двоичных состояний. Таким образом, если все, к чему вы имели доступ, были классами и 32-битными целыми числами, вы могли бы "воссоздать" a double
просто путем записи позиции десятичного знака, знака и т.д. И сохранения списка целых чисел.
Недостатком этого процесса является то, что у вас будет гораздо более сложная и багровая база кода для управления этим. Кроме того, a double
равен размеру слова 64-битного процессора, поэтому вычисления будут медленнее с вашим классом, содержащим список целых чисел.
Теперь компьютеры очень быстрые. И если вы не пишете неаккуратный код, вы не заметите разницу между double
и вашим классом со своим списком целых чисел для операций O (n) (один для цикла).
Таким образом, главная проблема здесь - сложность написания кода (сложность использования, чтение и т.д.).
Поскольку сложность кода является основной проблемой, рассмотрите финансовую ситуацию, в которой вы умножаете доли во много раз.
Это может привести к underflow, что является ошибкой округления, о которой вы говорите.
Исправление для нижнего потока - это взять журнал:
// small numbers a and b
double a = ...
double b = ...
double underflowed_number = a*pow(b,15); // this is potentially an inaccurate calculation.
double accurate_number = pow(e,log(a) + 15*log(b)); // this is accurate
Теперь возникает вопрос: слишком ли сложна для вас сложность кода?
Или, еще лучше: слишком ли сложно справляться со своими коллегами? Кто-нибудь придет и скажет: "Вау, это выглядит действительно неэффективно, я просто верну его обратно к a*pow(b,15)
"?
Если да, то просто используйте BigDecimal
; иначе: double
будет, за исключением вычисления нижнего потока, иметь более легкий вес с точки зрения использования и синтаксиса... и сложность письменного кода на самом деле не такая большая сделка.
С одной оговоркой: если вы выполняете значительные вычисления с использованием обходного пути underflow в сложном вычислительном параметре, например, в вложенном цикле для какой-либо внутренней подпрограммы, запущенной на заднем конце банка, вы должны проверить с помощью BigDecimal
, так как это может быть быстрее.
Итак, ответ на ваш вопрос:
// at some point, for some large_number this *might* be slower,
// depending on hardware, and should be tested:
for (i=1; i<large_number; i++){
for(j=1;j<large_number;j++){
for(k=1;k<large_number;k++){
// switched log to base 2 for speed
double n = pow(2,log2(a) + 15*log2(b));
}
}
}
// this *might* be faster:
for (i=1; i<large_number; i++){
for(j=1;j<large_number;j++){
for(k=1;k<large_number;k++){
BigDecimal n = a * pow(b,15);
}
}
}
Я добавлю асимптотический сюжет, если у меня будет время.
Ответ 9
Следующее выглядело бы достойной реализацией метода, который должен был "округлить до ближайшего копейки".
private static double roundDowntoPenny(double d ) {
double e = d * 100;
return ((int)e) / 100.0;
}
Однако вывод следующего показывает, что поведение не совсем то, что мы ожидаем.
public static void main(String[] args) {
System.out.println(roundDowntoPenny(10.30001));
System.out.println(roundDowntoPenny(10.3000));
System.out.println(roundDowntoPenny(10.20001));
System.out.println(roundDowntoPenny(10.2000));
}
Вывод:
10.3
10.3
10.2
10.19 // Not expected!
Конечно, может быть написан метод, который дает желаемый результат. Проблема в том, что на самом деле это очень сложно сделать (и делать это в любом месте, где вам нужно манипулировать ценами).
Для каждой числовой системы (base-10, base-2, base-16 и т.д.) с конечным числом цифр существуют рациональные методы, которые нельзя точно хранить. Например, 1/3 не может быть сохранена (с конечными цифрами) в базе-10. Аналогично, 3/10 не может быть сохранено (с конечными цифрами) в базе-2.
Если нам нужно было выбрать цифровую систему для хранения произвольных рациональных значений, неважно, какая система мы выбрали - любая выбранная система имела бы некоторые рациональности, которые не могли быть сохранены точно.
Однако люди начали присваивать цены вещам до развития компьютерных систем. Поэтому мы видим, что цены, например, 5,30, составляют 5 + 1/3. Например, наши фондовые биржи используют десятичные цены, что означает, что они принимают заказы и выдают котировки только в ценах, которые могут быть представлены в базе-10. Точно так же это означает, что они могут выдавать кавычки и принимать заказы по ценам, которые не могут быть точно представлены в базе-2.
Сохраняя (передавая, обрабатывая) эти цены в базе-2, мы в основном полагаемся на логику округления, чтобы всегда правильно округлять наши (в точном) базовом-2 (представление) чисел обратно к их (точной) базе- 10.