Реалистичный пример, где использование 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.