Programming in Scala [Chapter 15] — Case Classes and Pattern Matching

Tom van Eijk
7 min readNov 26, 2023

--

Photo by Jason Sung on Unsplash

Introduction

This is part of my personal reading summary of the book — Programming in Scala. Take into account, we use Scala 2 since it is in industry and has vastly more resources available than Scala 3.

This chapter introduces case classes and pattern matching in Scala, essential constructs for working with non-encapsulated data structures, especially useful for tree-like recursive data. Case classes facilitate pattern matching without excessive boilerplate, requiring only a “case” keyword addition to make objects pattern matchable. The chapter is structured in the following sections:

  1. A simple example
  2. Kinds of patterns
  3. Pattern guards
  4. Pattern overlaps
  5. Sealed classes
  6. The Option type
  7. Patterns everywhere

1. A simple example

The example in this section involves defining a hierarchy of classes to represent arithmetic expressions. Case classes, indicated by the “case” modifier, are employed for their syntactic conveniences, such as automatically generated factory methods and enhanced toString, hashCode, and equals implementations.

abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(operator: String, arg: Expr) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr

The author illustrates how case classes support pattern matching, making it convenient to work with complex data structures. A simplification function is implemented using pattern matching to demonstrate its power. The last case _ is a “catch all” case for any other possible expressions.

def simplifyTop(expr: Expr): Expr = expr match {
case UnOp("-", UnOp("-", e)) => e // Double negation
case BinOp("+", e, Number(0)) => e // Adding zero
case BinOp("*", e, Number(1)) => e // Multiplying by one
case _ => expr
}

The syntax of pattern matching is explained, where each alternative starts with the “case” keyword, followed by a pattern and expressions to be executed if the pattern matches.

expr match {
case BinOp(op, left, right) =>
println(expr +" is a binary operation")
case _ =>
}

2. Kinds of patterns

This section explores many different patterns in Scala. Therefore, we summarize all of them here compactly.

2.1 Wildcard patterns

  • The underscore (_) serves as a wildcard pattern, matching any object.
expr match {
case BinOp(_, _, _) => println(expr + " is a binary operation")
case _ => println("It's something else")
}

2.2 Constant patterns

  • Matches only specific literals or constants.
def describe(x: Any) = x match {
case 5 => "five"
case true => "truth"
case "hello" => "hi!"
case Nil => "the empty list"
case _ => "something else"
}

2.3 Variable patterns

  • Matches any object and binds a variable to it for further use.
expr match {
case 0 => "zero"
case somethingElse => "not zero: " + somethingElse
}

2.4 Constructor patterns

  • Powerful for matching case class constructors and supporting deep matches.
expr match {
case BinOp("+", e, Number(0)) => println("a deep match")
case _ =>
}

2.5 Sequence patterns

  • Matches against sequence types like List or Array.
expr match {
case List(0, _, _) => println("found it")
case _ =>
}

2.6 Tuple patterns

  • Matches against tuples of arbitrary length
def tupleDemo(expr: Any) =
expr match {
case (a, b, c) => println("matched " + a + b + c)
case _ =>
}

2.7 Typed patterns

  • Acts as a convenient replacement for type tests and type casts.
def generalSize(x: Any) = x match {
case s: String => s.length
case m: Map[_, _] => m.size
case _ => -1
}

2.8 Type erasure

  • Demonstrates the impact of type erasure on pattern matching, particularly in the context of maps.
def isIntIntMap(x: Any) = x match {
case m: Map[Int, Int] => true
case _ => false
}

2.9 Variable binding

  • Allows adding a variable to any pattern, enhancing expressiveness.
expr match {
case UnOp("abs", e @ UnOp("abs", _)) => e
case _ =>
}

3. Pattern guards

There can be situations where syntactic pattern matching in Scala may not be precise enough. For instance, attempting to create a simplification rule for replacing sum expressions with identical operands using a direct pattern match fails due to Scala’s restriction on patterns being linear. To overcome this, a pattern guard is introduced, allowing the formulation of rules based on additional conditions. The example below demonstrates this with a simplification rule for transforming expressions like BinOp("+", Var("x"), Var("x")) to BinOp("*", Var("x"), Number(2)). The guard, specified with if x == y, ensures the match only succeeds when the operands are equal.

// Attempted rule with direct pattern match (results in an error)
def simplifyAdd(e: Expr) = e match {
case BinOp("+", x, x) => BinOp("*", x, Number(2))
case _ => e
}

// Rule formulation with a pattern guard
def simplifyAdd(e: Expr) = e match {
case BinOp("+", x, y) if x == y =>
BinOp("*", x, Number(2))
case _ => e
}

4. Pattern overlaps

The code snippet below demonstrates the importance of the pattern order in a Scala match expression. The simplifyAll function recursively applies simplification rules to expressions, prioritizing specific cases before catch-all ones. The example ensures that catch-all cases follow more specific rules to avoid unintended matches. A compilation error is shown for an incorrectly ordered match in the simplifyBad function.

// Example of ordering patterns in a match expression
def simplifyAll(expr: Expr): Expr = expr match {
case UnOp("-", UnOp("-", e)) => simplifyAll(e) // Rule for double negation
case BinOp("+", e, Number(0)) => simplifyAll(e) // Rule for adding zero
case BinOp("*", e, Number(1)) => simplifyAll(e) // Rule for multiplying by one
case UnOp(op, e) => UnOp(op, simplifyAll(e)) // General rule for unary operations
case BinOp(op, l, r) => BinOp(op, simplifyAll(l), simplifyAll(r)) // General rule for binary operations
case _ => expr // Catch-all case
}

// Example of a compilation error due to incorrect pattern order
def simplifyBad(expr: Expr): Expr = expr match {
case UnOp(op, e) => UnOp(op, simplifyBad(e))
case UnOp("-", UnOp("-", e)) => e // Unreachable code, compiler error
}

5. Sealed classes

When using pattern matching in Scala, it’s crucial to cover all possible cases. While a default case can be added for sensible default behavior, ensuring completeness becomes challenging without a default. To address this, one can make the superclass of case classes sealed, preventing the addition of new subclasses except in the same file. This allows the Scala compiler to detect missing pattern combinations, providing better support.

sealed abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(operator: String, arg: Expr) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr

Now, consider a pattern match with missing cases:

def describe(e: Expr): String = e match {
case Number(_) => "a number"
case Var(_) => "a variable"
}

The compiler warns about incomplete matching, indicating potential runtime errors. To address this, a catch-all case can be added, but this may lead to unnecessary code. Alternatively, the @unchecked annotation can be applied to suppress exhaustiveness checking:

def describe(e: Expr): String = (e: @unchecked) match {
case Number(_) => "a number"
case Var(_) => "a variable"
}

6. The Option type

Scala provides the Option type for handling optional values, which can be of the form Some(x) with an actual value or None representing a missing value. This type is commonly used in Scala’s collections, such as the Map’s get method. Here’s an example:

val capitals = Map("France" -> "Paris", "Japan" -> "Tokyo")

// Using Option with Map's get method
val result1: Option[String] = capitals get "France" // Some(Paris)
val result2: Option[String] = capitals get "North Pole" // None

Pattern matching is a prevalent way to handle optional values in Scala. For instance:

def show(x: Option[String]): String = x match {
case Some(s) => s
case None => "?"
}

// Using the show function
val result3: String = show(capitals get "Japan") // Tokyo
val result4: String = show(capitals get "France") // Paris
val result5: String = show(capitals get "North Pole") // ?

7. Patterns everywhere

In Scala, patterns extend beyond standalone match expressions. They can be used in various contexts, such as variable definitions, case sequences as partial functions, and in for expressions.

7.1 Patterns in Variable Definitions

  • You can use patterns when defining variables with val or var.
// Destructuring a tuple.
val myTuple = (123, "abc")
val (number, string) = myTuple

// Example with case class
val exp = new BinOp("*", Number(5), Number(1))
val BinOp(op, left, right) = exp

7.2 Case Sequences as Partial Functions

  • A sequence of cases in curly braces can act as a partial function.
// Example of partiak function
val withDefault: Option[Int] => Int = {
case Some(x) => x
case None => 0
}

// Example of partial function with a pattern
val second: PartialFunction[List[Int], Int] = {
case x :: y :: _ => y
}

7.3 Patterns in For Expressions

  • You can use patterns in for expressions to destructure values.

// Destructure values example
for ((country, city) <- capitals)
println("The capital of " + country + " is " + city)

// Note that the match against the pair pattern never
// fails as capitals always yields pairs.

// Example where a pattern might not match:
val results = List(Some("apple"), None, Some("orange"))
for (Some(fruit) <- results) println(fruit)

Concluding Thoughts

Case classes simplify the creation of data structures, enabling pattern matching with minimal boilerplate. The diverse forms of patterns, such as wildcard, constant, variable, constructor, sequence, tuple, typed, and variable binding patterns, offer flexibility in expressing matching conditions. Pattern guards enhance precision by introducing conditions within patterns, addressing situations where syntactic matching alone may not suffice. Sealed classes play a pivotal role in ensuring pattern matching completeness by restricting the subclasses, aiding the compiler in detecting missing pattern combinations. The Option type, a key feature in Scala for handling optional values, is explored alongside pattern matching as a powerful tool for managing absence or presence of values. The broad applicability of patterns extends beyond standalone match expressions, finding use in variable definitions, case sequences as partial functions, and within for expressions.

With this, we conclude the fifteenth chapter of this series. I hope it adds value to people who are interested in learning Scala.

Please don’t hesitate to contact me on LinkedIn with any comments or feedback.

Other chapters in this series can be found in this reading list.

Resources:

Odersky, M., Spoon, L., & Venners, B. (2008). Programming in scala. Artima Inc.

--

--

Tom van Eijk

Data Enthusiast who loves to write data engineering blogs for learning purposes