Monday, August 15, 2011

Revisiting Enum Unit Conversions

In response to my blog post Using Java Enums for Units Conversions, mr. Bear observed that another approach to implementing the enum that stores temperature scales and provides conversion functionality between temperature scales would be to use a single convertTo method that each enum value overrode (per-instance method). I noted in response that this very design trade-off is evidenced when comparing the TimeUnit's conversion methods to CaseFormat's conversion methods.

The TimeUnit enum takes the approach I used of naming each conversion method explicitly for what the target scale was (such as toFahrenheit). In my implementation, I achieved this by defining one method for each scale being converted to with that target scale's name and then switching on the enum instance's own values to determine what the source scale was. The appropriate case matching the source scale handled the conversion to the atget scale prescribed by the method name.

Guava's CaseFormat enum approach is orthogonal to this approach. CaseFormat implements a single, generic method called to that switches on the thing being converted to (the target) rather than switching on the source thing. The source item is already known because each value in the enum overrides its own "to" (or "convertTo") method (each enum value has its own method, so these overridden methods are often referred to as per-instance methods). The advantage of this approach is that the switching is done on the target rather than on the source. Although switching must still be done, it does feel more appropriate in general to switch on the target than on the source. This second approach allows polymorphism to be used to inherently "know" which source scale is being used.

In the next part of this post, I show my TemperatureUnit.java enum ported to use this alternative implementation style. It is shown next as TemperatureUnit2. As with the previous post, I must issue a disclaimer and major caveat here: this code's primary intention is demonstration and it has NOT been thoroughly tested or vetted for correctness. In fact, the porting of the first example is likely to retain any errors in it and might even introduce some new ones! The point of the code is to demonstrate an alternative approach to implementing these conversions.

TemperatureUnit2.java
package dustin.examples;
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 TemperatureUnit2
{
   /** Celsius, used by most of the world's population. */
   CELSIUS(new BigDecimal("0"), new BigDecimal("100"), '\u00B0' + "C", "Anders Celsius")
   {
      /**
       * {@inheritDoc}
       *
       * <p>The source temperature scale in this case is Celsius.</p>
       */
      @Override
      public BigDecimal convertTo(
         final TemperatureUnit2 targetTemperatureScale, final BigDecimal sourceTemperature)
      {
         BigDecimal target = null;
         switch (targetTemperatureScale)
         {
            case CELSIUS :
               target = sourceTemperature;
               break;
            case FAHRENHEIT :
               target = sourceTemperature.multiply(NINE).divide(FIVE).add(THIRTY_TWO);
               break;
            case KELVIN :
               target = sourceTemperature.add(KELVIN.freezingPoint);
               break;
            case RANKINE :
               target = sourceTemperature.add(NINE.divide(FIVE)).add(THIRTY_TWO).add(RANKINE_FAHRENHEIT_DELTA);               
               break;
         }
         return target;
      }
   },
   /** Fahrenheit, commonly used in the United States. */
   FAHRENHEIT(new BigDecimal("32"), new BigDecimal("212"), '\u00B0' + "F", "Daniel Gabriel Fahrenheit")
   {
      /**
       * {@inheritDoc}
       *
       * <p>The source temperature scale in this case is Fahrenheit.</p>
       */
      @Override
      public BigDecimal convertTo(
         final TemperatureUnit2 targetTemperatureScale, final BigDecimal sourceTemperature)
      {
         BigDecimal target = null;
         switch (targetTemperatureScale)
         {
            case CELSIUS :
               target = sourceTemperature.subtract(THIRTY_TWO).divide(NINE.divide(FIVE));
               break;
            case FAHRENHEIT :
               target = sourceTemperature;
               break;
            case KELVIN :
               target = sourceTemperature.add(RANKINE_FAHRENHEIT_DELTA).divide(NINE.divide(FIVE));
               break;
            case RANKINE :
               target = sourceTemperature.add(RANKINE_FAHRENHEIT_DELTA);
               break;
         }
         return target;
      }
   },
   /** Kelvin, commonly used in scientific endeavors. */
   KELVIN(new BigDecimal("273.15"), new BigDecimal("373.15"), "K", "William Thomson, 1st Baron Kelvin")
   {
      /**
       * {@inheritDoc}
       *
       * <p>The source temperature scale in this case is Kelvin.</p>
       */
      @Override
      public BigDecimal convertTo(
         final TemperatureUnit2 targetTemperatureScale, final BigDecimal sourceTemperature)
      {
         BigDecimal target = null;
         switch (targetTemperatureScale)
         {
            case CELSIUS :
               target = sourceTemperature.subtract(KELVIN.freezingPoint);
               break;
            case FAHRENHEIT :
               target = (sourceTemperature.subtract(KELVIN.freezingPoint)).multiply(NINE).divide(FIVE).add(THIRTY_TWO);
               break;
            case KELVIN :
               target = sourceTemperature;
               break;
            case RANKINE :
               target = sourceTemperature.multiply(NINE.divide(FIVE));
               break;
         }
         return target;
      }
   },
   /** Rankine temperature scale. */
   RANKINE(new BigDecimal("491.67"), new BigDecimal("671.641"), '\u00B0' + "R", "William John Macquorn Rankine")
   {
      /**
       * {@inheritDoc}
       *
       * <p>The source temperature scale in this case is Rankine.</p>
       */
      @Override
      public BigDecimal convertTo(
         final TemperatureUnit2 targetTemperatureScale, final BigDecimal sourceTemperature)
      {
         BigDecimal target = null;
         switch (targetTemperatureScale)
         {
            case CELSIUS :
               target = (sourceTemperature.subtract(THIRTY_TWO).subtract(RANKINE_FAHRENHEIT_DELTA)).divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP);
               break;
            case FAHRENHEIT :
               target = sourceTemperature.subtract(RANKINE_FAHRENHEIT_DELTA);
               break;
            case KELVIN :
               target = sourceTemperature.divide(NINE.divide(FIVE), 2, RoundingMode.HALF_UP);
               break;
            case RANKINE :
               target = sourceTemperature;
               break;
         }
         return target;
      }
   };

   /** 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.
    */
   TemperatureUnit2(
      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.
    *
    * @param targetTemperatureScale Temperature scale to convert to.
    * @param sourceTemperature Temperature value to be converted.
    * @return Value of temperature in target temperature scale that corresponds
    *     to provided value for source temperature scale; may be null if no
    *     match can be calculated.
    */
   public abstract BigDecimal convertTo(
      final TemperatureUnit2 targetTemperatureScale, final BigDecimal 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 more generic approach employed by CaseFormat as implemented here is roughly the same code as the first example, but with the methods on the enum handled differently. In my implementation of the TimeUnit specific conversion method approach, I had the conversion methods written in general for the entire enum, named for each target they were converting to, and switching on whatever the source's value was. The CaseFormat-like implementation simply twisted this approach sideways so that roughly the same number of switches and cases are required, but now the switching is on the target type rather than on the source type. Each type has its own version of the method. This does seem to more neatly encapsulate knowledge in each enum value's scope and does take advantage of polymorphism.

The similarity in size of the source code for these two examples is shown in the next screen snapshot.


As the image shows, TemperatureUnit2 is less than a kilobyte larger than TemperatureUnit. The difference in terms of .class files is more pronounced.

Besides needing to move the individual case statements around when porting this code, another interesting observation was the need to change instance variable references to other enum value variables. In other words, while I could use this.freezingPoint when switch on the source value, I needed to use the source's freezingPoint (KELVIN.freezingPoint for example) instead when switching on the target value.

With the CaseFormat-inspired generic method name being more pleasing in terms of encapsulating knowledge in each enum value and in terms of taking advantage of polymorphic behavior, one may ask why I did not use this approach in the first place. The answer is that I thought of it instead from a user perspective. As a developer, I slightly prefer the specifically named method. I think it's a tad more readable and it's certainly more concise. With that in mind, I used the TimeUnit-inspired specifically named conversion method approach. One approach (the one covered in this post) allows the "source" value to know what it is already (via polymorphism) and have to ask and switch on the "target" value while the other approach (the one covered in the previous post) allows the "target" to be known via method name and has to ask and switch on the "source" value.

As I stated in my response to the great feedback on my earlier post, I would have a difficult time arguing against either of these approaches. In my opinion, one approach is more elegant from the perspective of the developer writing the enum with conversion functionality while the other approach is more elegant and slightly more readable for the developer using the enum. Neither's disadvantage when compared to the other is particularly large and I can live with either approach.

The good news is that Java does not force me to choose from either style presented so far. I can use a hybrid of the two approaches that combines the specific conversion approach of TimeUnit with implementation using overridden methods on each enum value to employ polymorphism. What this means is that I can eliminate switch statements altogether because the specific conversion method implies the target unit and the enum value in which the method is overridden implies the source unit. This is shown in the next code listing (for TemperatureUnit3).

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 TemperatureUnit3
{
   /** Celsius, used by most of the world's population. */
   CELSIUS(new BigDecimal("0"), new BigDecimal("100"), '\u00B0' + "C", "Anders Celsius")
   {
      /** {@inheritDoc} */
      @Override
      public BigDecimal convertToCelsius(final BigDecimal sourceTemperature)
      {
         return sourceTemperature;
      }

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

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

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

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

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

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

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

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

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

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

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

      /** {@inheritDoc} */
      @Override
      public BigDecimal convertToRankine(final BigDecimal 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 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.
    */
   TemperatureUnit3(
      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 null if no
    *     match can be calculated.
    */
   public abstract BigDecimal convertToCelsius(final BigDecimal 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 null if no
    *     match can be calculated.
    */
   public abstract BigDecimal convertToFahrenheit(final BigDecimal 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 null if no
    *     match can be calculated.
    */
   public abstract BigDecimal convertToKelvin(final BigDecimal 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 null if no
    *     match can be calculated.
    */
   public abstract BigDecimal convertToRankine(final BigDecimal 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;
   }
}

Although I did not have a clear favorite between the implementation of TemperatureUnit1 and TemperatureUnit2, I do clearly favor the implementation of TemperatureUnit3. There are no switch statements with the source scale implied by the particular enum value on which the overridden method exists and the target scale implied by the name of that overridden method. In other words, the defining location of the method implies the source and the name of the method implies the target. That's slick. It also gives me my preferred client experience so that client code does not need to pass a target enum into a general method, but can instead call the appropriately named method.

As a side note, this "hybrid" approach is how TimeUnit is implemented. The TimeUnit implementation differs from the TemperatureUnit3 implementation in that TimeUnit's method defined for the overall enum is NOT abstract, but instead throws an AbstractMethodError if invoked. The TemperatureUnit3 implementation makes the overall enum's methods explicitly abstract rather than providing an implementation at that level and throwing the error. I'm biased, but I prefer the approach in TemperatureUnit3 because it leads to a compile-time error if a particular enum value does not override the abstract method. One thing I would do to improve TemperatureUnit3 for real use is to check for null on all passed-in temperatures before attempting to perform calculations. TimeUnit does not need to make this check because it accepts primitive longs rather than reference types such as BigDecimal.


A Quick Diversion: Looking at the Bytecode

Before ending this post, a few observations can be made from looking at the generated bytecode. The source code files are roughly the same size for all three implementations as shown in the next screen snapshot.


Things get more interesting with the Java bytecode. The TemperatureUnit2 and TemperatureUnit3 enums are very similar from a .class file perspective. This is a reflection of the fact that both use per-instance methods in the enums. The existence of per-instance methods on those enums leads to the generated class files with $ in their name (such as TemperatureUnit2$5.class and TemperatureUnit3$3.class).

Running javap on the "main" enum .class file for each of the three cases provides interesting output.




As the screenshots of the javap output above show, the enums with per-instance methods (methods overridden on each value in the enum) feature synthetic methods as identified by the access$x000(); output (where 'x' is an integer).

The javap tool can be run against the individual generated .class files (the ones with $ in their name) to see what they are. This is shown in the next three screenshots, first for the TemperatureUnit (which has one of these generated files), then for the TemperatureUnit2 (which has five of these generated files), and then for TemperatureUnit3 (which has four of these generated files).


javap Demonstrates Switch in TemperatureUnit in Enum Leads to One Generated $.class File



javap Demonstrates Switch and Per-Instance Methods in TemperatureUnit2 Lead to Five Generated $.class Files



javap Demonstrates Per-Instance Methods in TemperatureUnit3 Lead to Four Generated $.class Files



The three alternative implementations of enums that perform conversions between temperature scales lead to different results in the bytecode. The original implementation, TemperatureUnit, had no per-instance methods and its only conversion method was a single method for the entire enum that used switch. Because it had one switch and no per-instance methods, it only had one extra generated $.class file.

The second of the three implementations, TemperatureUnit2, uses per-instance methods but still requires a switch. This results in five extra generated $.class files, four for the four per-instance methods and one for the switch still needed. The third of the three implementations, TemperatureUnit3, requires no switch statement and has four per-instance methods, resulting in four generated $.class files. Even though the third implementation actually has four overridden methods per instance, it is the number of instances with overridden methods that determines how many generated $.class files there are.


Conclusion

There is more than one approach that can be used to implement handy conversion functionality between values of an enum. Conversion methods are perfect for enums because enums tend to represent finite ranges of data and so it is often possible to convert from one type in that range to another type in that range. With any of the discussed approaches, the logic is encapsulated within the same enum definition that it affects.

1 comment:

Werner Keil said...

Interesting, this approach is very similar to a commercial implementation of Unit-API I developed a little over a year ago for a leading telco.