Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

BigDecimal is designed to work with floating point numbers. It provides a convenient way to manage the precision and, most importantly, deals with the rounding errors.

However, in some cases, we need to work with it as a simple integer number and convert it to an Integer or an int. In this tutorial, we’ll learn how to do this properly and understand some underlying problems with the conversion.

2. Narrowing Conversion

BigDecimal can store a much wider range of numbers than an Integer or an int. This often might lead to losing the precision during conversion.

2.1. Truncation

BigDecimal provides us with the intValue(), which can convert it to an int:

@ParameterizedTest
@ArgumentsSource(SmallBigDecimalConversionArgumentsProvider.class)
void givenSmallBigDecimalWhenConvertToIntegerThenWontLosePrecision(BigDecimal given, int expected) {
    int actual = given.intValue();
    assertThat(actual).isEqualTo(expected);
}

BigDecimal can contain floating point values, but an int cannot. That’s why the intValue() method truncates all the numbers after the decimal point:

@ParameterizedTest
@ValueSource(doubles = {0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5})
void givenLargeBigDecimalWhenConvertToIntegerThenLosePrecision(double given) {
    BigDecimal decimal = BigDecimal.valueOf(given);
    int integerValue = decimal.intValue();
    double actual = Integer.valueOf(integerValue).doubleValue();
    assertThat(actual)
      .isEqualTo((int) given)
      .isNotEqualTo(given);
}

The behavior is similar to casting a double to an int or long. Thus, the number’s precision can be lost. At the same time, losing the precision might be acceptable for an application. However, we should always account for it.

2.2. Overflow

Another problem is the overflow while using intValue(). It’s similar to the previous issue but gives us an entirely incorrect result:

@ParameterizedTest
@ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 2,
  Long.MAX_VALUE - 3, Long.MAX_VALUE - 4, Long.MAX_VALUE - 5,
  Long.MAX_VALUE - 6, Long.MAX_VALUE - 7, Long.MAX_VALUE - 8})
void givenLargeBigDecimalWhenConvertToIntegerThenLosePrecision(long expected) {
    BigDecimal decimal = BigDecimal.valueOf(expected);
    int actual = decimal.intValue();
    assertThat(actual)
      .isNotEqualTo(expected)
      .isEqualTo(expected - Long.MAX_VALUE - 1);
}

At the same time, it’s a reasonable behavior, considering the binary number representation. We cannot store more bits of information than an int can take. In some scenarios, we have both of these problems:

@ParameterizedTest
@ValueSource(doubles = {Long.MAX_VALUE - 0.5, Long.MAX_VALUE - 1.5, Long.MAX_VALUE - 2.5,
  Long.MAX_VALUE - 3.5, Long.MAX_VALUE - 4.5, Long.MAX_VALUE - 5.5,
  Long.MAX_VALUE - 6.5, Long.MAX_VALUE - 7.5, Long.MAX_VALUE - 8.5})
void givenLargeBigDecimalWhenConvertToIntegerThenLosePrecisionFromBothSides(double given) {
    BigDecimal decimal = BigDecimal.valueOf(given);
    int integerValue = decimal.intValue();
    double actual = Integer.valueOf(integerValue).doubleValue();
    assertThat(actual)
      .isNotEqualTo(Math.floor(given))
      .isNotEqualTo(given);
}

While intValue() might work for some cases, we must have a better solution to avoid unexpected bugs.

3. Precision Loss

There are a couple of ways we can approach the issues we discussed. Although we cannot avoid the precision loss, we can make the process more explicit. 

3.1. Checking the Scale

One of the most straightforward approaches is to check the scale of BigDecimal. We can identify if the given number contains a decimal point and assume it has some values afterward. This technique would work in most cases. However, it just identifies the presence of a decimal point and not if the number contains non-zero values after it:

@ParameterizedTest
@ValueSource(doubles = {
  0.0, 1.00, 2.000, 3.0000, 
  4.00000, 5.000000, 6.00000000, 
  7.000000000, 8.0000000000})
void givenLargeBigDecimalWhenCheckScaleThenItGreaterThanZero(double given) {
    BigDecimal decimal = BigDecimal.valueOf(given);
    assertThat(decimal.scale()).isPositive();
    assertThat(decimal.toBigInteger()).isEqualTo((int) given);
}

In this example, the number 0.0 would have a scale equal to one. We might have some edge cases if we base our conversion behavior on the scale value.

3.2. Defining Rounding

If losing precision is okay, we can set the scale to zero and identify the rounding strategy. This has a benefit over a simple intValue() invocation. We’ll explicitly define the rounding behavior:

@ParameterizedTest
@ValueSource(doubles = {0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5})
void givenLargeBigDecimalWhenConvertToIntegerWithRoundingUpThenLosePrecision(double given) {
    BigDecimal decimal = BigDecimal.valueOf(given);
    int integerValue = decimal.setScale(0, RoundingMode.CEILING).intValue();
    double actual = Integer.valueOf(integerValue).doubleValue();
    assertThat(actual)
      .isEqualTo(Math.ceil(given))
      .isNotEqualTo(given);
}

We can use the RoundingMode enum to define the rules. It provides us with several predefined strategies to gain more control over the conversion.

4. Overflow Prevention

Overflow problems are different. While losing precision might be okay for an application, getting an entirely incorrect number is never acceptable.

4.1. Range Checking

We can check if it’s possible to fit the BigDecimal value into an int. If we can do it, we use intValue() conversion. Otherwise, we can use a default value, for example, the smallest or the largest int:

@ParameterizedTest
@ValueSource(longs = {
  Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 2,
  Long.MIN_VALUE, Long.MIN_VALUE + 1, Long.MIN_VALUE + 2,
  0, 1, 2
})
void givenLargeBigDecimalWhenConvertToIntegerThenSetTheMaxOrMinValue(long expected) {
    BigDecimal decimal = BigDecimal.valueOf(expected);
    boolean tooBig = isTooBig(decimal);
    boolean tooSmall = isTooSmall(decimal);
    int actual;
    if (tooBig) {
        actual = Integer.MAX_VALUE;
    } else if (tooSmall) {
        actual = Integer.MIN_VALUE;
    } else {
        actual = decimal.intValue();
    }
    assertThat(tooBig).isEqualTo(actual == Integer.MAX_VALUE);
    assertThat(tooSmall).isEqualTo(actual == Integer.MIN_VALUE);
    assertThat(!tooBig && !tooSmall).isEqualTo(actual == expected);
}

In case it’s not possible to identify a reasonable default, we can throw an exception. BigDecimal API already provides us with a similar method.

4.2. Exact Value

BigDecimal has a safer version of intValue() intValueExact(). This method would throw ArithmeticException on any overflow in the decimal part:

@ParameterizedTest
@ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 2,
  Long.MAX_VALUE - 3, Long.MAX_VALUE - 4, Long.MAX_VALUE - 5,
  Long.MAX_VALUE - 6, Long.MAX_VALUE - 7, Long.MAX_VALUE - 8})
void givenLargeBigDecimalWhenConvertToExactIntegerThenThrowException(long expected) {
    BigDecimal decimal = BigDecimal.valueOf(expected);
    assertThatExceptionOfType(ArithmeticException.class)
      .isThrownBy(decimal::intValueExact);
}

This way, we can ensure that our application will handle the overflow and won’t allow an incorrect state.

5. Conclusion

Conversion of numeric values might sound trivial, but even a simple conversion can introduce hard-to-debug issues in an application. Thus, we should be careful with narrowing conversion and always consider precision loss and overflow.

BigDecimal provides various convenient methods to simplify the conversion and give us more control over the process.

As usual, all the code from this tutorial is available over on GitHub.

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.