От float до JSR-354. Как правильно работать с денежными величинами в Java

Большая часть корпоративных приложений оперирует денежными суммами. Деньги обычно представлены двумя частями: числовым значением и валютой. Мы имеем дело с деньгами в наших программах каждый день, но JDK, к сожалению, не содержит стандартных типов предназначенных для работы с ними. Нам разработчикам необходимо знать, какой тип данных подходит для представления денежных величин. Попробуем с этим разобраться.

1. float и double

Первое что приходит на ум – использовать типы с плавающей точкой (float и double). Однако, Джошуа Блох, автор Effective Java, не рекомендует использовать эти типы данных, если требуются точные значения.

Дело в том, что типы float и double предназначены в первую очередь для научных и инженерных расчетов. Они реализуют двоичную арифметику с плавающей точкой, тщательно спроектированную для быстрого получения правильного приближения для широкого диапазона значений. Эти типы не дают точных результатов и поэтому не годятся для денежных расчетов:

double val = 1.03 - .42;
System.out.println(val);   // 0.6100000000000001

Вместо float и double Джошуа рекомендует использовать BigDecimal, а также целочисленные типы int и long. Рассмотрим оба эти варианта.

2. int и long

При использовании примитивных целочисленных типов денежные величины всегда нужно преобразовывать до самых низких денежных единиц (например, перевести всю сумму в копейки). И все арифметические операции производить с копейками. Однако этот подход кому-то может показаться неудобным – нужно всегда отслеживать положение десятичной точки.

public class Product {
   private String name; 
   private int money; 

   // constructors
   // getters and setters 
}

Product milk = new Product("milk", 3550);       // сумма в копейках (35.50 р)
Product tomato = new Product("tomato", 7990);   // сумма в копейках (79.90 р)
int sum = milk.getMoney() + tomato.getMoney();  

Преимущества:

  • Хорошая производительность при выполнении арифметических операций.

Недостатки:

  • Нужно отслеживать положение десятичной точки;
  • Обрабатываемые значения не должны быть слишком большими (учитываем максимально возможные значения для типов int и long).

3. BigDecimal

Самый простой вариант – использовать BigDecimal вместо double:

public class Product {
   private String name; 
   private BigDecimal money; 

   // constructors
   // getters and setters
}

Product milk = new Product("milk", BigDecimal.valueOf(35.50));
Product tomato = new Product("tomato", BigDecimal.valueOf(79.90));
BigDecimal sum = milk.getMoney().add(tomato.getMoney()); 

Преимущества:

  • Полный контроль над округлением. Можно выбрать один из восьми режимов округления. Это может пригодиться при выполнении финансовых вычислений с жестко заданным алгоритмом округления;
  • Не нужно отслеживать положение десятичной точки.

Недостатки:

  • Менее удобен в использовании, чем примитивные типы;
  • Существенно медленнее в арифметических операциях.

Хотя если не предполагается массовых расчетов, то последний недостаток не так важен.

Добавляем валюту

Вроде бы все хорошо, но есть один существенный фактор, отсутствующий в нашем решении, и это валюта. Если наша программа имеет дело с одной валютой, то всё – супер. Однако в большинстве случаев это не так. Поэтому числовое значение «79.90» не имеет смысла без указания валюты.

Самое простое, что мы можем сделать – добавить поле типа String для хранения значения валюты:

 public class Product {
   private String name; 
   private String currency; 
   private BigDecimal money; 

   // constructors
   // getters and setters
} 

Но будет ли это хорошим решением? Очевидно, что нет. Хотя бы потому, что оно не типобезопасно. Строка не проверяется и в неё можно записать все что угодно, даже то, что не является допустимой валютой.

Давайте сделаем его типобезопасным, введя перечисление валют (enum). При этом мы должны не забывать про различные аспекты интернационализации, например, такие как ISO-4217.

public class Product {
   private String name; 
   private Currency currency;
   private BigDecimal value; 

   // constructors
   // getters and setters 
}

enum Currency {
   RUBLE, DOLLAR, EURO; 
} 

Кстати, в JDK есть класс java.util.Currency, который работает с ISO-4217. Валюты в нём идентифицируются по их кодам. Они могут быть представлены в коде двумя способами: трехбуквенным кодом и/или трехзначным цифровым кодом. Используя java.util.Currency мы можем получить следующую информацию:

  • Код валюты
  • Символ валюты или её краткое наименование         
  • Наименование валюты
  • Количество цифр после запятой
import java.util.Currency;

public class Product {
private String name;
private Currency currency;
private BigDecimal value;

// constructors
// getters and setters
}

Проверка валют очень важна, когда мы осуществляем арифметические операции над денежными величинами. Например, мы не можем суммировать цены на продукты, которые представлены в разных валютах:

Currency currency = Currency.getInstance(Locale.getDefault()); 
BigDecimal amount = new BigDecimal(0); 

Product milk = new Product("milk", BigDecimal.valueOf(35.50), currency);
Product tomato = new Product("tomato", BigDecimal.valueOf(79.90), currency);

if (milk.getCurrency().equals(tomato.getCurrency())) {
   amount = milk.getValue().add(tomato.getValue());
 } else {
   //exception
 }  

В этом случае мы могли бы ввести служебный класс, который отвечает за такие проверки:

public class ProductUtils { 
   public static BigDecimal sum(Product productA, Product productB) { 
      if (productA.getCurrency().equals(productB.getCurrency())) { 
        return productA.getValue().add(productB.getValue()); 
     } 

     throw new IllegalArgumentException("Несоответствие валют"); 
   } 
 } 

BigDecimal sum = ProductUtils.sum(milk, tomato); 

Но при этом необходимо учесть следующие моменты:

  • Мы должны заставить себя и наших коллег всегда использовать служебный класс для проверки и никогда не забывать об этом.
  • Мы должны определить служебные классы для различных служб или ввести общую абстракцию.
public class MoneyUtils {
public BigDecimal sum(Currency currencyA, BigDecimal valueA, Currency currencyB, BigDecimal valueB) {
//...
}
}

Добавление абстракции для представления денег становится всё более и более очевидным решением. Мартин Фаулер написал статью, описывающую абстракцию, которая представляет деньги и решает следующие проблемы:

  • Класс Money является единственным типом, который отвечает за работу с деньгами (принцип единственной ответственности).
  • Нет необходимости в служебных классах, потому что класс Money будет единственным классом ответственным за этот вид проверки.
public class Money { 
private Currency currency;
private BigDecimal value;

  // constructors
// methods
// getters and setters
}

Product milk = new Product("milk", new Money(35.50, ruble));
Product tomato = new Product("tomato ", new Money(79.90, ruble))
Money money = milk.getMoney().add(tomato.getMoney());

JSR-354 (Money and Currency API)

Отсутствие единого подхода при работе с денежными величинами и валютами в итоге привело к созданию спецификации стандартного Money and Currency API (JSR-354). Реализация спецификации должна предусматривать следующее:

  • Предоставление API для обработки и расчета денежных величин;
  • Определение классов представляющих валюты и денежные величины;
  • Округление и форматирование денежных величин;
  • Конвертация валют;

На сегодняшний день уже есть готовые реализации Money and Currency API по спецификации JSR-354. Информацию о готовых решениях можно найти, например, на javamoney.github.io

Давайте рассмотрим одну из реализаций java-money – библиотеку Moneta.

4. Moneta

Возьмем из maven-репозитория самую последнюю версию реализации. Добавим зависимость в наш проект и приступим к изучению возможностей библиотеки.

Основные классы в Moneta: Monetary, Money, FastMoney, а также интерфейсы CurrencyUnit и MonetaryAmount.

CurrencyUnit

CurrencyUnit моделирует свойства валюты. Его экземпляр можно получить через вызов фабричного метода Monetary.getCurrency().

Давайте посмотрим, какую информацию мы можем получить из свойств российского рубля:

public static void printCurrencyInfo() {
   CurrencyUnit ruble = Monetary.getCurrency("RUB");

   System.out.println("currencyCode: " + ruble.getCurrencyCode());
   System.out.println("numericCode: " + ruble.getNumericCode());
   System.out.println("defaultFractionDigits: " + ruble.getDefaultFractionDigits());
}

Результат:

currencyCode: RUB
numericCode: 643
defaultFractionDigits: 2

Если мы попытаемся передать в метод getCurrency() несуществующий трехбуквенный код валюты, то получим исключение UnknownCurrencyException:

private static void getUnknownCurrencyException() {
   CurrencyUnit ruble = Monetary.
getCurrency("ZZZ");
}

Результат:

Exception in thread "main" UnknownCurrencyException [currencyCode=ZZZ]

Такие ситуации, конечно же, нужно обрабатывать.

MonetaryAmount

MonetaryAmount – это числовое представление денежной величины. Он всегда связан с CurrencyUnit и определяет денежное представление валюты.

Классы Money и FastMoney реализуют интерфейс MonetaryAmount. Разница между ними в том, что они используют разные типы данных для числового представления денежных величин. FastMoney использует long, а Money – BigDecimal.Если нам важна производительность, то лучше воспользоваться классом FastMoney.

public static void  printMonetaryAmountStringified() {
   CurrencyUnit ruble = Monetary.getCurrency("RUB");
   MonetaryAmount monetaryAmount = Monetary.getDefaultAmountFactory()
         .setCurrency(ruble).setNumber(100).create();
   Money money = Money.of(50, ruble);
   FastMoney fastMoney = FastMoney.of(50, ruble);

   System.out.println("ruble: " + ruble.toString());
   System.out.println("monetaryAmount: " + monetaryAmount.toString());
   System.out.println("money: " + money.toString());
   System.out.println("fastMoney: " + fastMoney.toString());
}

Результат:

ruble: RUB
monetaryAmount: RUB 100.000000000000000000000000000000000000000000000000000000000000000
money: RUB 50

fastMoney: RUB 50.00000

Денежная арифметика

Мы можем выполнять сложение, вычитание, умножение, деление и другие денежные арифметические операции используя методы предоставляемые классом MonetaryAmount. Нужно учесть, что арифметические операции в некоторых ситуациях могут вызывать исключение ArithmeticException. Очевидно, что это может произойти, например, при попытке деления денежной величины на ноль:

public static void getArithmeticException() {
   MonetaryAmount ruble = Monetary.getDefaultAmountFactory()
         .setCurrency("RUB").setNumber(5).create();

   ruble.divide(0);
}

Результат:

Exception in thread "main" java.lang.ArithmeticException: BigInteger divide by zero

При сложении или вычитании сумм лучше использовать не сами значения, а параметры, которые являются экземплярами MonetaryAmount. В этом случае мы можем быть уверены в том, что операция не будет выполнена, если, например, суммы имеют разную валюту.

Расчет сумм

Общая сумма может быть рассчитана несколькими способами, один из которых – цепочки вызовов методов сложения:

public static void  printTotalAmount() {
   Money fiveRubles = Money.of(5.10, "RUB");
   Money tenRubles = Money.of(10.50, "RUB");
   Money twentyRubles = Money.of(20.30, "RUB");

   Money sumAmount = Money.of(0, "RUB");
   sumAmount = sumAmount.add(fiveRubles).add(tenRubles).add(twentyRubles);

   System.out.println("Общая сумма: " + sumAmount.toString());
}

Результат:

Общая сумма: RUB 35.9

Цепочки вызовов методов также могут содержать вычитания, умножения и деления.

Округление денежных сумм

Для округления денежных сумм воспользуемся методом getDefaultRounding класса Monetary:

public static void  printRoundedAmount() {
   Money ruble = Money.of(7.6886332, "RUB");
   MonetaryAmount roundedRuble = ruble.with(Monetary.getDefaultRounding());

   System.out.println("Начальная сумма: " + ruble.toString());
   System.out.println("Округленная сумма: " + roundedRuble.toString());
}

Результат:

Начальная сумма: RUB 7.6886332
Округленная сумма: RUB 7.69

Конвертация валют

Конвертация валют является важным аспектом работы с денежными средствами. К сожалению, эти преобразования имеют большое разнообразие различных реализаций и вариантов использования. API фокусируется на общих аспектах конвертации валют на основе исходной, целевой валюты и обменного курса. Конвертация валют или доступ к обменным курсам могут быть параметризованы:

public static void printExchangeRates() {
   Money oneDollar = Money.of(1, "USD");

   CurrencyConversion conversionRuble = MonetaryConversions.getConversion("RUB");

   Money ruble = oneDollar.with(conversionRuble);

   System.out.println(oneDollar.getCurrency().getCurrencyCode() + ": " + oneDollar.getNumber());
   System.out.println(ruble.getCurrency().getCurrencyCode() + ": " +  ruble.getNumber());
}

Результат:

USD: 1
RUB: 65.203045

Верно. Полученный результат полностью совпадает с данными курса валют, представленного на сайте Центрального банка РФ на 07.08.2019 г.

Форматирование денежных сумм

Форматирование позволяет получить доступ к форматам денежных единиц на основе региональных представлений с помощью java.util.Locale:

public static void  printAmountWithLocaleFormat() {
   Money oneRuble = Money.of(35.50, "RUB");

   MonetaryAmountFormat formatRu = MonetaryFormats.getAmountFormat(new Locale("ru"));
   String ruFormatted = formatRu.format(oneRuble);

   System.out.println("Начальное значение: " + oneRuble.toString());
   System.out.println("Отформатированное значение: " + ruFormatted);
}

Результат:

Начальное значение: RUB 35.5
Отформатированное значение: RUB 35,50

Более того, мы можем создавать свои собственные форматы для отображения денежных величин наших валют.  Для этого воспользуемся методом format класса MonetaryFormats. Ниже в примере мы определили наш собственный формат, задавая свойство pattern построителя форматов AmountFormatQueryBuilder:

public static void  printAmountWithCustomFormat() {
   Money oneRuble = Money.of(1, "RUB");

   MonetaryAmountFormat customFormat = MonetaryFormats.getAmountFormat(AmountFormatQueryBuilder.
            of(new Locale("ru")).set(CurrencyStyle.NAME).set("pattern", "00000.00 \u20BD").build());
   String customFormatted = customFormat.format(oneRuble);

   System.out.println(oneRuble.toString());
   System.out.println(customFormatted);
}

Результат:

RUB 1
00001,00 Р

Как мы видим, Moneta предоставляет достаточно большие возможности для оперирования денежными величинами и работы с валютой.

Заключение

В этой статье мы рассмотрели типы данных пригодные для оперирования денежными величинами, начиная с примитивных типов с плавающей точкой до полноценной реализации спецификации JSR-354 (Money and Currency API). Какой из них использовать – зависит от проекта. Если в приложении предусмотрена мультивалютность, то стоит присмотреться к реализациям JSR-354. Если в приложении все операции осуществляются только с одной валютой, то использование BigDecimal или long может оказаться вполне удовлетворительным решением.