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 Int
s:
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 definedTime.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.