Last updated on

Pattern Matching

Welcome to week 3 of CS-214 — Software Construction!

This exercise set is intended to help you practice pattern matching.

In previous exercises and labs, we used if conditionals and . field accessors to write functions on data types such as lists or trees. This week, we’ll use pattern matching to make these functions more succinct and readable. We’ll move from this:

def reduceIf(f: (Int, Int) => Int)(l: IntList): Int =
  if l.isEmpty then throw IllegalArgumentException("Empty list!")
  else if l.tail.isEmpty then l.head
  else f(l.head, reduceIf(f)(l.tail))

src/main/scala/patmat/IntList.scala

to this:

def reduceMatch(f: (Int, Int) => Int)(l: IntList): Int =
  l match
    case IntNil              => throw IllegalArgumentException("Empty list!")
    case IntCons(hd, IntNil) => hd
    case IntCons(hd, tl)     => f(hd, reduceMatch(f)(tl))

src/main/scala/patmat/IntList.scala

Most functional programmers find the second one much more readable, because it aligns the way the data is destructed (taken apart into a head and a tail) and the way the data is constructed (assembled from a head and a tail):

def constructDestruct =
  IntCons(1, IntCons(2, IntNil)) match
    case IntCons(a, IntCons(b, IntNil)) =>
      println(f"Found $a and $b")
    case _ => throw Exception("Not possible!")

src/main/scala/patmat/IntList.scala

Previously, we wrote IntNil() for empty IntLists. Now that we know about enums and case classes, we can use the more succinct and convenient syntax IntNil (no parentheses).

If you find yourself looking for more examples of pattern matching after completing this set, consider revisiting week 1 and week 2 exercises and redefining all the functions with pattern matching — it’s a great exercise.

Weekdays

Days of the week are a great example of simple enumerations. So, which day is tomorrow? let’s implement a function to find out.

The enum representing weekdays is defined as follows:

enum Weekday:
  case Monday
  case Tuesday
  case Wednesday
  case Thursday
  case Friday
  case Saturday
  case Sunday

src/main/scala/patmat/Weekday.scala

Complete the following two functions:

  1. next returns the next day of the week:

      def next(d: Weekday): Weekday =
        ???
    

    src/main/scala/patmat/WeekdayOps.scala

  2. prev returns the previous day of the week:

      def prev(d: Weekday): Weekday =
        ???
    

    src/main/scala/patmat/WeekdayOps.scala

Want to test your code? Run testOnly WeekdayOpsTest in sbt.

This exercise is taken from Logical Foundations, a book about mathematical proofs of programs, and translated into Scala.

Tri-booleans ⭐️

By now you’re very familiar with Booleans. But in real life, not every thing is Yes or No: sometimes we just don’t know! Tri-boolean logic helps with this by adding an indeterminate value, Maybe:

enum TriBool:
  case Yes
  case No
  case Maybe

src/main/scala/patmat/TriBool.scala

Maybe is like a Boolean value that is not yet known.

  1. Take the time to think of how Maybe combine with Yes and No.

    • If I have two objects, one which is for sure blue, and one that may be blue, then can I say that I have at least one blue item? Is that for sure or maybe? What does this entail about the “or” operator on tri-Booleans?

    • If I have the same two objects (one blue, one maybe blue), can I conclusively say that not all or my objects are blue? Can I promise that they are all blue? What does this entail about the “and” operator on tri-Booleans?

  2. Implement the following operations (we expect that you’ll find pattern-matching very nice for that!)

      def neg(b: TriBool): TriBool =
        ???
    

    src/main/scala/patmat/TriBoolOps.scala

      def and(b1: TriBool, b2: TriBool): TriBool =
        ???
    

    src/main/scala/patmat/TriBoolOps.scala

      def or(b1: TriBool, b2: TriBool): TriBool =
        ???
    

    src/main/scala/patmat/TriBoolOps.scala

      def nand(b1: TriBool, b2: TriBool): TriBool =
        ???
    

    src/main/scala/patmat/TriBoolOps.scala

    Want to test your code? Run testOnly TriBoolOpsTest in sbt.

    nand is a very surprising operator. If you’re not familiar with it, inspect the test cases, or read more about it!

  3. Try proving on paper the following properties of these functions:

    • For any a, neg(neg(a)) = a.
    • For any a and b, neg(and(a, b)) = or(neg(a), neg(b)).
    • For any a and b, neg(or(a, b)) = and(neg(a), neg(b)).

Contexts ⭐️

Now that you have a bit of experience with pattern matching, let’s use it to construct more complex types. In this exercise we’ll study contexts, which are essentially lists of keys and values. This exercise is particularly useful for this week’s lab.

Our contexts associate names (Strings) with values (Ints), and let us do the following:

For instance, we can have a context which associates "x" with 1 and "y" with 2. We can then look up the keys: looking up "x" in the context produces 1, looking up "y" produces 2, and looking up "z" or any other name results in a not-found error. We can add more mapping in the context: after associating "z" with 3, looking "z" up in the new context will result in 3, instead of an error.

Think of what Scala types and features you may use to represent a context before proceeding.

Reveal our choice of representation

We’ll start by representing contexts using and enum (and we’ll see another representation later):

enum Context:
  case Empty
  case Cons(name: String, value: Int, tail: Context)

src/main/scala/patmat/EnumContext.scala

We have two cases:

  • Empty, which represents an empty context;
  • Cons, which forms a new context by associating a new name with its value in an existing context.

For example, the context that associates "x" with 1 and "y" with 2 can be represented as Cons("x", 1, Cons("y", 2, Empty)), or Cons("y", 2, Cons("x", 1, Empty)).

If you want to experiment with contexts in the playground, make sure to add import EnumContext.* to bring Cons and Empty into the worksheet’s scope.

In our representation, how do you represent a new context with the additional binding associating "z" to 3 on top of the other bindings in our example?

Solution

Cons("z", 3, Cons("x", 1, Cons("y", 2, Empty)))

When multiple bindings in a context have the same name, only the outermost is effective. For instance, given a context Cons("a", 1, Cons("b", 2, Cons("a", 3, Empty))), looking up "a" this context should return 1 instead of 3.

Implement the following three functions:

Want to test your code? Run testOnly EnumContextTest in sbt.

Tree Mapping and Tree Reducing

In week 1 we worked with trees using a clunky API of .left, .right, and .isEmpty. This time, let’s do it the right way. And, to mix things up a bit, we’ll see a tree that contains values only at the leaves, instead of in every node (revisiting the examples from week 1 is another great exercise, though!)

The IntTree enum is defined as the following:

enum IntTree:
  case Leaf(value: Int)
  case Branch(left: IntTree, right: IntTree)

src/main/scala/patmat/IntTree.scala

Here is an example:

example
Example Tree

What is the enum representation of the tree below?

Solution Branch(Branch(Leaf(1), Leaf(2)), Leaf(3))

Want to test your code? Run testOnly IntTreeOpsTest in sbt.

Tree Mapping ⭐️

Let’s start with the treeMap function:

  def treeMap(tree: IntTree, op: Int => Int): IntTree =
    ???

src/main/scala/patmat/IntTreeOps.scala

It takes a tree and a function, and creates a new tree by applying the function on the value of each leaf node in the tree.

For instance, let’s name the tree we just saw in the diagram as t. The following diagram dipicts the computation of treeMap(t, x => x + 1).

treeMap treeMap
Computation of treeMap(t, x => x + 1)

Tree Reducing

The second task is the treeReduce function:

  def treeReduce(tree: IntTree, op: (Int, Int) => Int): Int =
    ???

src/main/scala/patmat/IntTreeOps.scala

Given a tree and an operator, the function reduces the values in the tree with the operator. To see what that means, let’s illustrate the call treeReduce(t, (a, b) => a + b) in diagrams, which essentially computes the sum of all values in the leaf node of t.

treeReduce treeReduce
Computation of treeReduce(t, (a, b) => a + b)

Intuitively, it is a bottom-up aggregation of values in the leaves. The values flow from the bottom to the root. At each branch node, the aggregated results from the two children are merged using op, the operator supplied to treeReduce.

But many of us prefer to think of it as a “pull” operation: to compute its own value, each internal node “pulls” the values of the subtrees by making recursive call.

A bit more formally,

Map and Reduce

The last task is the treeMapReduce function, which first maps the stores in each leaf with the mapper, then reduces these mapped values using reducer.

Start with a version that maps values to strings, then reduces the strings on the leaf to the result string:

  def treeMapReduce(tree: IntTree, mapper: Int => String, reducer: (String, String) => String): String =
    ???

src/main/scala/patmat/IntTreeOps.scala

Now implement a version on Doubles:

  def treeMapReduceDouble(tree: IntTree, mapper: Int => Double, reducer: (Double, Double) => Double): Double =
    ???

src/main/scala/patmat/IntTreeOps.scala

Are there similarities? Soon we will learn how to unify the two.

IntList

Let’s implement functions on IntList again, this time with pattern matching. Before starting this part of exercises, we suggest you implementing several IntList questions from previous weeks’ exercises using pattern matching to warm yourself up!

Want to check answers in this section on computer? Test them with testOnly IntListOpsTest.

polishEval

First, rewrite polishEval (from week 1) with pattern-matching:

  def polishEval(l: IntList): (Int, IntList) =
    ???

src/main/scala/patmat/IntListOps.scala

Throw InvalidOperationNumber exception if the operator is not defined, and InvalidExpression if the input is not a valid polish-notation expression.

Compare your version new version with the original if-based implementation. Which one is more readable?

zipWith ⭐️

map, fold, and other functions that we have previously studied operate on a single list. We have already seen that some of these generalize to trees; here, we will see how one of them (map) generalizes to multiple lists. This function is called zipWith in Scala:

  def zipWith(l1: IntList, l2: IntList, op: (Int, Int) => Int): IntList =
    ???

src/main/scala/patmat/IntListOps.scala

Here is one possible specification for zipWith, using l[n] to mean “the n-th element of list l”:

Given two lists xs and ys and an operator op, let zs be zipWith(xs, ys, op). Then, zs should have length min(length(xs), length(ys)), and the i-th element of zs should be op(xs[i], ys[i]) for all i.

Note the part about the length of zs. For instance, zipWith(IntNil, IntCons(1, Nil), (x, y) => x + y) should equal to IntNil.

Implement zipWith. Do you see a connection with map?

A modular implementation of zipWith ⭐️

zipWith does a lot of work at once, and we can make it a bit more modular. For this, let’s define an intermediate type IntIntList of lists of pairs of ints:

enum IntIntList:
  case IntIntNil
  case IntIntCons(xy: (Int, Int), xs: IntIntList)
import IntIntList.*

src/main/scala/patmat/IntList.scala

  1. Define a function zip to construct a list of pairs from a pair of lists:

      def zip(l1: IntList, l2: IntList): IntIntList =
        ???
    

    src/main/scala/patmat/IntListOps.scala

  2. Define a function unzip to construct a pair of lists from a list of pairs:

      def unzip(l: IntIntList): (IntList, IntList) =
        ???
    

    src/main/scala/patmat/IntListOps.scala

  3. Define a function map2to1 on lists of pairs that produces a list of ints:

      def map2to1(op: (Int, Int) => Int)(l: IntIntList): IntList =
        ???
    

    src/main/scala/patmat/IntListOps.scala

  4. Rewrite zipWith using (some of) the functions above.

      def zipThenWith(l1: IntList, l2: IntList, op: (Int, Int) => Int): IntList =
        ???
    

    src/main/scala/patmat/IntListOps.scala

  5. Use zipWith to implement a function movingWindow on lists that returns sequences of consecutive pairs in its input list. For example, movingWindow applied to the list a b c d e should produce the list ab bc cd de.

      def movingWindow(l: IntList): IntIntList =
        ???
    

    src/main/scala/patmat/IntListOps.scala

  6. What relation is there between zip and unzip? What properties can you prove?

extractSecond ⭐️

Enumerations and case classes are often useful to distinguish multiple kinds of results. In an imperative language this would often be done with an exception: a function returns a result, and may throw various exceptions if the result cannot be computed. In Scala (and functional programming), we tend to use case classes instead, with one case per kind of result.

For example, a function that extracts the second element of a list might return a type like the following:

enum ExtractResult:
  case SecondElem(i: Int)
  case NotLongEnough
  case EmptyList

src/main/scala/patmat/IntListOps.scala

When the input list is empty, extractSecond returns EmptyList; when the input list is not long enough to have a second element (i.e. it only has one element), extractSecond returns NotLongEnough; finally, when the input list has a second element, the function returns it.

Implement extractSecond:

  def extractSecond(l: IntList): ExtractResult =
    ???

src/main/scala/patmat/IntListOps.scala

How would you implement extractSecond with if? Jot down your implementation and compare it with the pattern-matching-based one. Is one more readable?

Natural Numbers 🔥

There are lots of encodings for natural numbers (that is, integers that is greater than or equal to zero). Today we will study one of the simplest one:

enum UnaryNat:
  case Zero
  case Succ(pred: UnaryNat)

src/main/scala/patmat/UnaryNat.scala

Basically, there are two cases:

One may think of a natural encoded in this way as a Matryoshka doll:

Then, the number represented by a UnaryNat is the degree of nesting of the doll (how many nested dolls there are). For example, 2 is represented as Succ(Succ(Zero)).

What is the representation of 5?

Answer

Succ(Succ(Succ(Succ(Succ(Zero)))))

This is the second time that we mention Matryoshka dolls in this class. The first one was for lists… can you see a connection between lists and numbers encoded in this way? To see it more clearly, write the type definition for IntLists with an enum, and compare it to the definition above. What differences are there?

Going further: what does the length function on lists translate to on UnaryNats?

Answer

UnaryNats are just like lists, but without the data. Zero is like the Nil case and Succ the Cons case, with pred being the “tail” of the number. And the length of a UnaryNat is its representation as a native Int!

Implement the following functions:

  1. Conversion to/from integers:

    1. Here is an implementation of fromInt. Is anything wrong with it?

      def fromInt(n: Int): UnaryNat =
        n match
          case 0 => Zero
          case _ => Succ(fromInt(n - 1))
      

      src/main/scala/patmat/UnaryNatOps.scala

    2. Implement toInt.

        def toInt(n: UnaryNat): Int =
          ???
      

      src/main/scala/patmat/UnaryNatOps.scala

  2. Addition between UnaryNats

      def add(n: UnaryNat, m: UnaryNat): UnaryNat =
        ???
    

    src/main/scala/patmat/UnaryNatOps.scala

  3. Multiplication between UnaryNats; for this, we have already provided you with a erroneous implementation: help us fix it… or write your own from scratch!

      def multiply(n: UnaryNat, m: UnaryNat): UnaryNat =
        m match
          case Zero => n
          case Succ(m0) => add(n, multiply(n, m0))
    

    src/main/scala/patmat/UnaryNatOps.scala

    • Run the test (testOnly -- "*multiply*") to find a failing test case. Use it to pinpoint the problem.
    • Use println to inspect how the function is recursively invoked.
  4. Subtraction between UnaryNats, saturating at zero (this means that minus(x, y) == max(0, x - y). For instance, minus(Succ(Zero), Succ(Succ(Zero))) == Zero.

      def minus(n: UnaryNat, m: UnaryNat): UnaryNat =
        ???
    

    src/main/scala/patmat/UnaryNatOps.scala

  5. Parity checks:

      def isEven(n: UnaryNat): Boolean =
        ???
    

    src/main/scala/patmat/UnaryNatOps.scala

      def isOdd(n: UnaryNat): Boolean =
        ???
    

    src/main/scala/patmat/UnaryNatOps.scala

To test your answers on computer, run testOnly UnaryNatOpsTest.

How would you implement isEven and isOdd with if?

Compare the pattern-match-based and if-based implementations: which ones look better in terms of conciseness and readability?

Binary Search Tree 🔥

We have seen contexts which associates names with associated values. Looking up a name in both enum-based and function-based contexts takes $\mathrm{O}(n)$, where $n$ is the number of bindings. This can be suboptimal if the context is frequently looked up. Is there a better solution?

If we know the relations between the names, we can arrange them in a smarter way. For instance, we can sort and distribute the names within a tree structure. This can significantly improve the efficiency of the lookup operation, especially when compared to searching through every element in a linked list. Binary Search Tree (BST) is a data structure that stores key-value bindings and supports efficient lookup operation. In this exercise we consider BSTs that associate integers to strings.

What are BSTs? Let’s see examples! The following trees stores the bindings 0 -> "a", 1 -> "b" and 2 -> "c". Here 0 -> "a" means a binding which associates 0 (the key) with "a" (the value).

bst1
Illustration of a BST.

Each box denotes a node, whose left part is the key and right part the value.

And this one stores 0 -> "a", 1 -> "b", 2 -> "c", 3 -> "d":

bst2
Illustration of another BST.

Do you have any clues for the principle of BSTs? Have a guess.

See the principle

Given each node in a BST whose key is k:

bstprinciple

All keys in its left child is less than k, and all keys in its right child is greater than k.

Find a BST that stores 0 -> "a", 1 -> "b", 2 -> "c", 3 -> "d" and 4 -> "e".

The answer is not unique, any tree that conforms to the principle and stores these bindings works. Here is a reference answer.

reference tree
bst3

The following shows the Scala enum representing BSTs:

enum BST:
  case Leaf
  case Branch(k: Int, v: String, l: BST, r: BST)

src/main/scala/patmat/BST.scala

It closely mirrors the structure of the trees shown above:

Now you task is to complete the following operations.

Basic operations

  def find(tree: BST, key: Int): FindResult =
    ???

src/main/scala/patmat/BSTOps.scala

  def insert(tree: BST, key: Int, value: String): BST =
    ???

src/main/scala/patmat/BSTOps.scala

To get you a feeling of why looking up a BST is fast, consider the following questions.

In the worst case, how many steps it will take to lookup a BST of depth $d$?

answer

$d$.

How many bindings a BST of depth $d$ can at most store?

answer

$2^d-1$.

If we were to store that many of bindings in a context-like structure, what is the worst-case lookup time?

answer

$2^d-1$.

Rotation

We have seen that lookup is efficient in BST. But there is a pitfall: in fact, a BST can degenerate into a linked list, and in that case the lookup operation is no more efficient than looking up a context.

For instance, the following BST, which stores the same set of bindings as one we saw before, conforms perfectly to the principle of BST but has poor performance for looking up.

bst4
A degenerated BST.

Therefore, one shall prefer a balanced BST over an imbalanced one. The problem then becomes: how one can balance a BST?

The rotation operation addresses this problem. How rotation works? See the following diagram:

rotation
Illustration of rotation.

It illustrates two rotation operations: left- and right-rotation. We color the root node in a tree in blue. Take left-rotation as an example. Given an input tree in the form of the left hand side, it transforms the tree into the right hand side. The tree root changes from k1 to k2. You can verify that, as long as the tree on LHS is a BST, the RHS conforms to the BST principle as well.

The following ordering holds:

All keys in P < k1 < All keys in Q < k2 < All keys in R.

What does the tree look like if we right-rotate the aforementioned degenerated BST?

What if we right-rotate twice?

Now, implement the following functions.

  def rotateLeft(tree: BST): BST =
    ???

src/main/scala/patmat/BSTOps.scala

  def rotateRight(tree: BST): BST =
    ???

src/main/scala/patmat/BSTOps.scala

To learn more about tree rotation and the way they are used to maintain the balance of a tree, this link is for you!

How to implement rotateLeft with if?

Compare the pattern-match-based and if-based implementations in terms of readability.

Contexts as Functions 🔥

Previously, we have seen how to represent contexts as Scala enums. Now you will try another alternative: representing them with functions!

type Context =
  String => LookupResult

src/main/scala/patmat/FuncContext.scala

This exercise takes a lot of thinking, so we recommend pondering with other students, and reading the hint below.

It may seem odd to represent data with functions. To understand better what this means, you can think of it this way: what we are doing is reducing a type (contexts) to its most fundamental operation (lookups). This becomes a bit more obvious when we think of the lookup method:

  • lookup has type (Context, String) => LookupResult.
  • If we curried it, it would have type Context => String => LookupResult
  • Now if we partially apply it, we get a new function lookupCurried(ctx) of type String => LookupResult

This last type is precisely what we call a “context” in this exercise.

Implement the following four functions for function-based contexts:

  def empty: Context =
    ???

src/main/scala/patmat/FuncContext.scala

  def cons(name: String, value: Int, rem: Context): Context =
    ???

src/main/scala/patmat/FuncContext.scala

  def lookup(ctx: Context, name: String): LookupResult =
    ???

src/main/scala/patmat/FuncContext.scala

  def erase(ctx: Context, name: String): Context =
    ???

src/main/scala/patmat/FuncContext.scala

Want to test your code? Run testOnly FuncContextTest in sbt.

The following additional questions can help further your understanding: