Wednesday, May 26, 2010

Using Scala implicit conversions for backward compatibility

This post explains how to use implicit conversions to work around API incompatibilities between Scala 2.7 and 2.8.
I learnt about this technique from reading the source code of SXR. What follows is a step-by-step explanation.

Implicit conversion basics

Implicit conversions can make an object behave like it has another type. In particular, it can be used to add new methods to existing types. When a method is invoked on an object and no match is found, Scala will try to apply any implicit conversion currently in scope. Try typing this in the Scala REPL:
implicit def intToHello(i: Int) = {
  println("intToHello was applied")
  new { def message = "hello from " + i }
}
In this contrived example, we define an implicit conversion to add a message method to integers. The conversion returns an anonymous instance that provides the method. I've added a print statement for illustrative purposes. We can now invoke message on any integer:
scala> println(42.message)
intToHello was applied
hello from 42

A case of API incompatibility: Symbol.fullNameString

SXR is a Scala compiler plugin that generates an HTML cross-reference of the sources of a Scala project. In order to support partial recompilation and cross-project links, it needs to generate "stable" IDs for public compiler symbols; in particular, these IDs contain the full name of the symbol, which, in Scala 2.7.7, is retrieved with the method Symbol.fullNameString :
def nameString(s: Symbol) = s.fullNameString
(full source)

Unfortunately (for this particular situation!), symbols have been refactored in Scala 2.8. Class Symbol now extends scala.reflect.generic.Symbols.AbsSymbol, and the method we need is now called fullName. The code above no longer compiles:
<console>:12: error: value fullNameString is not a member of Demo.this.global.Symbol
   def nameString(s: Symbol) = s.fullNameString

Implicits to the rescue

To compile the code with Scala 2.8, Symbol must behave like it has a fullNameString method. This is done exactly as in our first intToHello example:
def nameString(s: Symbol) = s.fullNameString
private implicit def symCompat(sym: Symbol): SymCompat = new SymCompat(sym)
private final class SymCompat(s: Symbol) {
    def fullNameString = s.fullName;
}

(full source)

An implicit is used to add the missing fullNameString method, which delegates to the original fullName. The code now compiles with Scala 2.8. However, it no longer compiles with 2.7:
<console>:12: error: value fullName is not a member of Demo.this.global.Symbol
   def fullNameString = s.fullName;
The 2.7 version of Symbol has no fullName method. Although the symCompat implicit will never be invoked at runtime in Scala 2.7 (since Symbol does defines fullNameString), this is still a compile error. To solve this, the missing method is added to the same implicit conversion:
def nameString(s: Symbol) = s.fullNameString
private implicit def symCompat(sym: Symbol): SymCompat = new SymCompat(sym)
private final class SymCompat(s: Symbol) {
    def fullNameString = s.fullName; def fullName = sourceCompatibilityOnly
}
private def sourceCompatibilityOnly = assert(false, "should not get here")
(full source)

To stress the fact that the method will never actually be invoked at runtime, it delegates to a method that throws an assertion error.

Conclusion

The final version of the code compiles with both versions of Scala. The implicit conversion and its supporting type provide runtime compatibility for Scala 2.8, and compile-time compatibility for Scala 2.7. Note that this can also be done the other way around; if the code is intended primarily for one version, it is probably a good idea to have the implicit applied on the other one. Finally, this technique should be properly encapsulated, to avoid leaking the implicits to the rest of your code.

0 comments: