Saturday, August 20, 2011

Enum Unit Conversions of a Third Kind

This post is a response to a nicely articulated recent comment from grelf.net regarding my post Using Java Enums for Units Conversions. I mostly do not disagree with grelf.net's points and the small number of disagreements are minor (more a matter of taste/opinion than of anything else). Indeed, most of grelf.net's comments are difficult to argue with coming from grelf.net's focus on accurately modeling a class that holds temperature characteristics. My focus in that post and in its other follow up post, Revisiting Enum Unit Conversions, is on using enums for conversions rather than on modeling temperatures correctly or even on providing a class to store a temperature value.

Like grelf.net, I too had read Cay Horstmann's blog post Whatever Floats Your Boat after seeing it featured in one of my almost-daily scanning of "Java.net today" headlines (that post is still featured on the main page as of this writing by the way). However, my interpretation of Horstmann's point was slightly different than grelf.net's. While grelf.net felt that Horstmann was making the point that "there is usually little point in using anything but double," I felt that Horstmann's point was a little less sweeping than that.

In my opinion, Horstmann was really stating that there's little reason to use float rather than double. I couldn't agree more. It is easy for me to agree with using double rather than float because I cannot even recall the last time I used float other than when an API I was calling required it. For an introductory class learning coding and Java, double is far less awkward and easier to use than BigDecimal, so I can see double being appropriate in Horstmann's case. Horstmann even states, "Clearly, BigDecimal would be the right choice ... reviewers were adamant not to use objects and method calls too early. So, BigDecimal wasn't going to fly." It is not surprising that it is more important to make the code easy to follow than to make it mathematically precise for beginning software developers, but practicing Java developers are certainly capable of learning and using BigDecimal when its precision is warranted.

Hortsmann does point out what most of us do know about using BigDecimal: "they are awful to use in Java." He briefly "digresses" to show Scala's convenient treatment treatment and I've certainly been spoiled by Groovy's "transparent" use of operator overloading with GDK-provided BigDecimal to allow normal mathematical operation symbols to be used to manipulate BigDecimal values.

Because of the "awkwardness" of Java's BigDecimal, I had thought about supplying an alternative API to my enum's conversion functions. As I stated in my blog post Caution: Double to BigDecimal in Java, I was aware of issues with naively using the BigDecimal constructor that accepts a double. I had thought about a String-based API because Strings work well with the BigDecimal constructor, but it seemed odd to make client code need to use Strings for working with temperature conversions. Out of laziness and a hurry to get the post out, I simply left the API with BigDecimal for the time being, but thought about changing it to use double in conjunction with BigDecimal.valueOf(double) and BigDecimal.doubleValue() (though the latter suffers potential BigDecimal-to-double loss of precision issues).

Providing a double-based (emphasis on the primitive) API has an added advantage of removing the need to check for null on passed-in objects. The primitive cannot be null, but it would still be possible for an NullPointerException to occur within the client's code if the client naively tried to pass a null to one of these methods and implicit unboxing was attempted. It is easiest to see the advantage of a double API when looking at client code. The following is a basic Java "test driver" of the TemperatureUnit3 enum.

Main3.java - Client 'Testing' TemperatureUnit3.java
package dustin.examples;

import java.math.BigDecimal;
import static java.lang.System.out;

/**
 * <p>This is a simple class that demonstrates use of the TemperatureUnit with
 * special focus on demonstrating that enum's conversions between temperature
 * scales (see <a href="#warning">warning</a>).</p>
 *
 * <p><span style="font-weight: bold; color: red;">WARNING:</span>
 * <a name="warning">This class has</a>
 * <span style="font-weight: bold; color: red;">NOT</span> been adequately
 * tested and some conversions are likely to not be properly coded. This
 * example is intended for demonstrative purposes only.</p>
 */
public class Main3
{
   public static void main(final String[] arguments)
   {
      final TemperatureUnit3 celsius = TemperatureUnit3.CELSIUS;
      out.println("Zero Celsius = " + celsius.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");
      out.println("100 Celsisus = " + celsius.convertToFahrenheit(new BigDecimal("100")) + " Fahrenheit");

      final TemperatureUnit3 kelvin = TemperatureUnit3.KELVIN;
      out.println("Zero Kelvin = " + kelvin.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");
      out.println("100 Kelvin = " + kelvin.convertToFahrenheit(new BigDecimal("100")) + " Fahrenheit");
      out.println("273.15 Kelvin = " + kelvin.convertToFahrenheit(new BigDecimal("273.15")) + " Fahrenheit");
      out.println("373.15 Kelvin = " + kelvin.convertToFahrenheit(new BigDecimal("373.15")) + " Fahrenheit");

      final TemperatureUnit3 rankine = TemperatureUnit3.RANKINE;
      out.println("671.641 Rankine = " + rankine.convertToFahrenheit(new BigDecimal("671.641")) + " Fahrenheit");

      out.println(TemperatureUnit3.FAHRENHEIT.getNamedFor());
      out.println("Units: " + TemperatureUnit3.FAHRENHEIT.getUnits());
      out.println("0 degrees Fahrenheit = " + TemperatureUnit3.FAHRENHEIT.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");
      out.println("100 degrees Celsius = " + TemperatureUnit3.CELSIUS.convertToFahrenheit(new BigDecimal("100")) + " Fahrenheit");
      out.println("0 degrees Celsius = " + TemperatureUnit3.CELSIUS.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");

      out.println("Celsius named for " + TemperatureUnit3.CELSIUS.getNamedFor());
      out.println("Units: " + TemperatureUnit3.CELSIUS.getUnits());
      out.println("0 degrees Celsius = " + TemperatureUnit3.CELSIUS.convertToCelsius(new BigDecimal("0")) + " Celsius");
      out.println("212 degrees Farenheit = " + TemperatureUnit3.FAHRENHEIT.convertToCelsius(new BigDecimal("212")).toPlainString() + " Celsius");
      out.println("273 Kelvin: " + TemperatureUnit3.KELVIN.convertToCelsius(new BigDecimal("273")) + " Celsisus");
      out.println("500 R: " + TemperatureUnit3.RANKINE.convertToCelsius(new BigDecimal("500")) + " Celsius");

      out.println("Kelvin named for " + TemperatureUnit3.KELVIN.getNamedFor());
      out.println("Units: " + TemperatureUnit3.KELVIN.getUnits());
      out.println("0 Kelvin = " + TemperatureUnit3.KELVIN.convertToCelsius(new BigDecimal("0")) + " Celsius");
      out.println("0 Kelvin = " + TemperatureUnit3.KELVIN.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");
      out.println("0 Kelvin = " + TemperatureUnit3.KELVIN.convertToRankine(new BigDecimal("0")) + " Rankine");
      out.println("100 Kelvin = " + TemperatureUnit3.KELVIN.convertToRankine(new BigDecimal("100")) + " Rankine");

      out.println("Rankine named for " + TemperatureUnit3.RANKINE.getNamedFor());
      out.println("Units: " + TemperatureUnit3.RANKINE.getUnits());
      out.println("0 Rankine = " + TemperatureUnit3.RANKINE.convertToCelsius(new BigDecimal("0")) + " Celsius");
      out.println("0 Rankine = " + TemperatureUnit3.RANKINE.convertToFahrenheit(new BigDecimal("0")) + " Fahrenheit");
      out.println("0 Rankine = " + TemperatureUnit3.RANKINE.convertToKelvin(new BigDecimal("0")) + " Kelvin");
      out.println("100 Rankine = " + TemperatureUnit3.RANKINE.convertToKelvin(new BigDecimal("100")) + " Kelvin");
   }
}

In the above client code, the requirement to instantiate a BigDecimal each time is onerous. It's not an issue with a Groovy client where the numerals can be supplied directly as literals and Groovy implicitly treats them as BigDecimal instances. In my revised enum that will be shown later in this post, I employ a double-based API.

The grelf.net design supports conversion and representation of a particular temperature whereas my implementation intentionally only supports conversion functionality. As grelf.net states, "A Temperature would have Units. Construct a Temperature using any chosen unit and read out its value in any chosen unit." The grelf.net implementation is for a temperature with possibility of conversion functionality. My implementation is actually not for a temperature, but rather for temperature conversion. One thing I do change in this post's latest implementation of the enum is its name. I am now calling it TemperatureScale because that is what it really represents with each instance of the enum being a temperature scale (but not an actually measured temperature).

My implementation is designed for situations where the need is not to store a temperature for passing around the system, but simply to convert one available temperature of a given scale to the equivalent temperature on a different scale. A contrived example of this use case is reading temperatures provided by some central service in Celsius scale, but then presenting them on a web page for customers in the United States in Fahrenheit. Similarly, reading a list of average temperatures at which certain scientific events occur and converting them to Celsius or Fahrenheit for public consumption can use the conversion. If one does want to store a particular value in addition to providing the ability to convert between scales, then grelf.net's design seems like a reasonable one.

I am reproducing grelf.net's code, provided in his original comment, because the Blogger comments don't support the nice appearance and color coded syntax that is supported in posts.

Code Slightly Adapted (to Compile) from grelf.net's Comment
package dustin.examples;

/**
 * <p>Class postulated in grelf.net's feedback comment to post "Enum Unit Conversions"
 * (http://marxsoftware.blogspot.com/2011/08/using-java-enums-for-units-conversions.html).
 * Only minor changes have been made to make the class compile.</p>
 *
 * <p><span style="font-size: 125%; color: red;">WARNING:</span> This class has
 * not been tested or even used by me (Dustin) in a runtime environment. It has
 * only been compiled without error or warning.</p>
 */
public class Temperature
{
   private double degreesK; // Keep values in Kelvin internally

   public enum Units
   {
      CELSIUS ("\u00b0C"), FAHRENHEIT ("\u00b0C"), KELVIN ("\u00b0K"); // Etc

      Units (String symbol) { this.symbol = symbol; } 
      public String getSymbol () { return this.symbol; }
      private String symbol;
   } // Units

   // Conversions to Kelvin:
   private double cToK (double degreesC) { return degreesC + 273.16; }
   private double fToK (double degreesF) { return (degreesF - 32.0) * 5.0 / 9.0 + 273.16; }

   // Conversions from Kelvin:
   private double kToC (double degreesK) { return degreesK - 273.16; }
   private double kToF (double degreesK) { return (degreesK - 273.16) * 9.0 / 5.0 + 32.0; }

   public Temperature (double value, Units units)
   {
      if (null == units) throw new ImpossibleUnitsException ();

      switch (units)
      {
         case CELSIUS: this.degreesK = cToK (value); break;
         case FAHRENHEIT: this.degreesK = fToK (value); break;
         case KELVIN: this.degreesK = value;
      }
   } // Temperature

   /** NB: Unchecked, to avoid awkwardness when instantiating Temperature. */
   public class ImpossibleUnitsException extends RuntimeException {}

   public double getValue (Units units)
   {
      double retValue;
      switch (units)
      {
         case CELSIUS: retValue = kToC (this.degreesK); break;
         case FAHRENHEIT: retValue = kToF (this.degreesK); break;
         default: retValue = this.degreesK;
      }
      return retValue;
   } // getValue

   /** NB: Not the overridden version */
   public String toString (Units units)
   {
      String retValue;
      switch (units)
      {
         case CELSIUS: retValue = kToC (this.degreesK) + Units.CELSIUS.getSymbol (); break;
         case FAHRENHEIT: retValue = kToF (this.degreesK) + Units.FAHRENHEIT.getSymbol (); break;
         default: retValue = this.degreesK + Units.KELVIN.getSymbol ();
      }
      return retValue;
   } // toString

} // Temperature

Returning to my enum and its focus on temperature scales and conversions between scales rather than on representation of a particular measured temperature, I have incorporated changes to it to deal with the awkwardness of BigDecimal and to be more appropriately named. Note that the enum itself still uses BigDecimal, but the awkwardness of it is hidden from clients. This is a general principle I like in software development: encapsulate specific business logic and associated awkwardness within a single construct and isolate clients from as much of these specifics as possible. This approach allows me to retain BigDecimal precision without requiring the client code to be aware that BigDecimal is even used.

TemperatureScale.java (AKA TemperatureUnit4)
package dustin.examples;

import java.math.BigDecimal;
import java.math.RoundingMode;

/**
 * <p>Enum representing different temperature scales
 * (<span style="font-weight: bold; color: red;">NOT</span> ready for production
 * - see <a href="#warning">warning</a>).</p>
 *
 * <p><span style="font-weight: bold; color: red;">WARNING:</span>
 * <a name="warning">This class has</a>
 * <span style="font-weight: bold; color: red;">NOT</span> been adequately
 * tested and some conversions are likely to not be properly coded. This
 * example is intended for demonstrative purposes only.</p>
 */
public enum TemperatureScale
{
   /** Celsius, used by most of the world's population. */
   CELSIUS(new BigDecimal("0"), new BigDecimal("100"), '\u00B0' + "C", "Anders Celsius")
   {
      /** {@inheritDoc} */
      @Override
      public double convertToCelsius(final double sourceTemperature)
      {
         return sourceTemperature;
      }

      /** {@inheritDoc} */
      @Override
      public double convertToFahrenheit(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).multiply(NINE).divide(FIVE).add(THIRTY_TWO).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToKelvin(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).add(KELVIN.freezingPoint).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToRankine(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).add(NINE.divide(FIVE)).add(THIRTY_TWO).add(RANKINE_FAHRENHEIT_DELTA).doubleValue();
      }
   },
   /** Fahrenheit, commonly used in the United States. */
   FAHRENHEIT(new BigDecimal("32"), new BigDecimal("212"), '\u00B0' + "F", "Daniel Gabriel Fahrenheit")
   {
      /** {@inheritDoc} */
      @Override
      public double convertToCelsius(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).subtract(THIRTY_TWO).divide(NINE.divide(FIVE)).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToFahrenheit(final double sourceTemperature)
      {
         return sourceTemperature;
      }

      /** {@inheritDoc} */
      @Override
      public double convertToKelvin(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).add(RANKINE_FAHRENHEIT_DELTA).divide(NINE.divide(FIVE)).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToRankine(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).add(RANKINE_FAHRENHEIT_DELTA).doubleValue();
      }
   },
   /** Kelvin, commonly used in scientific endeavors. */
   KELVIN(new BigDecimal("273.15"), new BigDecimal("373.15"), "K", "William Thomson, 1st Baron Kelvin")
   {
      /** {@inheritDoc} */
      @Override
      public double convertToCelsius(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).subtract(KELVIN.freezingPoint).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToFahrenheit(final double sourceTemperature)
      {
         return (BigDecimal.valueOf(sourceTemperature).subtract(KELVIN.freezingPoint)).multiply(NINE).divide(FIVE).add(THIRTY_TWO).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToKelvin(final double sourceTemperature)
      {
         return sourceTemperature;
      }

      /** {@inheritDoc} */
      @Override
      public double convertToRankine(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).multiply(NINE.divide(FIVE)).doubleValue();
      }
   },
   /** Rankine temperature scale. */
   RANKINE(new BigDecimal("491.67"), new BigDecimal("671.641"), '\u00B0' + "R", "William John Macquorn Rankine")
   {
      /** {@inheritDoc} */
      @Override
      public double convertToCelsius(final double sourceTemperature)
      {
         return (BigDecimal.valueOf(sourceTemperature).subtract(THIRTY_TWO).subtract(RANKINE_FAHRENHEIT_DELTA)).divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToFahrenheit(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).subtract(RANKINE_FAHRENHEIT_DELTA).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToKelvin(final double sourceTemperature)
      {
         return BigDecimal.valueOf(sourceTemperature).divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP).doubleValue();
      }

      /** {@inheritDoc} */
      @Override
      public double convertToRankine(final double sourceTemperature)
      {
         return sourceTemperature;
      }
   };

   /** Freezing point of water for each temperature scale. */
   private BigDecimal freezingPoint;

   /** Boiling point of water for each temperature scale. */
   private BigDecimal boilingPoint;

   /** Units by which this temperature scale is expressed. */
   private String units;

   /** Name of person that this temperature scale is named for. */
   private String namedFor;

   private static final BigDecimal FIVE = new BigDecimal("5");
   private static final BigDecimal NINE = new BigDecimal("9");
   private static final BigDecimal THIRTY_TWO = new BigDecimal("32");
   private static final BigDecimal KELVIN_CELSIUS_DELTA = new BigDecimal("273");
   private static final BigDecimal RANKINE_FAHRENHEIT_DELTA = new BigDecimal("459.67");

   /**
    * Constructor for TemperatureScale that accepts key characteristics of each
    * temperature scale.
    *
    * @param newFreezingPoint Freezing point for this temperature scale.
    * @param newBoilingPoint Boiling point for this temperature scale.
    * @param newUnits Units of measurement for this temperature scale.
    * @param newNamedFor Name of person after which temperature scale was named.
    */
   TemperatureScale(
      final BigDecimal newFreezingPoint,
      final BigDecimal newBoilingPoint,
      final String newUnits,
      final String newNamedFor)
   {
      this.freezingPoint = newFreezingPoint;
      this.boilingPoint = newBoilingPoint;
      this.units = newUnits;
      this.namedFor = newNamedFor;
   }

   /**
    * Conversion method to be implemented by each type of temperature scale for
    * converting from that temperature scale to the Celsius temperature scale.
    *
    * @param sourceTemperature Temperature value to be converted.
    * @return Value of temperature in Celsius temperature scale that corresponds
    *     to provided value for source temperature scale; may be
    *     Double.NEGATIVE_INFINITY or Double.POSITIVE_INFINITY.
    */
   public abstract double convertToCelsius(final double sourceTemperature);

   /**
    * Conversion method to be implemented by each type of temperature scale for
    * converting from that temperature scale to the Fahrenheit temperature scale.
    *
    * @param sourceTemperature Temperature value to be converted.
    * @return Value of temperature in Fahrenheit temperature scale that corresponds
    *     to provided value for source temperature scale; may be
    *     Double.NEGATIVE_INFINITY or Double.POSITIVE_INFINITY.
    */
   public abstract double convertToFahrenheit(final double sourceTemperature);

   /**
    * Conversion method to be implemented by each type of temperature scale for
    * converting from that temperature scale to the Kelvin temperature scale.
    *
    * @param sourceTemperature Temperature value to be converted.
    * @return Value of temperature in Kelvin temperature scale that corresponds
    *     to provided value for source temperature scale; may be
    *     Double.NEGATIVE_INFINITY or Double.POSITIVE_INFINITY.
    */
   public abstract double convertToKelvin(final double sourceTemperature);

   /**
    * Conversion method to be implemented by each type of temperature scale for
    * converting from that temperature scale to the Rankine temperature scale.
    *
    * @param sourceTemperature Temperature value to be converted.
    * @return Value of temperature in Rankine temperature scale that corresponds
    *     to provided value for source temperature scale; may be
    *     Double.NEGATIVE_INFINITY or Double.POSITIVE_INFINITY.
    */
   public abstract double convertToRankine(final double sourceTemperature);

   /**
    * Provide the freezing point of water for this temperature scale.
    *
    * @return Freezing point of this temperature scale.
    */
   public BigDecimal getFreezingPoint()
   {
      return this.freezingPoint;
   }

   /**
    * Provide the boiling point of water for this temperature scale.
    *
    * @return Boiling point of this temperature scale.
    */
   public BigDecimal getBoilingPoint()
   {
      return this.boilingPoint;
   }

   /**
    * Unit of measurement for this temperature scale.
    *
    * @return Unit of measurement for this temperature scale.
    */
   public String getUnits()
   {
      return this.units;
   }

   /**
    * Provide the name of the person for which this temperature scale was named.
    *
    * @return Name of person for which this temperature scale was named.
    */
   public String getNamedFor()
   {
      return this.namedFor;
   }
}

The latest incarnation of the temperature scale enum with focus on units conversions does provide a double-based API to its users. Any nastiness of dealing with BigDecimal is nicely encapsulated within the enum and is largely an unknown implementation to the user. The Java client begins to look simpler and more concise like the Groovy client with this double-based API.

I do agree with grelf.net that BigDecimal's accuracy is likely overkill in most situations involving temperature calculations, but my defense for using them is again the fact that my example's primary intent all along has not been to demonstrate how to appropriate define temperature related functionality. Rather, my intent has been to demonstrate using enums for unit conversion functionality and I wanted to employ BigDecimal as a more interesting and broadly applicable approach for illustrating general conversion principles. There is obviously nothing wrong with using double when that meets the situation's needs. If performance is a more important issue than loss of precision, than double is certainly the way to go with this implementation.

I find it interesting that there's been so much online discussion recently regarding Java floating point numeric representations when this is really not a new issue. Besides the previously mentioned post on using double rather than float, two other recent posts (Working with Money in Java and Double Your Money Again) discuss trade-offs between double and BigDecimal.

The latter posts comparing BigDecimal to double provide good food for thought when deciding which Java floating point representation to use. In my experience, I have predominately used double, but my frequent use of Groovy and some experiences that ended poorly with cumulative floating point misrepresentations in Java have caused me to consider BigDecimal more often and choose it when the decision is evenly split. Using techniques such as those employed in the TemperateScale enum above and as described in Caution: Double to BigDecimal in Java can be used to encapsulate awkwardness of BigDecimal and isolate clients from needing to deal with it.


Conclusion

I appreciate grelf.net's feedback because it is thought-provoking and represents an obviously highly informed opinion. It requires significant time and effort to place a post with that type of thinking and code samples into a blog's feedback comment section, so I appreciate the effort he took to do so. It's that type of thought and effort that makes an online community stronger and improves everyone's skill set.

Although I did not fully agree with everything stated in the comment, I think most of the disagreements are traced to perspective. While my perspective was on demonstrating that general unit conversions can be implemented in an enum, jrelf.net's focus was on correct implementation of a temperature itself. I did agree with jrelf.net's assessment that BigDecimal is likely not necessary for temperature conversions, but I cannot go so far to say "there is usually little point in using anything but double." Because my example was intended for more general illustrative purposes and because I think BigDecimal probably deserves more general consideration when thinking about floating-point arithmetic in Java, it seemed appropriate to leave it in my example. Because I wanted my API to be less "awkward," the newest version of the enum (TemperatureScale) provided in this post incorporates a double-based client API.


Additional Reading

3 comments:

grelf.net said...

Glad you appreciated my comment enough to write so extensively about it.
I think it is fundamentally wrong to use BigDecimal for measured values that can only have at most 2 or 3 significant figures.
Obviously BigDecimal must be used in financial calculations where propagating errors are unacceptable.
You may also be interested in http://www.apfloat.org/apfloat_java/

@DustinMarx said...

grelf.net,

Thanks for pointing out Apfloat for Java. I had not been aware of that.

Dustin

Richard said...

Thanks for posting the programs for Temperature Conversion.This helps me lot to do some projects.Good article.