This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 2.11, Creating Your Own String Interpolator.
Scala 3 Problem
You want to create your own string interpolator in Scala 3, like the s
, f
, and raw
interpolators that come with Scala.
Solution
To create your own string interpolator, you need to know that when a programmer writes code like foo"a b c"
, that code is transformed into a foo
method call on the Scala StringContext
class. Specifically, when you write this code:
val a = "a"
foo"a = $a"
it’s translated into this:
StringContext("a = ", "").foo(a)
Therefore, to create a custom string interpolator, you need to create foo
as a Scala 3 extension method on the StringContext
class. There are a few additional details you need to know, and I’ll show those in an example.
Suppose that you want to create a string interpolator named caps
that capitalizes every word in a string, like this:
caps"john c doe" // "John C Doe"
val b = "b"
caps"a $b c" // "A B C"
To create caps
, define it as an extension method on StringContext
. Because you’re creating a string interpolator, you know that your method needs to return a String
, so you begin writing the solution like this:
extension(sc: StringContext)
def caps(?): String = ???
Because a pre-interpolated string can contain multiple expressions of any type, caps
needs to be defined to take a varags parameter of type Any
, so you can further write this:
extension(sc: StringContext)
def caps(args: Any*): String = ???
To define the body of caps
, the next thing to know is that the original string comes to you in the form of two different variables:
-
sc
, which is an instance ofStringContext
, and provides it data in an iterator -
args.iterator
, which is an instance ofIterator[Any]
This code shows one way to use those iterators to rebuild a String
with each word capitalized:
extension(sc: StringContext)
def caps(args: Any*): String =
// [1] create variables for the iterators. note that for an
// input string "a b c", `strings` will be "a b c" at this
// point.
val strings: Iterator[String] = sc.parts.iterator
val expressions: Iterator[Any] = args.iterator
// [2] populate a StringBuilder from the values in the iterators
val sb = StringBuilder(strings.next.trim)
while strings.hasNext do
sb.append(expressions.next.toString)
sb.append(strings.next)
// [3] convert the StringBuilder back to a String,
// then apply an algorithm to capitalize each word in
// the string
sb.toString
.split(" ")
.map(_.trim)
.map(_.capitalize)
.mkString(" ")
end caps
end extension
Here’s a brief description of that code:
-
First, variables are created for the two iterators. The
strings
variable contains all the string literals in the input string, andexpressions
contains values to represent all of the expressions in the input string, such as a$a
variable. -
Next, I populate a
StringBuilder
by looping over the two iterators in thewhile
loop. This starts to put the string back together, including all of the string literals and expressions. -
Finally, the
StringBuilder
is converted back into aString
, and then a series of transformation functions are called to capitalize each word in the string.
There are other ways to implement the body of that method, but I use this approach to be clear about the steps involved, specifically that when an interpolator like caps"a $b c ${d*e}"
is created, you need to rebuild the string from the two iterators.
Discussion
To understand the solution it helps to understand how string interpolation works, i.e., how the Scala code you type in your IDE is converted into other Scala code. With string interpolation, the consumer of your method writes code like this:
id"text0${expr1}text1 ... ${exprN}textN"
In this code:
-
id
is the name of your string interpolation method, which iscaps
in my case -
The
textN
pieces are string constants in the input (pre-interpolated) string -
The
exprN
pieces are the expressions in the input string that are written with the$expr
or${expr}
syntax
When you compile the id
code, the compiler translates it into code that looks like this:
StringContext("text0", "text1", ..., "textN").id(expr1, ..., exprN)
As shown, the constant parts of the string — the string literals — are extracted and passed as parameters to the StringContext
constructor. The id
method of the StringContext
instance — caps
, in my example — is passed any expressions that are included in the initial string.
As a concrete example of how this works, assume that you have an interpolator named yo
and this code:
val b = "b"
val d = "d"
yo"a $b c $d"
In the first step of the compilation phase the last line is converted into this:
val listOfFruits = StringContext("a ", " c ", "").yo(b, d)
Now the yo
method needs to be written like the caps
method shown in the Solution, handling these two iterators:
args.iterators contains: "a ", " c ", "" // String type
exprs.iterators contains: b, d // Any type
More Scala interpolators
For more details, my Github project for this book shows several examples of interpolators, including my Q
interpolator, which converts this multiline string input:
val fruits = Q"""
apples
bananas
cherries
"""
into this resulting list:
List("apples", "bananas", "cherries")
this post is sponsored by my books: | |||
#1 New Release |
FP Best Seller |
Learn Scala 3 |
Learn FP Fast |
See Also
-
This recipe uses extension methods, which are discussed in [methods-extension-methods-intro]
-
The initial part of the explanation in the Discussion is based on this hootenannylas.blogspot.com blog post