Wednesday, August 10, 2011

Using Java Enums for Units Conversions

When I was writing code regularly in both C++ and Java in the early days of Java, I often missed Java's (at the time) lack of an enum. Fortunately, when Java finally got its enum, it was worth the wait. Not only does the Java enum provide a typesafe representation of finite values, but it supports methods on those values. Another feature of Java enums that I did not as immediately recognize, but which has really grown on me as I've used SDK-provided enums, is the ability to use Java enums' method support to provide conversions between the enum values. An example of an SDK classes that make use of this feature is the TimeUnit enum (see my blog post on this). Another example is Guava's CaseFormat enum (via its to(CaseFormat,String) method).

I have been using Java enums to represent finite values and to provide basic methods on the enum values for some time. An example of an enum representing temperature scales is shown next that uses these features that I have most commonly used.

TemperatureUnit.java Enum with Representation and Basic Characteristics Methods
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 TemperatureUnit
{
   /** Celsius, used by most of the world's population. */
   CELSIUS(new BigDecimal("0"), new BigDecimal("100"), '\u00B0' + "C", "Anders Celsius"),
   /** Fahrenheit, commonly used in the United States. */
   FAHRENHEIT(new BigDecimal("32"), new BigDecimal("212"), '\u00B0' + "F", "Daniel Gabriel Fahrenheit"),
   /** Kelvin, commonly used in scientific endeavors. */
   KELVIN(new BigDecimal("273.15"), new BigDecimal("373.15"), "K", "William Thomson, 1st Baron Kelvin"),
   /** Rankine, */
   RANKINE(new BigDecimal("491.67"), new BigDecimal("671.641"), '\u00B0' + "R", "William John Macquorn Rankine");

   /** 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 TemperatureUnit 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.
    */
   TemperatureUnit(
      final BigDecimal newFreezingPoint,
      final BigDecimal newBoilingPoint,
      final String newUnits,
      final String newNamedFor)
   {
      this.freezingPoint = newFreezingPoint;
      this.boilingPoint = newBoilingPoint;
      this.units = newUnits;
      this.namedFor = newNamedFor;
   }

   /**
    * 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 version of the enum above provides a typesafe representation of the finite set of temperature skills. This already makes this enum superior to those available in many languages. This enum also has characteristics about each temperature scale that are easily accessible via its methods. This makes the Java enum even more powerful. It gets better. The next code listing shows the same enum, but this time with conversion methods that support converting a temperature in one temperature scale to another temperature scale.

WARNING: I have not tested all conversions and some may be (likely are) incorrect. This is meant solely for demonstrative purposes, though most of the values I did try out in very simple testing converted similarly to when I put the same values in an online temperature converter.

TemperatureUnit.java with Temperature Scale Conversion Methods Added
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 TemperatureUnit
{
   /** Celsius, used by most of the world's population. */
   CELSIUS(new BigDecimal("0"), new BigDecimal("100"), '\u00B0' + "C", "Anders Celsius"),
   /** Fahrenheit, commonly used in the United States. */
   FAHRENHEIT(new BigDecimal("32"), new BigDecimal("212"), '\u00B0' + "F", "Daniel Gabriel Fahrenheit"),
   /** Kelvin, commonly used in scientific endeavors. */
   KELVIN(new BigDecimal("273.15"), new BigDecimal("373.15"), "K", "William Thomson, 1st Baron Kelvin"),
   /** Rankine, */
   RANKINE(new BigDecimal("491.67"), new BigDecimal("671.641"), '\u00B0' + "R", "William John Macquorn Rankine");

   /** 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 TemperatureUnit 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.
    */
   TemperatureUnit(
      final BigDecimal newFreezingPoint,
      final BigDecimal newBoilingPoint,
      final String newUnits,
      final String newNamedFor)
   {
      this.freezingPoint = newFreezingPoint;
      this.boilingPoint = newBoilingPoint;
      this.units = newUnits;
      this.namedFor = newNamedFor;
   }

   /**
    * 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;
   }

   /**
    * Convert provided measure in this temperature scale to equivalent measure
    * in Fahrenheit scale.
    *
    * @param sourceMeasure Numeric measurement in source scale.
    * @return Numeric measurement equivalent in Fahrenheit scale; null if the
    *    provided source scale is not supported.
    */
   public BigDecimal toFahrenheit(final BigDecimal sourceMeasure)
   {
      BigDecimal fahrenheit = null;
      switch (this)
      {
         case CELSIUS:
            fahrenheit = sourceMeasure.multiply(NINE).divide(FIVE).add(THIRTY_TWO);
            break;
         case FAHRENHEIT:
            fahrenheit = sourceMeasure;
            break;
         case KELVIN:
            fahrenheit = (sourceMeasure.subtract(this.freezingPoint)).multiply(NINE).divide(FIVE).add(THIRTY_TWO);
            break;
         case RANKINE:
            fahrenheit = sourceMeasure.subtract(RANKINE_FAHRENHEIT_DELTA);
            break;
      }
      return fahrenheit;
   }

   /**
    * Convert provided measure in this temperature scale to equivalent measure
    * in Celsius scale.
    *
    * @param sourceMeasure Numeric measurement in source scale.
    * @return Numeric measurement equivalent in Celsius scale; null if the
    *    provided source scale is not supported.
    */
   public BigDecimal toCelsius(final BigDecimal sourceMeasure)
   {
      BigDecimal celsius = null;
      switch (this)
      {
         case CELSIUS:
            celsius = sourceMeasure;
            break;
         case FAHRENHEIT:
            celsius = sourceMeasure.subtract(THIRTY_TWO).divide(NINE.divide(FIVE));
            break;
         case KELVIN:
            celsius = sourceMeasure.subtract(this.freezingPoint);
            break;
         case RANKINE:
            celsius = (sourceMeasure.subtract(THIRTY_TWO).subtract(RANKINE_FAHRENHEIT_DELTA)).divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP);
            break;
      }
      return celsius;
   }

   /**
    * Convert provided measure in this temperature scale to equivalent measure
    * in Kelvin scale.
    *
    * @param sourceMeasure Numeric measurement in source scale.
    * @return Numeric measurement equivalent in Kelvin scale; null if the
    *    provided source scale is not supported.
    */
   public BigDecimal toKelvin(final BigDecimal sourceMeasure)
   {
      BigDecimal kelvin = null;
      switch (this)
      {
         case CELSIUS:
            kelvin = sourceMeasure.add(this.freezingPoint);
            break;
         case FAHRENHEIT:
            kelvin = sourceMeasure.add(RANKINE_FAHRENHEIT_DELTA).divide(NINE.divide(FIVE));
            break;
         case KELVIN:
            kelvin = sourceMeasure;
            break;
         case RANKINE:
            kelvin = sourceMeasure.divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP);
            break;
      }
      return kelvin;
   }

   /**
    * Convert provided measure in this temperature scale to equivalent measure
    * in Rankine scale.
    *
    * @param sourceMeasure Numeric measurement in source scale.
    * @return Numeric measurement equivalent in Rankine scale; null if the
    *    provided source scale is not supported.
    */
   public BigDecimal toRankine(final BigDecimal sourceMeasure)
   {
      BigDecimal rankine = null;
      switch (this)
      {
         case CELSIUS:
            rankine = sourceMeasure.add(NINE.divide(FIVE)).add(THIRTY_TWO).add(RANKINE_FAHRENHEIT_DELTA);
            break;
         case FAHRENHEIT:
            rankine = sourceMeasure.add(RANKINE_FAHRENHEIT_DELTA);
            break;
         case KELVIN:
            rankine = sourceMeasure.multiply(NINE.divide(FIVE));
            break;
         case RANKINE:
            rankine = sourceMeasure;
            break;
      }
      return rankine;
   }
}

The last version of TemperatureUnit.java adds conversion methods to the enum for converting between one supported temperature scale to another supported temperature scale.

NOTE: I used BigDecimal in the code to avoid rounding issues, but this means that one must be beware the non-terminating representation ArithmeticException. This also means that Java clients of this enum must provide numeric representations as BigDecimal. I could have allowed Strings to be provided and passed those internally to BigDecimal's constructor accepting String. For Groovy clients, which use BigDecimals for floating-point numbers implicitly, no special treatment is required.


Conclusion

The Java enum is a powerful feature of the language that allows one to elegantly represent finite values and to provide convenient access to characteristics of those finite values. In this post, I have demonstrated adding conversion methods to an enum to convert between its range of values. This places significant functionality in a single place and encapsulates conversion functionality within the enum itself.

6 comments:

Turbo said...

Interesting. Some questions:

- why not make an abstract method in the enum:
public abstract BigDecimal convertTo(TemperatureUnit toUnit, BigDecimal a);

- and then implement said method in each defined ENUM differently?

Advantages:
- I don't really like switch statements. I prefer objects taking care of this themselves.
- no toFarhenheit, toCelsius, toKelvin, but one method called convertTo(). Seems more expressive.

Calling method would look something like this?

CELSIUS.convertTo(FARHENHEIT, new BigDecimal(100));

Let me know what you think.

Dustin said...

Turbo,

The advantages of your recommended approach are compelling. In fact, the developers of Guava used this general approach in implementing CaseFormat.to(CaseFormat, String) for converting Strings (units of a sort in this case) to the specified (first argument) CaseFormat. Like you, I generally am not fond of switch statements and the overloaded method approach seems like an elegant way to go for the implementer of the enum with conversion functionality.

From a user/client perspective, I have a very slight preference for the approach used by TimeUnit (and in my post), but the emphasis is on "very" slight.

If I was reviewing someone's code, I'd be happy to approve either approach. I do think as a developer of the enum I have a slight preference for the way you suggested to avoid the switch statement, but from the perspective of a client of the enum I have a slight preference for the other approach.

One observation that is clearly made from this is the power of the Java enum and the ability to have so much flexibility in designing these types of unit conversion methods.

Thanks for leaving the comment. It adds significant value to the post.

Dustin

grelf.net said...

Yes you have demonstrated how there can be much more to enums than merely a list of identifiers. However I have some issues with your approach, as follows.

My main point is that your use of objects is upside down (or perhaps inside out). A Temperature would have Units. Construct a Temperature using any chosen unit and read out its value in any chosen unit, maybe causing a conversion:

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 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)
{
switch (units)
{
case CELSIUS: return kToC (this.degreesK); break;
case FAHRENHEIT: return kToF (this.degreesK); break;
default: return this.degreesK;
}
} // getValue

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

} // Temperature

This approach could then be extended to allow for a standard error field as well as the degreesK field. Nearly all temperatures are measured. The only exceptions are phase transitions of water that are used to define each scale: those should be instances of Temperature with 0.0 error, not properties of Temperature.Units.

Temperature waterFreezingPoint = new Temperature (0.0, Temperature.Units.CELSIUS);

I defy anyone to measure temperature to such an accuracy that the use of BigDecimal, with its awkwardnesses, would be justified. Float would suffice but, as Cay Horstmann has recently noted (http://weblogs.java.net/blog/cayhorstmann/archive/2011/08/14/whatever-floats-your-boat) there is usually little point in using anything but double.

Dustin said...

grelf.net,

Thanks for taking the time to articulate and express your opinions and to add example source code. I have responded to your comment in a new blog post, Enum Unit Conversions of a Third Kind. I incorporated some changes in the now-called TemperatureScale enum based on your feedback.

Dustin

Tarek Mohamed El-Mallah said...

Thank you for that, it's realy useful, I found also this link to the Length conversion online
, wish it's useful too and also I found many other tools in this site like Area Conversion , Weight Conversion , Volume Conversion , Temperature Conversion , wish it's useful too and thank you again.

Simon Page said...

Is there a case for expanding the Java Enum classes to a standard library for genuine Enum candidates?

Prime contenders would be standard units of measurement (with some built in functionality Eg conversion).