Using BigDecimal in a Class

BigDecimal is one of the most powerful classes in the JDK. With such a vast API, however, many developers will shy away from using it in their everyday programming. It’s often reserved for monetary calculations or other situations where an exact answer is required.

In this post, I want to demonstrate how BigDecimal can be used as an incredibly powerful building block for a class. Integrating it can be remarkably simple, and its immutability and full control over rounding can make your implementations much cleaner than the alternatives.

Sample Design

For this post, let’s begin by considering the design of a value class that stores the latitude and longitude values of a location on the globe. Assuming we are going to store the coordinates in decimal degrees, our first inclination might be to use a primitive double. Here’s how that might look:

import com.google.errorprone.annotations.Immutable;

@Immutable
public final class LatLon
{
   private final double latitude;

   private final double longitude;

   public LatLon(double latitude, double longitude)
   {
       this.latitude = latitude;
       this.longitude = longitude;
   }

   public double getLatitude()
   {
       return latitude;
   }

   public double getLongitude()
   {
       return longitude;
   }

   @Override
   public String toString()
   {
       String northSouth = latitude == 0 ? "" : latitude > 0 ? " N" : " S";
       String eastWest = longitude == 0 ? "" : longitude > 0 ? " E" : " W";
       return Math.abs(latitude) + "°" + northSouth + ", " + Math.abs(longitude) + "°" + eastWest;
   }

   @Override
   public boolean equals(Object o)
   {
       if (o == this)
       {
           return true;
       }
       if (!(o instanceof LatLon))
       {
           return false;
       }
       LatLon other = (LatLon) o;
       return Double.compare(other.latitude, latitude) == 0
               && Double.compare(other.longitude, longitude) == 0;
   }

   @Override
   public int hashCode()
   {
       int result = Double.hashCode(latitude);
       result = 31 * result + Double.hashCode(longitude);
       return result;
   }
}

Ignoring the missing validity checks, this implementation is surprisingly robust for its simplicity. The only thing we might want to change would be to add consistent rounding for our toString display. As is, this code:

LatLon location1 = new LatLon(29.9511, -90.0715);
LatLon location2 = new LatLon(29.95112345, -90.07150123);

System.out.println("Location 1: " + location1);
System.out.println("Location 2: " + location2);

will produce this output:

Location 1: 29.9511° N, 90.0715° W
Location 2: 29.95112345° N, 90.07150123° W

String.format() will do the job:

@Override
public String toString()
{
    String northSouth = latitude == 0 ? "" : latitude > 0 ? " N" : " S";
    String eastWest = longitude == 0 ? "" : longitude > 0 ? " E" : " W";
    return String.format("%.4f°%s, %.4f°%s", Math.abs(latitude), northSouth, Math.abs(longitude), eastWest);
}

But consider the side effects that it will introduce:

LatLon location1 = new LatLon(29.9511, -90.0715);
LatLon location2 = new LatLon(29.95112345, -90.07150123);

System.out.println("Location 1: " + location1);
System.out.println("Location 2: " + location2);

System.out.println("Location1 == Location2: " + location1.equals(location2));

prints:

Location 1: 29.9511° N, 90.0715° W
Location 2: 29.9511° N, 90.0715° W
Location1 == Location2: false

We now have two LatLons with the same output display that considered "unequal". Talk about a problem waiting to happen …

We not only need to print out the rounded lat/lon values, we also would like to do our equals comparisons using them as well. In fact, we’d probably like every usage of the lat/lon instance variables to obey our rounding rules. To achieve this, we’ll swap out the usage of the primitive double types for BigDecimals instead.

Using BigDecimal

In our constructor, we will create BigDecimals from the values the client passes in and round them to our chosen precision right at the start. After the constructor exits, the primitive doubles are gone; every other part of our class will use the BigDecimals we just created. Here’s how the implementation will look:

@Immutable
public final class LatLon
{
    private final BigDecimal latitude;

    private final BigDecimal longitude;

    public LatLon(double latitude, double longitude)
    {
        this.latitude = BigDecimal.valueOf(latitude).setScale(4, RoundingMode.HALF_UP);
        this.longitude = BigDecimal.valueOf(longitude).setScale(4, RoundingMode.HALF_UP);
    }

    public double getLatitude()
    {
        return latitude.doubleValue();
    }

    public double getLongitude()
    {
        return longitude.doubleValue();
    }

    @Override
    public String toString()
    {
        String northSouth = getLatitude() == 0 ? "" : getLatitude() > 0 ? " N" : " S";
        String eastWest = getLongitude() == 0 ? "" : getLongitude() > 0 ? " E" : " W";
        return latitude.abs() + "°" + northSouth + ", " + longitude.abs() + "°" + eastWest;
    }

    @Override
    public boolean equals(Object o)
    {
        if (o == this)
        {
            return true;
        }
        if (!(o instanceof LatLon))
        {
            return false;
        }
        LatLon other = (LatLon) o;
        return other.latitude.equals(latitude)
                && other.longitude.equals(longitude);
    }

    @Override
    public int hashCode()
    {
        int result = latitude.hashCode();
        result = 31 * result + longitude.hashCode();
        return result;
    }
}

The biggest thing to note is that this is completely transparent to the client! The API of the class didn’t change, but by updating the implementation from primitive doubles to BigDecimals we were able to ensure that consistently rounded values were used everywhere including our getters, toString() output, and equals() comparisons. That’s a lot of power gained for such a small amount of effort!

Also note that I’m using the BigDecimal static factory valueOf() instead of the double constructor. This is required in order to avoid introducing inaccurate values into the computation.

Trailing Zeros

For some applications, you may not want BigDecimal to add trailing zeros to your display outputs. As is, this is how our LatLon implementation will behave:

LatLon newOrleans = new LatLon(30, -90);
LatLon equator = new LatLon(0, 0);

System.out.println(newOrleans);
System.out.println(equator);

Output:

30.0000° N, 90.0000° W
0.0000°, 0.0000°

Changing this behavior is as simple as using the stripTrailingZeros() method when we create our BigDecimals:

public LatLon(double latitude, double longitude)
{
    this.latitude = BigDecimal.valueOf(latitude).setScale(4, RoundingMode.HALF_UP).stripTrailingZeros();
    this.longitude = BigDecimal.valueOf(longitude).setScale(4, RoundingMode.HALF_UP).stripTrailingZeros();
}

This is a perfect example of the elegance of fluent APIs.

With the remainder unchanged, running the same code will produce:

3E+1° N, 9E+1° W
0°, 0°

Wait a second! This doesn’t look like what we expected!

You can knock yourself out reading the details of how BigDecimal.toString() behaves, but suffice it to say that all you need to do is use the toPlainString() method in your class’ toString() and order is restored.

@Override
public String toString()
{
    String northSouth = getLatitude() == 0 ? "" : getLatitude() > 0 ? " N" : " S";
    String eastWest = getLongitude() == 0 ? "" : getLongitude() > 0 ? " E" : " W";
    return latitude.abs().toPlainString() + "°" + northSouth + ", "
            + longitude.abs().toPlainString() + "°" + eastWest;
}

New output:

30° N, 90° W
0°, 0°

Conclusion

The next time you are writing a value class that involves floating point values, strongly consider using BigDecimal in your implementation. Perhaps you can already think of some classes that you can refactor! This approach will keep your APIs steady, your classes’ immutability intact, and likely solves a lot of headache and trouble when it comes time for display or comparisons. Give it a try and let me know how it works out for you!

Leave a Reply