Як використовувати BigDecimal в Java
Суть проблеми
Коли ми почали створювати послуги General Ledger для бухгалтерського обліку, то виявили, що в багатьох місцях були помилки в 0.01 або більше відсотків. Це робить облік грошей практично неможливим. Хто хотів би виставити рахунок клієнту на $4.01, коли у його замовленні $4.00?
Причини цих помилок стали зрозумілі досить швидко: розрахунки, які містять суми, кількості, корективи, і багато інших речей зазвичай виконуються з малим або взагалі без уваги до особливостей проблем точності та округлення, що виникають під час роботи з фінансами.
Всі ці обчислення використовували тип Java double , який не пропонує способу керування заокругленням чи обмеження точності в розрахунках. Ми прийшли до рішення, що включає використання java.math.BigDecimal, що дає нам керувати всім цим.
Приклад проблем під час обліку фінансів
Грошові розрахунки вимагають точності в заданій мірі, наприклад, для більшості валют це два знаки після коми. Вони також вимагають певного типу поведінки під час округлення, наприклад, у разі податків завжди виконувати округлення у більшу сторону.
Наприклад, припустимо, що у нас є продукт, який коштує 10.00 у заданій валюті та місцевий податок з продажу 0.0825, або 8.25%. Якщо порахувати податок на папері, сума буде:
10.00*0.0825 = 0.825
Оскільки точність розрахунку для цієї валюти дві цифри після коми, потрібно заокруглити число 0.825. Крім того, оскільки це податок, звичайною практикою є постійне округлення до центу у бік. Таким чином, після розрахунку балансу за рахунками наприкінці дня ми ніколи не отримаємо недоплати податків.
І таким чином клієнту виставляється загальний рахунок у сумі 10.83 у місцевій валюті, а збирачеві податків виплачується 0.83.Зверніть увагу, що якщо продати 1000 таких продуктів, то переплата збиральнику податків була б:
1000* (0.83 - 0.825) = 5.00
Інше важливе питання: де робити округлення у цьому розрахунку. Припустимо, рідкий азот продається за ціною 0,528361 за літр. Клієнт приходить та купує 100.00 літрів, тому порахуємо повну ціну:
100.0*0.528361 = 52.8361
Так як це не податок, можна округлити цю цифру вгору або вниз на власний розсуд. Припустимо, округлення виконується відповідно до стандартних правил округлення: якщо наступна цифра менше 5, округляємо в меншу сторону. В іншому випадку округляємо вгору. Це дає остаточної ціни значення 52.84.
Розрахунок 1: 52.8361 * 0.95 = 50.194295 = 50.19
Розрахунок 2: 52.84 * 0.95 = 50.198 = 50.20
Зауважте, що остаточна цифра округлена за стандартним правилом округлення.
Бачите різницю в один цент між двома цифрами? Старий код не турбувався про взяття до уваги округлення, тому він завжди робив обчислення як у Розрахунку 1. Але в новому коді перед розрахунком знижок, податків та всього іншого спочатку виконується округлення, як і в Розрахунку 2. Це одна з головних причин для помилки у один цент.
Знайомство з BigDecimal
З прикладів у попередньому розділі має стати зрозумілим, що потрібні дві речі:
1. Можливість задати масштаб, який є кількістю цифр після десяткової точки
2. Можливість задати метод округлення
Клас java.math.BigDecimal бере до уваги обидва ці міркування. Дивіться документацію по BigDecimal.
Створити BigDecimal із (скалярного) числа типу double просто:
bd = новий BigDecimal(1.0);
Щоб створити BigDecimal із Double, спочатку скористайтесьметодом doubleValue() .
Однак найкращою ідеєю є створення з рядка:
bd = новий BigDecimal("1.5");
Якщо цього не зробити, то результат буде таким:
bd = новий BigDecimal(1.5);
Округлення та масштабування
Щоб встановити кількість цифр після коми, використовуйте метод .setScale(scale). Проте, гарною практикою є одночасне вказівку разом із масштабом режиму округлення за допомогою .setScale(scale, roundingMode) . Режим округлення визначає правило округлення числа.
Чому бажано вказати режим заокруглення? Давайте як приклад використовуємо наведений вище BigDecimal з 1.5:
bd = новий BigDecimal(1.5); // насправді 1.4999.
bd.setScale(1); // отримуємо виняток ArithmeticException
Буде згенеровано виняток, тому що не відомо, як округлити 1.49999. Тому завжди використовувати .setScale(scale, roundingMode) - це хороша ідея.
Є вісім варіантів режиму заокруглення:
ROUND_CEILING: У більшу сторону
ROUND_DOWN: Відкидання розряду
ROUND_FLOOR: У менший бік
ROUND_HALF_UP: Округлення вгору, якщо число після коми >= .5
ROUND_HALF_DOWN: Округлення вгору, якщо число після коми > .5
Округлення половини парності округляє як завжди. Однак, коли округлена цифра 5, округлення йтиме вниз, якщо цифра зліва від 5 парна і вгору, якщо непарна. Це найкраще ілюструється прикладом:
a = new BigDecimal("2.5"); // цифра зліва від 5 парна, тому округлення вниз
b = новий BigDecimal("1.5"); // цифра зліва від 5 непарна, тому округлення нагору
a.setScale(0, BigDecimal.ROUND_HALF_EVEN).toString() // => 2
b.setScale(0, BigDecimal.ROUND_HALF_EVEN).toString()// => 2
Документація Java говорить про ROUND_HALF_EVEN так: зверніть увагу, що це такий режим округлення, який зводить до мінімуму сукупну помилку, коли при виконанні послідовності обчислень постійно виконується округлення.
Використовуйте ROUND_UNNECESSARY, коли необхідно використовувати один із методів, який вимагає введення режиму округлення, але відомо, що результат округляти не треба.
При розподілі BigDecimals будьте обережні і вказуйте спосіб заокруглення методом .divide(. ) . В іншому випадку можна отримати ArithmeticException , якщо немає точного заокругленого результуючого значення, наприклад, 1/3. Таким чином, завжди слід робити так:
a = b.divide(c, decimals, rounding);
Незмінність та арифметика
Числа BigDecimal є незмінними. Це означає, що якщо створюється новий об'єкт BigDecimal зі значенням "2.00", такий об'єкт залишиться "2.00" і ніколи не може бути змінено.
То як тоді виконуються математичні розрахунки? Методи .add() , .multiply() та інші повертають новий об'єкт BigDecimal , що містить результат. Наприклад, щоб отримати результат при розрахунку суми:
amount = amount.add( thisAmount );
Переконайтеся, що ви не робите так:
Це найпоширеніша помилка при роботі з BIGDECIMAL!
Важливо ніколи не використовувати для порівняння метод BigDecimal .equals() . Цього не можна робити тому, що функція equals порівнюватиме масштаби. Якщо масштаби різняться, .equals() поверне брехню, навіть якщо вони математично рівні:
BigDecimal a = new BigDecimal("2.00");
BigDecimal b = New BigDecimal("2.0");
Натомість слід використовувати методи .compareTo() і .signum() .
a.compareTo(b); // Повертає (-1 якщо a b)
a.signum(); // Повертає (-1 якщо a 0)
Де робити округлення: роздуми про точність
Тепер, коли можна управляти округленням розрахунку, до якого знака слід округляти? Відповідь залежить від того, як планується використати отримане число.
Вам відома необхідна точність кінцевого результату потреб користувачів. Для чисел, які будуть складатися і відніматися для отримання кінцевого результату, необхідно додати ще один десятковий розряд, так що сума 0.0144 + 0.0143 буде заокруглена до 0.03, тоді як якщо округлення виконується до 0.01, результатом буде 0.02.
Якщо необхідні числа, які будуть множитись для отримання кінцевого результату, необхідно зберігати стільки знаків після коми, скільки можливо. Наприклад, коефіцієнти та питомі витрати не повинні округлятися. Після множення необхідно округляти кінцевий результат.