Scala pattern matching: apply the unapply
In my previous blog post, we delved under the hood of Scala for-comprehension. This time, we’ll take a similar approach and will become familiar with another great feature of this powerful programming language — pattern matching. Again, we’ll be doing it in a series of small steps, by writing some short snippets of code.
We begin by declaring a very simple case class that we will soon dissect:
case class FullName(first: String, last: String)
Among many other useful features of case class (such as structural
equals,
hashCode,
copy and
toString), Scala compiler provides support for the following code, which constructs and then deconstructs an instance of this case class:
val me = FullName("Linas", "Medžiūnas")
val FullName(meFirst, meLast) = me//meFirst: String = Linas
//meLast: String = Medžiūnas
Notice a nice symmetry here: when constructing,
me is on the left hand side, and
FullName(...) with two string parameters is on the right hand side of the assignment. When deconstructing, it is exactly the opposite.
When talking about Scala pattern matching, the first thing that comes to mind is the
match statement (which is similar to
switch /
case present in many other programming languages, just way more powerful, as we shall discover). However, there are many more places in Scala, where pattern matching comes to play: you can use it when defining lambda functions, also on the left hand side of for-comprehension generators, and even in assignment statements like in the example above. For the sake of simplicity, we will mostly use pattern matching within assignment statement in the rest of this post.
Now that we have our case class defined, and some code that is using it, we will try to understand what is so special about Scala case class, and what makes our code that’s using it to work. Sometimes, a really great way to understand how something works is to break it and then try to make it work again! Let’s do exactly that, by excluding the
case keyword from the definition of our
FullName class:
/*case*/ class FullName(first: String, last: String)
If you try this, you will find out that our code (both construction of value
me, and deconstruction of it) no longer compiles. In order to fix it, we will need to manually implement the features that were kindly provided to us by Scala compiler before the things started breaking down. We add a companion object for the
FullName class:
object FullName {
def apply(first: String, last: String): FullName =
new FullName(first, last)
def unapply(full: FullName): Some[(String, String)] =
Some((full.first, full.last))}
Companion object in Scala is a singleton that has the same name and resides in the same file as it’s companion class. And, companion object and it’s class have an access to private members of each other. Companion object is where you put the static members of your class (unlike Java, Scala does not have the
static modifier). This provides a more clear separation of static / instance members.
Note: we have to change our
FullName class definition slightly, in order to make
FullName.unapply compile:
/*case*/ class FullName(val first: String, val last: String)
Without this change,
first and
last would only serve as constructor arguments, and we wouldn’t be able to access them from
unapply. Adding
val before
first and
last turns them into both constructor arguments and instance fields (public by default) at the same time. This functionality, together with the companion object, was automatically generated for us by Scala compiler before we dropped the
case keyword.
Adding all that code manually now is enough to fix what we have broken. Let’s get into the details of the two methods we have just implemented:
def apply(first: String, last: String): FullName
apply is a special method name in Scala, which by convention can be omitted from your code. So,
FullName(...) is equivalent to
FullName.apply(...). We are using it to construct new instances of
FullName without needing the
new keyword.
def unapply(full: FullName): Some[(String, String)]
unapply does the opposite — it deconstructs an instance of
FullName, and is the foundation of pattern matching. We will focus on this method for the most of this article. In this case, it deconstructs
FullName into two string values, and wraps them in
Some, which means that it matches any instance of
FullName (we will explore the partial matching a bit later).
Once again, notice the symmetry of these two methods:
apply takes two strings as arguments, and returns an instance of
FullName.
unapply works in the opposite direction.
Now that we have a very basic understanding of what is
unapply and how it can be used for deconstruction / pattern matching, let’s find a good use case for it. Turns out, it is not that easy! In most cases, it is already taken care of by Scala — implementations of
unapply are provided not only for all the case classes that we write, but also for pretty much everything in the standard library of Scala, including collections (where applicable). And in fact, implementing your own
unapply is not that common, unless you are a developer of some interesting library. However, we can cheat a bit — let’s take something from the world of Java, where
unapply surely does not exist. Let’s take a few classes from
java.time and add the support for Scala pattern matching on them:
import java.time.{LocalDate, LocalDateTime, LocalTime}
It sounds very natural to be able to deconstruct a Date into a year, a month and a day, and a Time into an hour, minutes and seconds. Also, a DateTime — into a Date and a Time. With the knowledge we already have, it is pretty much straightforward. The only important detail is that we cannot use the names
LocalDate,
LocalDateTime and
LocalTime for creating the proper companion objects — they would need to reside in the same files as their classes, and since the classes come from Java standard library, it isn’t possible. In order to avoid name collisions, we simply omit
Local from the names of the objects that we implement:
object DateTime { def unapply(dt: LocalDateTime): Some[(LocalDate, LocalTime)] =
Some((dt.toLocalDate, dt.toLocalTime))}
object Date { def unapply(d: LocalDate): Some[(Int, Int, Int)] =
Some((d.getYear, d.getMonthValue, d.getDayOfMonth))}
object Time { def unapply(t: LocalTime): Some[(Int, Int, Int)] =
Some((t.getHour, t.getMinute, t.getSecond))}
Let’s try using them:
val Date(year, month, day) = LocalDate.nowval Time(hour, minute, second) = LocalTime.now
Both
LocalDate and
LocalTime get deconstructed into 3
Int values each, as intended. And if we only need some of the deconstructed values and not the others, we can use underscore in place of those that we don’t need:
val Date(_, month, day) = LocalDate.now
A more interesting case is nested deconstruction of
LocalDateTime:
val DateTime(Date(y, m, d), Time(h, mm, s)) = LocalDateTime.now
This gives us 6
Int values (3 for date part, and 3 for time).
Another feature of pattern matching that can sometimes be really useful is assignment of the whole value, which can be done besides the deconstruction. For our DateTime example it could look like this:
val dt @ DateTime(date @ Date(y, m, d), time @ Time(h, mm, s)) =
LocalDateTime.now
Besides the 6
Int values, we also get one value of
LocalDate, one — of
LocalTime, and finally the whole value of
LocalDateTime (in
dt).
In all the examples above, we were deconstructing into a fixed number of values — (year, month, day), or (hour, minute, second), or (date, time). There may be cases where we are working with a sequence of values, and not with some fixed number of them. This is also possible. I’ll try to illustrate that by deconstructing
LocalDateTime into a sequence of
Ints:
object DateTimeSeq { def unapplySeq(dt: LocalDateTime): Some[Seq[Int]] =
Some(Seq(
dt.getYear, dt.getMonthValue, dt.getDayOfMonth,
dt.getHour, dt.getMinute, dt.getSecond))}
unapplySeq is a variation of
unapply that deconstructs into a sequence of values instead of a fixed size
Tuple. In this example, the sequence always has a length of 6, but we can omit the tail of it in case we don’t need it:
val DateTimeSeq(year, month, day, hour, _*) = LocalDateTime.now
(by using
_* which is the syntax of Scala varargs).
Until now, our
unapply /
unapplySeq was always returning a
Some. It is time to introduce partial matching. For that, our
unapply will need to return
Some in case the value matches some criteria, and
None — in case it does not. We are already working with values of
LocalTime, and matching them into either AM or PM time would be a natural example:
object AM { def unapply(t: LocalTime): Option[(Int, Int, Int)] =
t match {
case Time(h, m, s) if h < 12 => Some((h, m, s))
case _ => None
}}
object PM { def unapply(t: LocalTime): Option[(Int, Int, Int)] =
t match {
case Time(12, m, s) => Some(12, m, s)
case Time(h, m, s) if h > 12 => Some(h - 12, m, s)
case _ => None
}}
Here,
case _ => is the default case, which gets evaluated in case nothing else matched. Notice how we reused our previously defined
Time.unapply under the hood of
AM /
PM? Also, we have just introduced two more features that we use for partial matching: guards (
case Time(h, m, s) if h < 12), and matching by a constant (
case Time(12, m, s)). I guess by now you already see what a powerful feature Scala pattern matching is!
To wrap this up, let’s implement ourselves a clock that formats the current time nicely, by using what we’ve learned about pattern matching, and our
AM /
PM extractors (plus some old school Java string formatting that almost looks like a stream of emojis):
LocalTime.now match { case t @ AM(h, m, _) =>
f"$h%2d:$m%02d AM ($t precisely)" case t @ PM(h, m, _) =>
f"$h%2d:$m%02d PM ($t precisely)"}
We have explored most of the features of Scala pattern matching. You can find the code which this blog post was based on in this gist. For even better understanding, play around with it in Scala worksheet of IntelliJ IDEA or Scala IDE for Eclipse (or online, using Scastie). And if you have some complicated, nested ifs and elses in your Scala code, try to restructure it better, by using what you have learned about the pattern matching today.