TODO This is just draft right now. → Techlead discussion is expected.
Since we need high precision for our computation with money we decided to utilize java.math.BigDecimal.
Working with it is not that straightforward so that’s why this document exists.
BigDecimal
BigDecimal represents an immutable arbitrary-precision signed decimal number. It consists of two parts:
Unscaled value – an arbitrary precision integer
Scale – a 32-bit integer representing the number of digits to the right of the decimal point
For example, the BigDecimal 3.14 has the unscaled value of 314 and the scale of 2.
Creation
Problem
Doubles aren’t accurate but BigDecimals try to represent them as accurate as possible.
assert(BigDecimal(0.1) > BigDecimal("0.1")) // true
Reason
The results of BigDecimal(double val)
constructor can be somewhat unpredictable. One might assume that writing new BigDecimal(0.1)
in Java creates a BigDecimal
which is exactly equal to 0.1 (an unscaled value of 1, with a scale of 1), but it is actually equal to 0.1000000000000000055511151231257827021181583404541015625. This is because 0.1 cannot be represented exactly as a double
(or, for that matter, as a binary fraction of any finite length). Thus, the value that is being passed in to the constructor is not exactly equal to 0.1, appearances notwithstanding.
Solution
Use only BigDecimal(String val)
constructor.
If necessary to use double/float as source for BigDecimal use string conversion like
public static BigDecimal valueOf(double val)
Operations
Using operators
BigDecimal was designed to use methods rather then operators. With operators we can see some unexpected behavior:
For division the scale is taken from first argument.
println(BigDecimal("1.000") / BigDecimal("3.00")) // 0.333 println(BigDecimal("1.00") / BigDecimal("3.000")) // 0.33 println(BigDecimal("0.05") / BigDecimal("2")) // 0.02
For rest of the operations it seems fine.
println(BigDecimal("1.23") + BigDecimal("1.2345")) // 2.4645 println(BigDecimal("1.23") - BigDecimal("2.345")) // -1.115 println(BigDecimal("1.234") * BigDecimal("2.3")) // 2.8382
Division
Problem
BigDecimals have arbitrary-precision, but some number needs infinite precision.
BigDecimal("1").divide(BigDecimal("3"))
This will cause
java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
Reason
BigDecimal throws this exception in case it cannot represent the number exactly and it’s up to the programmer to solve the inexact representation.
Solution
Pass MathContext to the division in all cases. Especially when the numbers are parametrized.
BigDecimal("1").divide(BigDecimal("3"), MathContext.DECIMAL128)
or
BigDecimal("1").divide(BigDecimal("3"), MathContext(20, RoundingMode.HALF_UP))
20 in example above represents the number of significant digits.
MathContext
https://docs.oracle.com/javase/8/docs/api/java/math/MathContext.html
Precision: The number of digits to be used for an operation; this accuracy is rounded to results.
RoundingMode: a RoundingMode object that determines the rounding algorithm to be used.
MathContext doesn’t work with number of decimal places but with number of significant digits.
Rounding
We covered rounding topic a bit in “Division“ section. But what if we need to round for decimal places?
Problem
Using MathContext doesn’t solve rounding for specific number of decimal places.
val rounded = BigDecimal("0.0725").round(MathContext(3, RoundingMode.HALF_UP)) // 0.0725
Reason
MathContext doesn’t work with number of decimal places but with number of significant digits.
Solution
Use BigDecimal .setScale
instead of BigDecimal.round
.
val rounded = BigDecimal("0.0725").setScale(3, RoundingMode.HALF_UP) // 0.073
Testing
Problem
Equals shouldn’t be used for comparing BigDecimal values.
assertEquals(BigDecimal("1000.00"), BigDecimal("1000")) // false assertTrue(BigDecimal("1000.00").equals(BigDecimal("1000")) // false assertTrue(BigDecimal("1000.00") == BigDecimal("1000")) // false
Reason
The equals
method considers two BigDecimal
objects as equal only if they are equal in value and scale.
Solution
Use compareTo
if you are not interested in equal scale.
assertTrue(BigDecimal("1000.00").compareTo(BigDecimal("1000") == 0)) // true
Serialization
Problem
We want to serialize BigDecimal as string (API output).
Reason
We don’t want loose precision during conversions from/to floats.
Solution
We can use serializer of object mapper in Application.kt.
@Factory @Replaces(ObjectMapperFactory::class) internal class CustomObjectMapperFactory : ObjectMapperFactory() { /** * Overrides Jackson object mapper, so we can set BigDecimal serialization globally. */ @Singleton @Replaces(ObjectMapper::class) override fun objectMapper( jacksonConfiguration: JacksonConfiguration?, jsonFactory: JsonFactory? ): ObjectMapper { val mapper: ObjectMapper = super.objectMapper(jacksonConfiguration, jsonFactory) return mapper.registerModule(SimpleModule().addSerializer(BigDecimal::class.java, ToStringSerializer())) } }
Or we set set @JsonFormat
annotation globally in Application.kt (older solution).
@Factory @Replaces(ObjectMapperFactory::class) internal class CustomObjectMapperFactory : ObjectMapperFactory() { /** * Overrides Jackson object mapper, so we can set annotations globally. */ @Singleton @Replaces(ObjectMapper::class) override fun objectMapper( jacksonConfiguration: JacksonConfiguration?, jsonFactory: JsonFactory? ): ObjectMapper { val mapper: ObjectMapper = super.objectMapper(jacksonConfiguration, jsonFactory) return mapper.configOverride(BigDecimal::class.java).setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING)) } }
Open Questions
Is there a way how to set MathContext globally?
What maximum precision we will use?
Can we enforce BigDecimal scale on global level (Juraj Machac wants to represent amounts with predefined scale
BigDecimal(BigInteger("20"), 5)
)?