Sunday, February 17, 2013

Writing your own Scala 2.10 String Interpolators

In the first post of this series I showed how Scala 2.10’s new processed string syntax allow us to interpolate expression values into literal strings. We saw the s, raw and f interpolators that are provided by the Scala library. Now we will see how to write your own interpolators.

The complete code for all examples in this series will be made available in the accompanying BitBucket project.

Syntactic sugar for processed strings

The first step to writing your own interpolators is to understand how the Scala compiler interprets the new processed string syntax. A processed string has this general form:

id"text0${expr1}text1 ... ${exprn}textn"

where id is an identifier, the text pieces are constant string fragments, and the expr pieces are arbitrary expressions. The general form of processed string is translated into an expression of the following form.

StringContext ("text0", "text1", ... , "textn").id (expr1, ... , exprn)

The constant parts of the string literal are extracted and passed to the constructor of the Scala library’s StringContext class. The id method of the StringContext object is called and the interpolated expressions are passed as arguments.

What does s do?

More concretely, the expression

s"You are ${age / 10} decades old, $name!"

is really

StringContext ("You are ", " decades old, ", "!").s (age / 10, name)

The StringContext.s method takes the constant parts, interprets any escape sequences they contain, and interleaves them with the values of the expression arguments. The value returned is equivalent to

"You are " + (age / 10) + " decades old, " + (name) + "!"

which is probably what we would have written prior to Scala 2.10.

Adding methods to classes using an implicit conversion

It should be clear by now that to write your own interpolator, all you need is to add a method to the StringContext class. Of course, you can’t actually modify StringContext, but you can use an implicit conversion to achieve a similar effect.

Prior to 2.10 we would implement an implicit conversion as follows. Suppose we want to add a method sayhello to values of type Int. We can declare a new class MyInt that has the sayhello method and an implicit conversion that wraps Int values in an instance of the MyInt class.

class MyInt (val i : Int) {
  def sayhello = s"Hello there: $i"
}

implicit def IntToMyInt (i : Int) : MyInt =
  new MyInt (i)

Now we can use the sayhello method on an Int

println (42.sayhello)

which is treated by the compiler as

println (new MyInt (42).sayhello)

so we get the expected output:

Hello there: 42

Implicit classes

In 2.10 we can write the same conversion in a shorter way using another new feature: implicit classes.

implicit class MyInt (i : Int) {
  def sayhello = s"Hello there: $i"
}
println (42.sayhello)
Hello there: 42

The implicit modifier on the class MyInt causes the compiler to synthesise the IntToMyInt conversion that we wrote earlier.

In practice, you would probably also want to make MyInt a value class (another new feature in 2.10) to avoid the overhead of the object wrapping. We will avoid value classes here to keep the examples simple.

Writing our own interpolator

Armed with implicit conversions, it is easy to write a new interpolator. As our first example, let’s write one that performs exactly like s but returns the reverse of the result string.

implicit class ReverseContext (val sc : StringContext) {
  def rev (args : Any*) : String = {
    val orig = sc.s (args : _*)
    orig.reverse
  }
}

The ReverseContext class wraps a StringContext and adds the rev method. rev passes all of its expression arguments to the s method of the StringContext and then returns the reverse of the result.

val msg = "Hello world!"
println (rev"Backwards version of $msg")
!dlrow olleH fo noisrev sdrawkcaB

Constructing values that are not strings

Even though processed strings start out as a form of string literal, the value that they stand for does not have to be a string. This observation leads to much of the power of processed strings.

For example, the following code is a simple interpolator that first applies s and then counts the number of space characters in the result. The count is returned in a Count object.

case class Count (num : Int)

implicit class SpaceCountC (val sc : StringContext) {
  def nspaces (args : Any*) : Count = {
    val orig = sc.s (args : _*)
    Count (orig.count (_.isSpaceChar))
  }
}
val msg1 = "Hello world!"
val msg2 = s"a b $msg1 c d"
println (nspaces"$msg1")
println (nspaces"$msg2")
Count(1)
Count(5)

What’s next?

These examples have been chosen to be simple to make the mechanism clear. In the next post, we consider a more realistic example: octal number literals. As well as requiring a (slightly) more complex interpolator, octal numbers prompt us to think about the form of the literal that we can accept and what to do if the literal’s form is not legal.

No comments: