Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Наша команда периодически пополняется новыми людьми, которые «приносят с собой» новые ошибки. Поэтому мы регулярно проводим семинары с их разбором. Это хороший повод напомнить всем о правилах работы с суммами, объяснить новичкам зачем они нужны и, возможно, пополнить наш чек-лист с помощью которого мы проверяем код на типовые ошибки.
Вот один из примеров, который на таком семинаре разобрал наш рукводитель разработки сервисной цифровой платформы Сергей Богданов.
Что не так с этим кодом?
@Component
class CreditTotalQtyCalculator {
fun calculateWithInsurance(
desiredAmount: BigDecimal,
rateQty: BigDecimal,
creditTerm: Int
): BigDecimal {
val divisor = BigDecimal.ONE.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(
BigDecimal(
12
)
))
return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
}
}
Почему он выдал ошибку на этапе прохождения тестирования перед выходом в пром?
import java.math.*;
public class Main {
public static void main(String[] args) {
var a = new BigDecimal(16);
var b = new BigDecimal(12);
var c = a.divide(b);
System.out.println(c);
}
}
Ошибка округления BigDecimal.Divide
Проблема вот в чем: не указана точность округления при делении divide (это, кстати, есть в нашем чек-листе: «не используем суммы в виде чисел с плавающей точкой»).
Любые вычисления должны приводить также к точному числу, но в делении может образоваться бесконечная дробь.Поэтому когда используем BigDecimal, мы должны указать, что результат должен быть с фиксированной точностью.
Иначе что делать машине, если при делении получается бесконечная дробь. Например, если в формуле 16/12 = 4/3 – рациональное число для которого не существует точной записи в десятичной системе. Это бесконечная дробь, такую запись невозможно сделать. Происходит арифметическое исключение в функции BigDecimal, в которой параметр scale — неограниченный.
В BigDecimal есть опасные методы, которые надо внимательно изучить, прежде чем использовать. Смотрим код дальше, что еще тут можно улучшить? Возник вопрос: зачем в этом коде делим на 12? Что это такое? Какая размерность? Зачем мы каждый раз это деление производим? Это вычисление можно вывести в переменную.
Решение: вводим переменную periodInMonth, описываем. Проверяем на условие положительности.
@Component
class CreditTotalQtyCalculator {
fun calculateWithInsurance(
desiredAmount: BigDecimal,
rateQty: BigDecimal,
creditTerm: Int
): BigDecimal {
val periodInMonths = BigDecimal(12)
val divisor = BigDecimal.ONE.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(periodInMonths))
return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
}
}
Размерность переменных
Важное правило при работе с денежными суммами: сумма — это не просто число, но и размерность. Рубли или копейки? Центы или доллары? Размерность должна включать валюту. Рядом с каждым значением должен быть указан тип и лучше «вэлью-объект», который содержит одновременно размерность суммы и валюты. И для каждой валюты используются своя размерность: для рублей — копейки, для биткоина — «сатоши». Она помогает понять, что делает метод, и не дает совершить ошибку (например, складывание рублей с долларами).
@Component
class CreditTotalQtyCalculator {
fun calculateWithInsurance(
desiredAmount: BigDecimal, // RUB
rateQty: BigDecimal, // ???
creditTerm: Int // ???
): BigDecimal { // 1 - (rateQty * creditTerm) / periodInMonths
val periodInMonth = BigDecimal(12)
val one = BigDecimal.ONE // ???
val divisor = one.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(
periodInMonth
))
return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
}
}
Здесь у 12 размерность — месяцы, у BigDecimal – рубли. А какая размерность у rateQty? А у creditTerm? Что за единица one?
В чек-листе разработчика есть правило: сумма всегда идет вместе с валютой. Для переменных можно завести класс или добавить к имени переменной слово, обозначающее единицы измерения. Например, у нас есть правило добавлять day/hour для времени.
Важно: сумма не должна быть отрицательной. Кредит на отрицательную сумму значит, что не банк выдает кредит клиенту, а наоборот. Поэтому сумма должна быть описана как положительная.
Пример про размерность из практики: процессинг части клиентов, с которыми работает наш платежный хаб — в рублях, а другой части – в копейках. Почему так? Во-первых, это «тянется» из стандарта ISO 8583, который используется в терминалах MS и Visa.
А во-вторых, когда системы создавались, не было альтернативы: не существовало чисел с фиксированной точкой, и нельзя было настроить точность и алгоритм округления. Поэтому самое простое решение — хранить в целых, в копейках
Вроде просто, но были и свои проблемы. К примеру, суммы больше 2^31 - 1 (больше 2 млрд копеек) не влезали в разрядность и приходилось создавать решение для округления.
Кстати. В нашем чек-листе есть правило, что все публичные классы и методы должны иметь комментарии, чтобы разобраться в коде мог любой. Сначала определяемся с задачей, описываем в комментариях как должен работать метод, а уже после этого корректируем реализацию.
Используем value0f для экономии
Еще можно добавить value0f — фабричный метод, который позволяет переиспользовать частое значение и экономить на этом память. value0f — это стандартный шаблон, который называется lightweight.
// Cache of common small BigDecimal values.
private static final BigDecimal ZERO_THROUGH_TEN[] = {
new BigDecimal(BigInteger.ZERO, 0, 0, 1),
new BigDecimal(BigInteger.ONE, 1, 0, 1),
new BigDecimal(BigInteger.TWO, 2, 0, 1),
new BigDecimal(BigInteger.valueOf(3), 3, 0, 1),
new BigDecimal(BigInteger.valueOf(4), 4, 0, 1),
new BigDecimal(BigInteger.valueOf(5), 5, 0, 1),
new BigDecimal(BigInteger.valueOf(6), 6, 0, 1),
new BigDecimal(BigInteger.valueOf(7), 7, 0, 1),
new BigDecimal(BigInteger.valueOf(8), 8, 0, 1),
new BigDecimal(BigInteger.valueOf(9), 9, 0, 1),
new BigDecimal(BigInteger.TEN, 10, 0, 2),
};
Точность – в настройки
Важный момент. Если вчера нам точность была не нужна, а сегодня вдруг понадобилась (было неопределенное значение, а теперь — 7), то можно предположить, что завтра значение может снова измениться. Поэтому надо заранее предусмотреть возможность такого изменения и вынести точность в настройки.
Потери округления — рефакторим формулы
Еще в этом коде есть два деления и каждое требует округления. Два округления – это потеря точности, чем округлений меньше, тем лучше. Надо преобразовать формулу так, чтобы округление было одно:
@Component
class CreditTotalQtyCalculator {
fun calculateWithInsurance(
desiredAmount: BigDecimal, // RUB
rateQty: BigDecimal, // ???
creditTerm: Int // ???
): BigDecimal { // 1 - (rateQty * creditTerm) / periodInMonths
val periodInMonths = BigDecimal(12)
val precision = 0
return desiredAmount
.multiply(periodInMonths)
.divide(periodInMonths.minus(rateQty.multiply(BigDecimal.valueOf(creditTerm.toLong()))),
precision, RoundingMode.HALF_UP)
}
}
Нам не нужно округление до 7, т.к. возвращаемый параметр с округлением до 0 десятых, т.к. вычисляется день.
Добавим про roundingMode.HALF_UP/EVEN/DOWN: в разных ситуациях округление может быть в определенную сторону. Чтобы и 5,5, и 6,5 не округлялось до 6, необходим отдельный параметр, описывающий каким должно быть это округление и почему.
Код валюты
В нашей стране есть еще легаси-код валюты — RUR (код 810) – обозначение нашей валюты до 29.02.2004. Именно до этого времени планировали полностью провести деноминацию и завершить работу со старой валютой. В начем чек-лите прописано, что код валюты должен быть RUB (643):
Тут важно правило: когда принимаете из другой системы старое обозначение, нужно принять его как есть, переработать и в новом коде выдать RUB. При этом надо быть готовым к тому, что в какой-то момент ту систему отключат, а ваш код должен работать и без нее.