Last updated on

Week 5: Comprehensions, Variance and Specs

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

As before, exercises or questions marked ⭐️ are the most important, 🔥 are the most challenging, and 🧪 are the most useful for this week’s lab.

We strongly encourage you to solve the exercises on paper first, in groups. After completing a first draft on paper, you may want to check your solutions on your computer. To do so, you can pull from the course exercises repository.

Comprehensions

for comprehensions

Scala does not have for loops; instead, it has for comprehensions. Comprehensions are particularly powerful when you need to combine and filter results from multiple collections. Let’s see some examples!

Warm-up ⭐️

In previous weeks we saw how to filter, map over, and flatten lists using recursive functions and List API functions. This week, we have a new way to perform these computations: comprehensions.

Just 3 (filters) 🧪

Using a for comprehension, write a function that filters a list of words (represented as Strings), keeping only words of length 3:

def onlyThreeLetterWords(words: List[String]): List[String] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

def onlyThreeLetterWords(words: List[String]): List[String] =
  for
    word <- words
    if word.length() == 3
  yield word

comprehensions/src/main/scala/comprehensions/forcomp.scala

LOUDER (maps)

Using a for comprehension, write a function that converts a list of words (represented as Strings) to uppercase, using the .toUpperCase() method:

def louder(words: List[String]): List[String] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

For example List("a", "bc", "def", "ghij") should become List("A", "BC", "DEF", "GHIJ").

When using internationalization-related methods, such as .toUpperCase(locale) or .toLowerCase(locale), always be careful about which locale parameter you use.

If omitted, locale defaults to the user’s current locale. On some computers, this will create issues with our tests: for example, on a Turkish system, calling "TITLE".toLowerCase() will yield "tıtle", not "title" (you can reproduce this behavior by passing locale = Locale("tr")).

Locale-related bugs are a common source of issues in real-world apps — think hard about the right value, and if writing locale-independent code, use the standardized invariant locale Locale.ROOT.

def louder(words: List[String]): List[String] =
  for word <- words
  yield word.toUpperCase(Locale.ROOT)

comprehensions/src/main/scala/comprehensions/forcomp.scala

Echo (flatMaps)

Using a for comprehension, write a function that repeats each word in a list of words n times. Write a comprehension with two separate <- clauses, and use either Iterable.fill(n)(word) to create an iterable of n times the value word, or (1 to n) to iterate n times.

def echo(words: List[String], n: Int): List[String] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

For example List("a", "bc", "def", "ghij") should become List("a", "a", "bc", "bc", "def", "def", "ghij", "ghij") if n == 2.

What should happen if n == 1? How about n == 0?

Solution

For n == 1, the resulting list is the same as the source string. For n == 0, the resulting list is empty.

def echo(words: List[String], n: Int): List[String] =
  for
    word <- words
    _ <- (1 to n)
  yield word

comprehensions/src/main/scala/comprehensions/forcomp.scala

All together now 🧪

Using a for comprehension, write a function that converts all words in a list to upper case, removes all words whose length is not three, and repeats all others n times:

def allTogether(words: List[String], n: Int): List[String] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

For example List("All", "together", "now") should become List("ALL", "ALL", "ALL", "NOW", "NOW", "NOW") if n is 3.

def allTogether(words: List[String], n: Int): List[String] =
  for
    word <- words
    if word.length() == 3
    upper = word.toUpperCase(Locale.ROOT)
    _ <- (1 to n)
  yield upper

comprehensions/src/main/scala/comprehensions/forcomp.scala

Cross product

Reimplement the cross-product function from last week using a for comprehension.

Additionally, your function should now take Scala Lists as input, and return a Scala List as output.

def crossProduct[A, B](l1: List[A], l2: List[B]): List[(A, B)] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

def crossProduct[A, B](l1: List[A], l2: List[B]): List[(A, B)] =
  for
    a <- l1
    b <- l2
  yield (a, b)

comprehensions/src/main/scala/comprehensions/forcomp.scala

Permutations

Reimplement the distinctPairs function from last week using a for comprehension.

def distinctPairs[A](items: Seq[A]): Seq[(A, A)] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

def distinctPairs[A](items: Seq[A]): Seq[(A, A)] =
  for
    a <- items
    b <- items
    if a != b
  yield (a, b)

comprehensions/src/main/scala/comprehensions/forcomp.scala

Comprehensions on Option ⭐️

When thinking about a for comprehension, the most intuitive use of it would be on a list. But it can be used on any type that defines map, flatMap and, optionally, withFilter. In particular, we can use for comprehensions on Option values.

For this exercise, we provide you with a function that parses a string into an Option of integer. For example, parseInt("123") returns Some(123), but parseInt("abc") returns None. You do not need to understand the inner works of the function, as we will learn more about Try later 🔜.

def parseInt(s: String): Option[Int] =
  Try(s.toInt).toOption

comprehensions/src/main/scala/comprehensions/forcomp.scala

Implement the validInts function, that parses a list of strings to integers and returns only valid integers.

def validInts(strings: List[String]): List[Int] =
  ???

comprehensions/src/main/scala/comprehensions/forcomp.scala

def validInts(strings: List[String]): List[Int] =
  for
    s <- strings
    i <- parseInt(s)
  yield i

comprehensions/src/main/scala/comprehensions/forcomp.scala

Glob matching (ungraded callback to find!)

The real Unix find command takes a “glob pattern” for its -name filter: it supports wildcards like ? and * to allow for partial matches. For example,

Write a function glob(pattern, input) that returns a boolean indicating whether a glob pattern matches a string (represented as a list of Chars):

def glob(pattern: List[Char], input: List[Char]): Boolean =
  ???

comprehensions/src/main/scala/comprehensions/Glob.scala

The rules are as follows:

The whole pattern must match the whole string: partial matches are not allowed.

Implementation guide

This is a tricky problem at first sight, but it admits a very nice recursive solution. Think of it in groups: how can you reduce the problem of matching a string against a pattern to a problem with a smaller string, or a smaller pattern?

Based on your implementation, can you prove that a pattern that does not contain wildcards matches only itself? That * matches all strings? That a pattern with only ?s matches all strings of the same length as the pattern? That repeated *s can be replaced by a single *?

If you plug in your new function into your copy of find, you’ll get an even better file-searcher! It should be a very straightforward refactoring — just change the function passed to the higher-order find function that we wrote in the last callback.

def glob(pattern: List[Char], input: List[Char]): Boolean =
  (pattern, input) match
    case (Nil, Nil)             => true
    case (Nil, _)               => false
    case ('?' :: pat, i :: inp) => glob(pat, inp)
    case ('*' :: pat, Nil)      => glob(pat, input)
    case ('*' :: pat, i :: inp) => glob(pat, input) || glob(pattern, inp)
    case (p :: pat, Nil)        => false
    case (p :: pat, i :: inp)   => p == i && glob(pat, inp)

comprehensions/src/main/scala/comprehensions/Glob.scala

Des chiffres et des lettres

Des chiffres et des lettres” is a popular TV show in French-speaking countries.

In this show, contestants take turns guessing the longest word that can be made from a list of letters, and finding a way to arrange a list of numbers into an arithmetic computation to get as close as possible to a target number. For example:

Des lettres 🧪

Write a function longestWord which, given a wordlist (a collection of words) represented as a List[String] and a collection of letters represented as a String, finds the longest word that can be made by reordering a subset of these strings. You can write your function directly, or follow the steps below.

Reveal step-by-step hints

Let’s represent collections of letters by converting them to uppercase and sorting them, so that "Polytechnique" becomes "CEEHILNOPQTUY". We call "Polytechnique" the “original” word, and "CEEHILNOPQTUY" the “scrambled” word.

  1. Write a function scramble that transforms a single word into its scrambled representation.

    def scramble(word: String): String =
      ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

    Hint

    You may find .toUpperCase() and .sorted useful.

  2. Write a function scrambleList that transforms a wordlist into a Map from scrambled words to sets of original words.

    For example, Set("Nale", "lean", "wasp", "swap") should become Map("AELN" -> Set("Nale", "lean"), "APSW" -> Set("wasp", "swap"))

    def scrambleList(allWords: Set[String]): Map[String, Set[String]] =
      ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

    Hint

    Consider using .groupBy to create the map.

  3. Write a function exactWord that returns all words of a wordlist that can be formed by using all letters from a given collection of letters.

    def exactWord(allWords: Set[String], letters: String): Set[String] =
      ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

  4. Write a function compatible that checks whether a scrambled word can be formed from a collection of letters. Beware of repeated letters!

    def compatible(small: String, large: String): Boolean =
      ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

  5. Write a function longestWord that returns the longest word that can be formed using some letters from a given collection of letters.

    def longestWord(allWords: Set[String], letters: String): Set[String] =
      ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def scramble(word: String): String =
  word.toUpperCase(Locale.ROOT).sorted

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def scrambleList(allWords: Set[String]): Map[String, Set[String]] =
  allWords.groupBy(scramble)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def exactWord(allWords: Set[String], letters: String): Set[String] =
  scrambleList(allWords).getOrElse(scramble(letters), Set.empty)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def compatible(small: String, large: String): Boolean =
  (small, large) match
    case ("", _) => true
    case (_, "") => false
    case _ =>
      if small.head == large.head then compatible(small.tail, large.tail)
      else if small.head > large.head then compatible(small, large.tail)
      else false

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def longestWord(allWords: Set[String], letters: String): Set[String] =
  val haystack = scramble(letters)
  val words =
    for
      (needle, words) <- scrambleList(allWords)
      if compatible(needle, haystack)
    yield words
  words.maxByOption(_.head.length).getOrElse(Set.empty)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Des chiffres 🔥

Write a function leCompteEstBon that takes a List of integers and a target number, and returns an arithmetic expression that evaluates to the target sum, if one exists. The allowable operators are +, - (only when the result is nonnegative), *, and / (only when the division is exact), as well as parentheses. For the purpose of this exercise, assume that we are only interested in exact results, using all provided integers. The result should be an expression of type Expr:

The Expr type

We have provided you with an Expr type as a starting point:

trait Expr:
  val value: Option[Int]

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Notice that each Expr reports its own value as an Option[Int]: this is because this type has two direct subclasses: numbers and binary operators:

case class Num(n: Int) extends Expr:
  val value = Some(n)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

abstract class Binop extends Expr:
  val e1, e2: Expr // Subexpressions
  def op(n1: Int, n2: Int): Option[Int] // How to evaluate this operator

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

The value of a binop is defined thus, using for to unpack options:

val value: Option[Int] =
  for
    n1 <- e1.value
    n2 <- e2.value
    r <- op(n1, n2)
  yield r

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Finally, the four arithmetic operators are subclasses of Binop:

case class Add(e1: Expr, e2: Expr) extends Binop:
  def op(n1: Int, n2: Int) =
    Some(n1 + n2)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

case class Sub(e1: Expr, e2: Expr) extends Binop:
  def op(n1: Int, n2: Int) =
    if n1 < n2 then None else Some(n1 - n2)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

case class Mul(e1: Expr, e2: Expr) extends Binop:
  def op(n1: Int, n2: Int) =
    Some(n1 * n2)

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

case class Div(e1: Expr, e2: Expr) extends Binop:
  def op(n1: Int, n2: Int) =
    if n2 != 0 && n1 % n2 == 0 then Some(n1 / n2) else None

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Note how - and / sometimes return None.

Our solution is short, and uses a combination of for comprehensions and recursion. Take the time to think through how you might divide this problem into smaller subproblems.

Reveal possible approaches

There are broadly two possible approaches: a top-down one, which splits the input set into two halves, and combines them with an operator; or a bottom-up one, which combines numbers into increasingly larger expression trees.

Reveal step-by-step hints (top-down)
  1. Write a recursive function partitions which generates all partitions of a list into two non-overlapping sublists.

    def partitions[A](l: List[A]): List[(List[A], List[A])] =
        ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

    Hint

    This function must decide, for each element, whether it goes into the left or the right partition.

  2. Write a recursive function allTrees that generates all possible trees of expressions from a set of numbers using partitions.

    def allTrees(ints: List[Int]): List[Expr] =
        ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

    Hint

    At each step, this function should call itself recursively twice, once per subset, and generate one tree per operator.

  3. Write a recursive function leCompteEstBon that finds an expression among the possible ones that match the target number, or returns None if the target cannot be achieved:

    def leCompteEstBon(ints: List[Int], target: Int): Option[Expr] =
        ???
    

    comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Since the steps above are only suggestions, we have provided only integration tests (tests for leCompteEstBon), and no unit tests for intermediate functions. Make sure to write a few unit tests to make sure you understand what each function does before starting! (And feel free to share them with other students on Ed!)

You may find that your function takes a long time to return in our tests. In that case, write your own small tests to make sure that it works, then study it by running it on examples to understand where it wastes time. Can you think of optimizations?

Hint

Focus on allTrees. Does it really have to return all trees? For example, given the set List(2, 2), is it valuable to return both Add(Num(2), Num(2)) and Mul(Num(2), Num(2))? Similarly, on the set List(2, 3, 4), is it valuable to keep both 2 * 3 + 4 and 3 * 4 - 2? Our solution keeps just one of each, and this speeds it up from multiple seconds per problem to just a few milliseconds.

def partitions[A](l: List[A]): List[(List[A], List[A])] =
  l match
    case List() => List((Nil, Nil))
    case h :: t =>
      for
        (l1, l2) <- partitions(t)
        partition <- List((h :: l1, l2), (l1, h :: l2))
      yield partition

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def allTrees(ints: List[Int]): List[Expr] =
  ints match
    case Nil     => Nil
    case List(n) => List(Num(n))
    case _ =>
      val trees: List[Expr] =
        for
          (lints, rints) <- partitions(ints)
          if !lints.isEmpty && !rints.isEmpty
          ltree <- allTrees(lints)
          rtree <- allTrees(rints)
          op <- List(Add(_, _), Sub(_, _), Mul(_, _), Div(_, _))
          r = op(ltree, rtree)
          if !r.value.isEmpty
        yield r
      // Optimization: keep just one tree per feasible value
      trees.groupBy(_.value).values.map(_.head).toList

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

def leCompteEstBon(ints: List[Int], target: Int): Option[Expr] =
  allTrees(ints).find(_.value == Some(target))

comprehensions/src/main/scala/comprehensions/DesChiffresEtDesLettres.scala

Tracing code execution

Polish notation (from week 1) ⭐️

Use tracing to gain a better understanding of the following function, which computes the value of a polish-notation expression:

enum Operand[+T]:
  case Add extends Operand[Nothing]
  case Mul extends Operand[Nothing]
  case Num(t: T)

type OpStack[T] = List[Operand[T]]

comprehensions/src/main/scala/comprehensions/Tracing.scala

def polishEval(ops: OpStack[Int]): (Int, OpStack[Int]) =
  ops match
    case Nil => throw IllegalArgumentException()
    case op :: afterOp =>
      op match
        case Operand.Num(n) =>
          (n, afterOp)
        case Operand.Add =>
          val (l, afterL) = polishEval(afterOp)
          val (r, afterR) = polishEval(afterL)
          (l + r, afterR)
        case Operand.Mul =>
          val (l, afterL) = polishEval(afterOp)
          val (r, afterR) = polishEval(afterL)
          (l * r, afterR)

comprehensions/src/main/scala/comprehensions/Tracing.scala

/* A cute use of extension functions to save myself some typing */
extension [B](result: B)
  def andThenPrintResult(indent: String): B =
    println(f"${indent}← ${result}")
    result

def polishEval(ops: OpStack[Int], indent: String = ""): (Int, OpStack[Int]) = {
  println(f"${indent}→ ${ops}")
  val new_indent = "  " + indent
  ops match
    case Nil => throw IllegalArgumentException()
    case op :: afterOp =>
      op match
        case Operand.Num(n) =>
          (n, afterOp)
        case Operand.Add =>
          val (l, afterL) = polishEval(afterOp, new_indent)
          val (r, afterR) = polishEval(afterL, new_indent)
          (l + r, afterR)
        case Operand.Mul =>
          val (l, afterL) = polishEval(afterOp, new_indent)
          val (r, afterR) = polishEval(afterL, new_indent)
          (l * r, afterR)
}.andThenPrintResult(indent)

comprehensions/src/main/scala/comprehensions/Tracing.scala

McCarthy ⭐️

What does the following function do? Use tracing annotations to figure it out!

def mc(n: Int): Int =
  if n > 100 then n - 10
  else mc(mc(n + 11))

comprehensions/src/main/scala/comprehensions/Tracing.scala

This is the McCarthy 91 function.

Takeuchi 🔥

What do the following functions do? Use tracing annotations to figure it out!

def t(x: Int, y: Int, z: Int): Int =
  if x <= y then y
  else
    t(
      t(x - 1, y, z),
      t(y - 1, z, x),
      t(z - 1, x, y)
    )

comprehensions/src/main/scala/comprehensions/Tracing.scala

This function and the previous one were both important in the development of computer-assisted proofs, as they exhibit complex, nested recursion patterns. This 1991 paper discusses the two examples above (and the problems it lists as “open problems” have now been solved!).

This is the Takeuchi function.

Tree folds

Two functions pairs and foldt are defined below. Use examples, pictures, the substitution method, refactoring, or tracing to figure out what foldt does.

extension [T](l: List[T])
  def pairs(op: (T, T) => T): List[T] = l match
    case a :: b :: tl => op(a, b) :: tl.pairs(op)
    case _            => l
  def foldt(z: T)(op: (T, T) => T): T = l match
    case Nil       => z
    case List(t)   => t
    case _ :: tail => l.pairs(op).foldt(z)(op)

comprehensions/src/main/scala/comprehensions/Tracing.scala

Armed with that knowledge, can you figure out what algorithm the function ms below implements? Is it efficient? How does it differ from another version of the same algorithm that you saw previously?

extension (l: List[Int])
  def ms: List[Int] =
    l.map(List(_)).foldt(Nil)(merge)

comprehensions/src/main/scala/comprehensions/Tracing.scala

(merge is the usual function that takes two sorted lists and merges them in sorted order)

def merge(xs: List[Int], ys: List[Int]): List[Int] =
  (xs, ys) match
    case (Nil, _) => ys
    case (_, Nil) => xs
    case (x :: xs1, y :: ys1) =>
      if x < y then x :: merge(xs1, ys)
      else y :: merge(xs, ys1)

comprehensions/src/main/scala/comprehensions/recursion.scala

foldt is a tree fold: it organizes the input list into a sequence of consecutive pairs, then applies a function to them, and repeats the process until there is only one element left.

ms implements a bottom-up merge sort. It is asymptotically optimal ($O(n\mathrm{lg}(n))$). It explodes its input into one list per element, then repeatedly merges the lists, two by two, in order. Once all lists have been merged, the result is a sorted version of the input.

Tracing performance issues

Sometimes, tracing can even help find performance issues: the longer a calculation takes, and the slower tracing statements will appear on screen. Can you use this insight to demonstrate that something is wrong with each of the following functions?

  1. Reverse
def badReverse[T](l: List[T], acc: List[T] = Nil): List[T] =
  l match
    case Nil    => acc.reverse
    case h :: t => badReverse(t, acc ++ List(h))

comprehensions/src/main/scala/comprehensions/Tracing.scala

  1. Map
def badMap[T1, T2](l: List[T1], f: T1 => T2): List[T2] =
  if l.length == 0 then Nil
  else f(l.head) :: badMap(l.tail, f)

comprehensions/src/main/scala/comprehensions/Tracing.scala

  1. Using ++ to append has cost $O(n)$ in the length of the left argument.

  2. Computing the .length of a list requires traversing it.

Tracing comprehensions

Performance issues

The following function has surprisingly poor performance. Why?

def badZip[T1, T2](l1: List[T1], l2: List[T2]): Seq[(T1, T2)] =
  for i <- (0 to math.min(l1.length, l2.length) - 1)
  yield (l1(i), l2(i))

comprehensions/src/main/scala/comprehensions/Tracing.scala

Computing the .length of a list requires traversing it; additionally, the call to l1(i) is slow: accessing the i-th element of a list requires traversing it, which takes i steps.

A subtlety in desugaring and tracing comprehensions 🔥

A keystone of the CS214 debugging guide is to “understand the system”. Sometimes observing the system’s final output is enough, but other times adding instrumentation helps. It pays to be careful, however: any time you change the system that you are debugging, you risk changing its behavior, too. Let’s see an example.

As we’ve seen previously, tracing can help not just with recursion, but also with comprehensions: to insert a tracing instruction into a comprehension, we use _ = println(…).

  1. Here is an instrumented comprehension. What does the tracing code print, and in which order?

    def traceIfTrue(b: Boolean, label: String) =
      if b then println(label)
      b
    
    def filter_tracedIfTrue(l: Seq[Int]): Seq[Int] =
      for
        n <- l
        if traceIfTrue(n % 5 == 0, f"$n is a multiple of 5!")
        if traceIfTrue(n % 3 == 0, f"  $n is also a multiple of 3!")
        if traceIfTrue(n >= 10, f"    $n is also greater than 10!")
      yield n
    

    comprehensions/src/main/scala/comprehensions/Tracing.scala

    Confirm your guess by running this code in a worksheet.

  2. Here is a different, arguably more readable way to trace the same comprehension. What will it print?

    def filter_traced(l: Seq[Int]): Seq[Int] =
      for
        n <- l
        if n % 5 == 0
        _ = println(f"$n is a multiple of 5!")
        if n % 3 == 0
        _ = println(f"  $n is also a multiple of 3!")
        if n >= 10
        _ = println(f"    $n is also greater than 10!")
      yield n
    

    comprehensions/src/main/scala/comprehensions/Tracing.scala

    As before, confirm your guess by running this code in a worksheet.

  3. What can you conclude?

  1. This first result is as expected: the consecutive ifs are equivalent to a single if with && between each clause.

    filter_tracedIfTrue(7 to 32)
    // 10 is a multiple of 5!
    // 15 is a multiple of 5!
    //   15 is also a multiple of 3!
    //     15 is also greater than 10!
    // 20 is a multiple of 5!
    // 25 is a multiple of 5!
    // 30 is a multiple of 5!
    //   30 is also a multiple of 3!
    //     30 is also greater than 10!
    

    To see exactly what’s going on, we can use the command scala -Xprint:typer Tracing.scala, which shows us what Scala is doing under the hood, after “desugaring” the comprehensions (replacing them with more primitive syntax). After cleaning up the result a bit, this is what we get:

    def filter_tracedIfTrue_desugared(l: Seq[Int]): Seq[Int] =
      l.withFilter(n => traceIfTrue(n % 5 == 0, f"$n is a multiple of 5!"))
        .withFilter(n => traceIfTrue(n % 3 == 0, f"  $n is also a multiple of 3!"))
        .withFilter(n => traceIfTrue(n >= 10, f"    $n is also greater than 10!"))
        .map(n => n)
    

    comprehensions/src/main/scala/comprehensions/Tracing.scala

    The withFilter construct is lazy (that is, it doesn’t construct a new list, it constructs an iterable that is ready to produce filtered elements on demand). Hence, it’s only when we get to the final map that the individual filters are computed, and consequently the elements of l pass through the whole computation one by one.

  2. Surprisingly, the introduction of variable bindings with _ causes the Scala compiler to compute a separate filtered list after each step, instead of computing a single list at the end.

    filter_traced(7 to 32)
    // 10 is a multiple of 5!
    // 15 is a multiple of 5!
    // 20 is a multiple of 5!
    // 25 is a multiple of 5!
    // 30 is a multiple of 5!
    //   15 is also a multiple of 3!
    //   30 is also a multiple of 3!
    //     15 is also greater than 10!
    //     30 is also greater than 10!
    

    In contrast, here, scala -Xprint:typer Tracing.scala gives us the following output:

    def filter_traced_desugared(l: Seq[Int]): Seq[Int] =
      l.withFilter(n => n % 5 == 0)
        .map(n => (n, println(f"$n is a multiple of 5!")))
        .withFilter(x => x._1 % 3 == 0)
        .map(x => (x, println(f"  ${x._1} is also a multiple of 3!")))
        .withFilter(x => x._1._1 >= 10)
        .map(x => (x, println(f"    ${x._1._1} is also greater than 10!")))
        .map(x => x._1._1._1)
    

    comprehensions/src/main/scala/comprehensions/Tracing.scala

    Notice the calls to map at each step: this is how Scala internally handles variable bindings in comprehensions. But since List.map is eager (that is, it immediately constructs a list), all elements of l will first be filtered through the first withFilter; then all matching elements will be collected in the result of map; then the second map will trigger the second layer of filtering, etc.

    🔜 We will see in a later class that the order is not fixed: it depends on the type of the argument l! This is because the behavior crucially depends on whether map is eager or lazy. As an experiment, you can try replacing l with an Iterable and observe the results.

  3. Beware of tracing: it’s very useful, but it can change the behavior of the system. Sometimes it will make it slower, sometimes faster (!), and sometimes if can influence not just performance, but also results. In other words: “Understand the system” also means “understand the language”: the better you become at using a given programming language, the easier it is to avoid pitfalls.

Non-structural recursion: numbers

Factorial ⭐️

Define a recursive function that returns the factorial of the input integer.

def factorial(n: Int): Int =
  ???

comprehensions/src/main/scala/comprehensions/recursion.scala

def factorial(n: Int): Int =
  require(n >= 0)
  if n == 0 then 1
  else n * factorial(n - 1)

comprehensions/src/main/scala/comprehensions/recursion.scala

Fast exponentiation

Fast exponentiation is a technique to optimize the exponentiation of numbers:

b²ⁿ = (b²)ⁿ = (bⁿ)²
b²ⁿ⁺¹ = b * b²ⁿ

Define a function that implements this fast exponentiation.

def fastExp(base: Int, exp: Int): Int =
  ???

comprehensions/src/main/scala/comprehensions/recursion.scala

def fastExp(base: Int, exp: Int): Int =
  require(exp >= 0)
  if exp == 0 then 1
  else if (exp % 2) != 0 then base * fastExp(base, exp - 1)
  else fastExp(base * base, exp / 2)

comprehensions/src/main/scala/comprehensions/recursion.scala

Base-N encoding ⭐️

In this exercise, your task is to implement a function that encodes a given integer into its representation in a given base. The result should be a list of integers. If the number input is 0, decimalToBaseN should return an empty List.

For instance:

  1. Encode the number 9 to base-2: decimalToBaseN(9, 2) should return List(1, 0, 0, 1).
  2. Encode the number 20 to base-16: decimalToBaseN(20, 16) should return List(1, 4).

Your task is to implement the decimalToBaseN function.

def decimalToBaseN(number: Int, base: Int, acc: List[Int] = Nil): List[Int] =
    ???

comprehensions/src/main/scala/comprehensions/recursion.scala

def decimalToBaseN(number: Int, base: Int, acc: List[Int] = Nil): List[Int] =
  if number == 0 then acc
  else decimalToBaseN(number / base, base, (number % base) :: acc)

comprehensions/src/main/scala/comprehensions/recursion.scala

Non-structural recursion: collections

Coin Change ⭐️

You are given a list of coin denominations coins: List[Int], which are the different values a coin can have, and a target amount. You need to determine the number of distinct ways to make up the target amount using any combination of the provided coin denominations. You can use each coin denomination an unlimited number of times.

def coinChange(coins: List[Int], amount: Int): Int =
    ???

comprehensions/src/main/scala/comprehensions/recursion.scala

Let’s illustrate the coin change problem with an example.

Suppose you have the following coin denominations: List(1, 2, 5), and you want to make change for the amount 5. How many different ways are there to make change for 5 using these coins?

Here are the different ways to make change for 5:

5 = 5 (use one 5-coin)
5 = 2 + 2 + 1 (use two 2-coins and one 1-coin)
5 = 2 + 1 + 1 + 1 (use one 2-coin and three 1-coins)
5 = 1 + 1 + 1 + 1 + 1 (use five 1-coins)

So, there are a total of 4 different ways to make change for 5 using the denominations List(1, 2, 5).

def coinChange(coins: List[Int], amount: Int): Int =
  if amount == 0 then 1
  else if amount < 0 || coins.isEmpty then 0
  else
    coinChange(coins.tail, amount) + coinChange(coins, amount - coins.head)

comprehensions/src/main/scala/comprehensions/recursion.scala

Merge sort ⭐️

Merge sort is a popular and efficient comparison-based sorting algorithm that follows the divide-and-conquer paradigm. It works by recursively dividing a list into smaller sublists, sorting them, and then merging them back together into a single sorted list.

Roughly, merge sort proceeds in the following way:

Split

Define a function that splits a list into two halves, whose length difference is less than or equal to 1.

There are multiple ways to implement such a function from the Scala standard library. You may use a library function.

For example, if the list has n elements, the first list has the first $\lfloor n / 2 \rfloor$ elements and the second has the rest.

Or, when counting elements from 1, the first list has all items that were at odd positions, and the second one has all items that were at even positions. For example, the list a b c d e f g will be split into a c e g and b d f.

def split[A](l: List[A]): (List[A], List[A]) =
  ???

comprehensions/src/main/scala/comprehensions/recursion.scala

The splitAt function works, or you can use the following:

def split[A](l: List[A]): (List[A], List[A]) =
  l match
    case Nil         => (Nil, Nil)
    case head :: Nil => (List(head), Nil)
    case even :: odd :: rest =>
      val (evenList, oddList) = split(rest)
      (even :: evenList, odd :: oddList)

comprehensions/src/main/scala/comprehensions/recursion.scala

Merge

Write a merge function that takes two sorted lists and returns a sorted list containing all elements of both lists.

def merge(xs: List[Int], ys: List[Int]): List[Int] =
  ???

comprehensions/src/main/scala/comprehensions/recursion.scala

def merge(xs: List[Int], ys: List[Int]): List[Int] =
  (xs, ys) match
    case (Nil, _) => ys
    case (_, Nil) => xs
    case (x :: xs1, y :: ys1) =>
      if x < y then x :: merge(xs1, ys)
      else y :: merge(xs, ys1)

comprehensions/src/main/scala/comprehensions/recursion.scala

Sort

Implement the mergeSort function.

If the list has one or zero elements, what should the function do?

def mergeSort(xs: List[Int]): List[Int] =
    ???

comprehensions/src/main/scala/comprehensions/recursion.scala

def mergeSort(xs: List[Int]): List[Int] =
  xs match
    case Nil         => Nil
    case head :: Nil => List(head)
    case _ =>
      val (left, right) = split(xs)
      merge(mergeSort(left), mergeSort(right))

comprehensions/src/main/scala/comprehensions/recursion.scala

Exercises from the slides

N-Queens

Write a function isSafe which tests if a queen placed in an indicated column col is secure amongst the other placed queens. It is assumed that the new queen is placed in the next available row after the other placed queens (in other words: in row queens.length).

def isSafe(col: Int, queens: List[Int]): Boolean =
  ???

comprehensions/src/main/scala/comprehensions/NQueens.scala

def isSafe(col: Int, queens: List[Int]): Boolean =
  val row = queens.length
  queens.zipWithIndex.forall((qc, qr) =>
    // Check if there's no conflict in the same column or diagonals
    qc != col && math.abs(qc - col) != math.abs(qr - row)
  )

comprehensions/src/main/scala/comprehensions/NQueens.scala

Variance

The following exercises are intended to help you hone your understanding of covariance and contravariance.

Do these exercises on paper first. Using the compiler is a great way to confirm your understanding, but starting from the code will not help you develop your intuition and understanding of these rules, since the compiler will do all the work for you. After completing a first draft on paper, you can either check our solution, or check your solutions on your computer.

Subtyping of variant types ⭐️

Recall that:

Consider the following hierarchies:

abstract class Fruit
class Banana extends Fruit
class Apple extends Fruit
abstract class Liquid
class Juice extends Liquid

Consider also the following typing relationships for A, B, C, D: A <: B and C <: D.

Fill in the subtyping relation between the types below. Bear in mind that it might be that neither type is a subtype of the other.

Left hand side?:Right hand side
List[Banana] List[Fruit]
List[A] List[B]
Banana => Juice Fruit => Juice
Banana => Juice Banana => Liquid
A => C B => D
List[Banana => Liquid] List[Fruit => Juice]
List[A => D] List[B => C]
(Fruit => Juice) => Liquid (Banana => Liquid) => Liquid
(B => C) => D (A => D) => D
Fruit => (Juice => Liquid) Banana => (Liquid => Liquid)
B => (C => D) A => (D => D)
Left hand side?:Right hand side
List[Banana]<:List[Fruit]
List[A]<:List[B]
Banana => Juice>:Fruit => Juice
Banana => Juice<:Banana => Liquid
A => CXB => D
List[Banana => Liquid]>:List[Fruit => Juice]
List[A => D]>:List[B => C]
(Fruit => Juice) => Liquid>:(Banana => Liquid) => Liquid
(B => C) => D>:(A => D) => D
Fruit => (Juice => Liquid)XBanana => (Liquid => Liquid)
B => (C => D)XA => (D => D)

Testing subtyping relationships

Scala’s compiler automatically checks variance rules. Can we make it show us what it inferred? Write a function assertSubtype so that assertSubtype[Int, String] causes a compilation error, but assertSubtype[String, Object] compiles.

def assertSubtype[A, B >: A]: Unit = {}
def assertSupertype[A, B <: A]: Unit = {}

variance/src/main/scala/variance/Assertions.scala

Understanding variance ⭐️

Variance rules are designed to keep type casts safe: any time the compiler rejects code for variance reasons, it does so to avoid runtime errors that could happen if these protections were not in place.

Here are some examples. For each, think of what might go wrong if the compiler didn’t disallow this pattern.

Contravariant field

Scala rejects the following definition. Why?

case class C[-A](a: A)
Show error message
case class C[-A](a: A) // Error
                 // ^ contravariant type A occurs in covariant position in type A of value a
Read error messages carefully

What does the error message above really say? If you think you know, consider the fact that this code does not cause a variance error:

class C[-A](a: A)

Intuition: An object with a field of type A gives a way to produce a value of type A, by accessing the field (in other words, a field is very much like a function that returns A).

Counterexample: If Scala didn’t disallow this, the following code would compile but the result would crash at runtime.

val strAny: Any = "hello"
val cAny: C[Any] = C(strAny)
val cInt: C[Int] = cAny // Would be OK because Int <: Any
cInt.a - 1 // 😱

variance/src/main/scala/variance/CounterExamples.scala

This is also the reason why this is rejected:

trait C[-A]:
  val a: A

In contrast, the following is OK:

class C[-A](a: A)

Since this isn’t a case class, no field a gets created, and so there’s so way to produce an A from an instance of C.

Double negation

Scala rejects the following definition. Why?

  trait C[-A]:
    def app(f: A => Int): Int
Show error message
  trait C[-A]:
    def app(f: A => Int): Int // Error
            // ^ contravariant type A occurs in covariant position in type A => Int of parameter f

Intuition: A class implementing this trait can call the function on a previously stored value, and calling a more restrictive function on that stored value would cause issues.

Counterexample: If Scala didn’t disallow this, the following code would compile but the result would crash at runtime.

case class CIntOption(s: Option[Int]) extends C[Option[Int]]:
  override def app(f: Option[Int] => Int): Int =
    f(s)

val cOption: C[Option[Int]] = CIntOption(None)
val cSome: C[Some[Int]] = cOption // OK since Some <: Option
val boom = cSome.app((some: Some[Int]) => some.value) // 😱

variance/src/main/scala/variance/CounterExamples.scala

Free and bound functions

Scala rejects the following definition:

  trait F[+A]:
    def f(a: A): A

… yet it accepts the following:

def f[A](a: A): A = a

variance/src/main/scala/variance/CounterExamples.scala

Why?

Show error message
  trait F[+A]:
    def f(a: A): A // Error
          // ^ covariant type A occurs in contravariant position in type A of parameter a

Intuition: f consumes an A and returns an A, making it unsafe to cast it up or down: an arbitrary function f: Option[Int] => Option[Int] cannot be used as an f: Some[Int] => Some[Int] (no downcasts) nor as an f: Any => Any (no upcasts). This justifies rejecting the function f defined in the trait.

When f is defined at the top level, things are different: the function itself is polymorphic, so it can be instantiated with any type and there is no type casting involved.

Counterexample: If Scala didn’t disallow this, the following code would compile but the result would crash at runtime.

class FInt extends F[Int]:
  override def f(i: Int): Int = i + 1

val fInt = FInt()
val fAny: F[Any] = fInt // Ok since Any >: Int
fAny.f("Boom") // 😱

variance/src/main/scala/variance/CounterExamples.scala

The following example is the same without Any

case class CSome() extends F[Some[Int]]:
  override def f(a: Some[Int]): Some[Int] =
    Some(a.value + 1)

val cSome: F[Some[Int]] = CSome()
val cOption: F[Option[Int]] = cSome // Ok since Option >: Some
cOption.f(None) // 😱

variance/src/main/scala/variance/CounterExamples.scala

Note that we would get a very similar error if we instantiated f first and then tried to cast it:

    def f[A](a: A) = a
    val fInt: Int => Int = f[Int]
    val fAny: Any => Any = fInt // Error
                        // ^^^^ Found:    (fInt : Int => Int)
                        //      Required: Any => Any

Extension methods

Scala rejects the following definition:

  trait Foldable1[+A]:
    def fold(a: A)(f: (A, A) => A): A

… yet it accepts the following:

trait Foldable2[+A]
extension [A](t: Foldable2[A])
  def fold(a: A)(f: (A, A) => A): A = ???

variance/src/main/scala/variance/CounterExamples.scala

Why?

Show error message
  trait Foldable1[+A]:
    def fold(a: A)(f: (A, A) => A): A // Error
             // ^               ^ covariant type A occurs in contravariant position in type (A, A) => A of parameter f
             // ^ covariant type A occurs in contravariant position in type A of parameter a

Intuition: Marking a trait as covariant allows instances to be upcast: if we have a tInt: Foldable1[Int], we can cast it to a tAny: Foldable1[Any]. For this to make sense, we need to be able to upcast all the methods attached to the trait, too. The type of tInt.fold is Int => ((Int, Int) => Int) => Int, which is not a subtype of Any => ((Any, Any) => Any) => Any, so the variance annotation is rightly rejected.

In the case of an extension method, there are no type casts involved.

Counterexample: Very similar to F above:

class FoldableInt extends Foldable1[Int]:
  override def fold(a: Int)(f: (Int, Int) => Int): Int =
    a + f(a - 1, a + 1)

var fInt = FoldableInt()
var fAny: Foldable1[Any] = fInt // OK since Int <: Any
fAny.fold("Foo")((x, y) => y) // 😱

variance/src/main/scala/variance/CounterExamples.scala

Another way to think about it: in the trait case, we can create a class FoldableInt that implements just fold(a: Int)(f: (Int, Int) => Int): Int; we can’t do that in the extension method case. That FoldableInt.fold function can depend on the details of the type Int, so we can never cast FoldableInt instances to Foldable[Any]. In the extension method case, the [A] parameter forces the implementation of fold to be parametric: it cannot depend on the details of the type, even if we apply it to a Foldable2[Int].

This is all fine and well, but fold is defined on lists, so what gives? Read the next exercise to find out!

Implementing classes with variance

Here is a simple trait for stacks:

trait Stack[T]:
  /** Peek at the top of this stack */
  def peek(): Option[T]

  /** Create a new stack with one more entry, at the top */
  def push(t: T): Stack[T]
  /** Separate the top entry from the rest of the stack */
  def pop(): (Option[T], Stack[T])

variance/src/main/scala/variance/Variance.scala

  1. Write an implementation of this trait.

  2. Write a function that takes a list of stacks and collects the top of each stack into a new stack.

    def joinStacks[T](l: List[Stack[T]]): Stack[T] =
      ???
    

    variance/src/main/scala/variance/Variance.scala

  3. What happens if you try to call join with a mix of stacks with different element types (different Ts)? Can you even put two such stacks together in a list? Why or why not?

    def mkStackInt(): Stack[Int] =
      ???
    
    def mkStackString(): Stack[String] =
      ???
    
    // Does this work?
    // val tops = joinStacks(List(mkStackInt(), mkStackString()))
    

    variance/src/main/scala/variance/Variance.scala

  4. Assume that we want to change Stack[T] to allow for multiple stacks with different T types to be placed together in a list. Is there a variance annotation for T that will allow this? Which one makes more sense?

    Try to perform the change by adding an annotation on T in Stack[T]. What problem do you run into? Why is this error reasonable? (What would go wrong otherwise?) Change the signature of push to make it work.

  5. Is it always possible to turn an invariant trait into a covariant one? Consider the following example:

    trait Drawer[T]:
      def get(): T
      def put(t: T): Drawer[T]
    
    case class IncrementingDrawer(i: Int) extends Drawer[Int]:
      def get() = i - 1
      def put(j: Int) = IncrementingDrawer(j + 1)
    

    variance/src/main/scala/variance/Variance.scala

    Can you make Drawer covariant in T without breaking IncrementingDrawer?

  6. From question 5, it is clear that covariance puts restrictions on what classes that implement a covariant trait can do (the same is true of contravariance). Based on this, can you think of a good reason not to make a container class (like a List or a Stack) covariant?

  7. Here is another trait, similar to stacks, but storing at most one element. First give T an adequate variance annotation as in the Stack case (this will require other changes, of course), then implement the trait.

    trait Box[T]:
      /** Peek at the value inside the box */
      def unbox(): Option[T]
    
      /** Create a new box with the contents */
      def replace(t: T): Box[T]
      /** Create a new box by applying `f` to the contents of this one */
      def map[T2](f: T => T2): Box[T2]
    

    variance/src/main/scala/variance/Variance.scala

  8. Both of these traits have a way to inspect one element of the container. Change both of them to extend the following HasTop trait, which captures this property.

    trait HasTop[+T]:
      /** Peek at one value inside this container */
      def top: Option[T]
    

    variance/src/main/scala/variance/Variance.scala

  9. Drawer[T] is required to be invariant to be compatible with IncrementingDrawer, but HasTop[+T] is covariant in T. Can Drawer[T] implement HasTop[T] without breaking IncrementingDrawer?

  10. Rewrite the join operation to work on lists of HasTop instances, rather than stacks. Does this join still work if you remove the covariance annotation on T in Box and Stack? Why?

    def joinTops[T](l: List[HasTop[T]]): List[T] =
      ???
    

    variance/src/main/scala/variance/Variance.scala

  1. See the final implementation in the solution of question 4.

  2. def joinStacks[T](l: List[Stack[T]]): Stack[T] =
      ListStack(l.flatMap(_.top))
    

    variance/src/main/scala/variance/Variance.scala

  3. You get a compilation error: because T is invariant in Stack[T], Scala can’t cast a Stack[A] and a Stack[B] to a common supertype such as Stack[A | B] or Stack[Any].

    Scala is still sometimes so smart that it’s hard to run into trouble. Ask yourself: why does the following work?

    val woah = joinStacks(
      List(
        ListStack(List(1, 2, 3)),
        ListStack(List("a", "b", "c"))
      )
    )
    

    variance/src/main/scala/variance/Variance.scala

    Answer

    In this case Scala casts 1, 2, 3, "a", "b", and "c" to Int | String, so that both stacks are already Stack[Int | String], and hence they have the same type and hence the stacks themselves don’t need to be cast.

  4. The variance annotation needed to allow combining multiple stacks into a list is +T.

    This requires changing the type of push to make it accept more things. The key idea here is this: if T in Stack[T] is marked as covariant, then the push method must be ready to accept any supertype of T: once we mark Stack as covariant in T, we can turn any Stack[A] into a Stack[B] for any B >: A and then call push on that; and if push expects an A, we’ll get in trouble. So we have to guarantee that push is sufficiently flexible. This is what the generalization below does.

    case class ListStack[T](l: List[T]) extends Stack[T]:
      def peek() = l.headOption
      def push[T2 >: T](t: T2) = ListStack(t :: l)
      def pop() = (peek(), ListStack(l.drop(1)))
      def top = peek()
    

    variance/src/main/scala/variance/Variance.scala

  5. You can make Drawer covariant in T in the same way as we did for Stack: by adding an annotation on T and changing the type of put to accept all supertypes of T. But you cannot update IncrementingDrawer, because its put method relies on having an Int, and not any supertype of int. In other words, it does not make sense to treat an IncrementingDrawer as a Drawer[AnyVal], for example, because the implementation of IncrementingDrawer.put relies on being given an Int, not a B >: Int.

  6. Performance could be one reason. A Set[Int], for example, could be implemented using a Vector of booleans called mask such that mask(i) is true if and only if the set contains integer i. That scheme would not work with a covariant Set type, because the set would have to be able to store any values (not just Ints), while the mask vector only accepts Int indices.

    This is actually the justification for the Set type being… invariant in Scala, unlike most other collections!

  7. case class OptionBox[+T](o: Option[T]) extends Box[T]:
      def unbox() = o
      def replace[T2 >: T](t: T2) = OptionBox(Some(t))
      def map[T2](f: T => T2) = OptionBox(o.map(f))
      def top = unbox()
    

    variance/src/main/scala/variance/Variance.scala

    It would also work to define OptionBox[T] without a variance annotation, but it would mean something different (because variance annotations are not inherited). With OptionBox[T], we would not be allowed to cast an OptionBox[List[T]] to an OptionBox[Iterable[T]], for example.

  8. trait Stack[+T] extends HasTop[T]:
      /** Peek at the top of this stack */
      def peek(): Option[T]
    
      /** Create a new stack with one more entry, at the top */
      def push[T2 >: T](t: T2): Stack[T2]
      /** Separate the top entry from the rest of the stack */
      def pop(): (Option[T], Stack[T])
    

    variance/src/main/scala/variance/Variance.scala

    trait Box[+T] extends HasTop[T]:
      /** Peek at the value inside the box */
      def unbox(): Option[T]
    
      /** Create a new box with the contents */
      def replace[T2 >: T](t: T2): Box[T2]
      /** Create a new box by applying `f` to the contents of this one */
      def map[T2](f: T => T2): Box[T2]
    

    variance/src/main/scala/variance/Variance.scala

  9. Yes! Extending HasTop does not require Drawer (nor, in fact, Stack and Box) to be covariant in their type parameters themselves. This is because once we transform a Stack or a Box or a Drawer to an HasTop instance, we lose access to the push, replace, and put methods, so the covariance restrictions don’t apply to them — only to the method that will be callable on the HasTop instance (so just top).

    trait Drawer[T] extends HasTop[T]:
      def get(): T
      def put(t: T): Drawer[T]
      def top = Some(get())
    
    case class IncrementingDrawer(i: Int) extends Drawer[Int]:
      def get() = i - 1
      def put(j: Int) = IncrementingDrawer(j + 1)
    

    variance/src/main/scala/variance/Variance.scala

  10. This new version of join only requires HasTop to be covariant; it doesn’t put any variance restrictions on Stack, Box, or Drawer. Hence, it works even if Stack and Box are made invariant in T. This is particularly convenient: while variance is the right choice for stacks, we saw earlier that an implementation like IncrementingDrawer is not compatible with a covariant Drawer. Similarly, HasTop could be made to work for Scala Sets, despite the fact that they are not covariant!

A variance puzzle 🔥

This exercise goes beyond what will be on the exam. It’s hard to get an intuition for it: instead, if you want to complete it, you may find our complete writeup on variance rules useful.

Assume the following two classes or traits…

C[-A, +B]
F[ A, +B] extends C[A, B]

… and the following subtyping relationships…

A <: B
X <: Y

… what are the relations between the following pairs?

Check in a worksheet if you’re not sure that your answer is correct!

  • C[A, Y] is a supertype of F[B, X]
  • F[A, X] is a subtype of C[A, Y]

The following code confirms this:

class C[-A, +B]
class F[ A, +B] extends C[A, B]

def assertSubtype[A, B >: A]: Unit = {}
def assertSupertype[A, B <: A]: Unit = {}

def test[B, A <: B, Y, X <: Y]: Unit =
  assertSubtype[C[A, Y], F[B, X]] // Error
  assertSupertype[C[A, Y], F[B, X]] // Ok
  assertSubtype[F[A, X], C[A, Y]] // OK
  assertSupertype[F[A, X], C[A, Y]] // Error

Exercises from the slides

Array Typing Problem

Arrays in Java are covariant, but covariant array typing causes problems. Do you know why?

To see why, let’s explore with an example:

Consider a class IntSet that defines binary trees storing sets of integers. A set of integers is either an empty set represented by an empty tree, or a non-empty set stored in a tree that consists of one integer and two subtrees. In Java, IntSet can be implemented as follows:

sealed interface IntSet permits Empty, NonEmpty {}
final class Empty implements IntSet {}
final class NonEmpty implements IntSet {
  private final int elem;
  private final IntSet left, right;
  public NonEmpty (int elem, IntSet left, IntSet right) {
    this.elem = elem;
    this.left = left;
    this.right = right;
  }
}

In Scala, IntSet would be written as follows:

sealed trait IntSet
case class Empty() extends IntSet
case class NonEmpty(elem: Int, left: IntSet, right: IntSet) extends IntSet

Now consider the Java code below:

NonEmpty[] a = new NonEmpty[]{
  new NonEmpty(1, new Empty (), new Empty())};
IntSet[] b = a ;
b[0] = new Empty();
NonEmpty s = a[0];

It looks like in the last line, we assigned an Empty value to a variable of type NonEmpty! What went wrong?

The problematic array example would be written as follows in Scala:

val a: Array[NonEmpty] = Array(NonEmpty(1, Empty(), Empty()))
val b: Array[IntSet] = a
b(0) = Empty()
val s: NonEmpty = a(0)

When you try out this example, what do you observe?

  1. A type error in line 1
  2. A type error in line 2
  3. A type error in line 3
  4. A type error in line 4
  5. A program that compiles and throws an exception at run-time
  6. A program that compiles and runs without exception

e: A program that compiles and throws an exception at run-time.

  • Line 1: It creates an array of type Array[NonEmpty] containing a single NonEmpty element.

  • Line 2: Because NonEmpty <: IntSet, it would compile if arrays were covariant in Scala. b references to the same array as a.

  • Line 3: Because Empty <: IntSet, it would compile if arrays were covariant in Scala. However, the underlying array of a now contains an Empty value where a NonEmpty value was expected.

  • Line 4: It tries to retrieve a(0), an Empty value, as a NonEmpty value. A runtime error occurs due to the type mismatch.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) tells us when a type can be a subtype of another:

If A <: B, then everything one can do with a value of type B one should also be able to do with a value of type A.

Assume the following type hierarchy and two function types:

trait Fruit
class Apple extends Fruit
class Orange extends Fruit
type A = Fruit => Orange
type B = Apple => Fruit

According to the LSP, which of the following should be true?

  1. A <: B
  2. B <: A
  3. A and B are unrelated.

a: A <: B.

In a context where a value of type Apple => Fruit is expected, one can always provide a value of type Fruit => Orange. This works because the input apple can be treated as a fruit, and producing an orange fulfills the contract of producing a fruit.

Prepend

Consider adding a prepend method to List which prepends a given element, yielding a new list.

  1. A first implementation of prepend could look like this:

    trait List[+T]: 
       def prepend(elem: T): List[T] = Node(elem, this)
    

    But that does not type-check. Why?

    1. prepend turns List into a mutable class.
    2. prepend fails variance checking.
    3. prepend’s right-hand side contains a type error.
  2. Given the second implementation of prepend:

    trait List[+T]: 
      def prepend [U >: T] (elem: U): List[U] = Node(elem, this)
    

    What is the result type of this function (Apple <: Fruit, Orange <: Fruit):

    def f(xs: List[Apple], x: Orange) = xs.prepend(x)
    

    Possible answers:

    1. does not type check
    2. List[Apple]
    3. List[Orange]
    4. List[Fruit]
    5. List[Any]
  1. b: prepend fails variance checking.

    Given xs: List[Fruit], ys: List[Apple], o: Orange, we can prepend o to xs by xs.prepand(o).

    But the same operation on ys would lead to a type error under this prepand definition:

    ys.prepend(o)
               ^ type mismatch 
               required: Apple
               found: Orange
    

    Which means List[Apple] cannot be a subtype of List[Fruit].

  2. d: List[Fruit].

Specifications

Reasoning about your code requires specifying what it is supposed to do??

In Scala, it’s common to use require and ensuring to specify pre- and post-conditions for functions, respectively. The require method checks a given condition (usually an input validation) and throws an IllegalArgumentException if the condition is not met. On the other hand, ensuring is used to validate the result of a function. It takes a predicate that the result must satisfy, and if not, an assertion error is thrown.

For example:

val eps = 0.00001f

def sqrt(x: Double): Double = {
  require(x >= 0)
  Math.sqrt(x)
} ensuring (res =>
    (x - res * res) <= eps && (x - res * res) >= -eps
)

The following exercises will make writing such specs second nature!

Debugging with tests and specs

Bad combinatorics ⭐️

Consider the following incorrect function:

/** Construct all subsets of `s` of size `k` (INCORRECT!) */
def badCombinations[T](set: Set[T], k: Int): Set[Set[T]] = {
  if k == 0 then Set(Set())
  if set.isEmpty then Set()
  else
    for
      item <- set
      rest = set - item
      subset <- Iterable.concat(
        for s <- badCombinations(rest, k - 1) yield rest + item,
        for s <- badCombinations(rest, k) yield rest
      )
    yield subset
}

specs/src/main/scala/specs/Tracing.scala

  1. Read this function carefully.

  2. Which inputs are meaningful for this function? Write a require clause to rule out meaningless arguments. Check that your require clause works by calling this function with invalid arguments in a worksheet and confirming that it raises an exception.

  3. Unit tests are a great way to ensure that corner cases are correctly handled. Create a new test suite class in src/test/scala/specs/SpecsSuite.scala (you can consult a previous-week exercise set or lab for inspiration, or the munit documentation), then write a unit test that asserts that this function computes the right result when called with an empty set as input (use assertEquals and Set()).

  4. Make sure that your test runs and reports a failure (it should, since this function is incorrect). You can use sbt testOnly -- *your-test-name* to run your test.

  5. Find the bug that leads to an empty set being returned, and confirm that the unit test now passes.

  6. What does the documentation of this function promise? Write an ensuring clause to capture this formally. Is this a complete specification? (A specification is complete if any function that respects it is a correct implementation of the original requirements — here, the documentation string).

  7. Find an input for which this function violates the postcondition that you just wrote. Confirm your conjecture by calling this function in a worksheet and confirming that the ensuring clause throws an exception.

  8. Correct the remaining bugs in this function. You may find it useful to add tracing instructions to observe its behavior.

  9. Bonus question: Is your corrected function efficient? Look carefully at the work it does on a simple example. Do you spot any redundant work?

  1. 👀

  2. A reasonable precondition would be k >= 0, since a negative set size is most likely an unintended mistake. It’s up to you to decide whether to additionally include k > set.size: you might argue that it’s a reasonable parameter to pass, and that the code should simply return no results in that case.

  3. class CombinationsSuite extends munit.FunSuite:
      test("`badCombinations` correctly handles empty sets"):
        assertEquals(badCombinations(Set(), 0), Set(Set()))
    

    specs/src/test/scala/specs/SpecsSuite.scala

  4. SBT produces the following output:

    se.CombinationsSuite:
    ==> X se.CombinationsSuite.`badCombinations` correctly handles empty sets  0.041s munit.ComparisonFailException: /home/cpc/git/epfl/cs214/course-material/exercises/specs/src/test/scala/specs/ExercisesSuite.scala:7
    6:  test("`badCombinations` correctly handles empty sets"):
    7:    assertEquals(badCombinations(Set(), 0), Set(Set()))
    8:/*++++++*/
    values are not the same
    => Obtained
    Set()
    => Diff (- obtained, + expected)
    -Set()
    +Set(
    +  Set()
    +)
        at munit.Assertions.failComparison(Assertions.scala:274)
    [error] Failed: Total 1, Failed 1, Errors 0, Passed 0
    [error] Failed tests:
    [error] 	se.CombinationsSuite
    [error] (Test / test) sbt.TestsFailedException: Tests unsuccessful
    
  5. There is a missing else in front of the second if:

    se.CombinationsSuite:
      + `badCombinations` correctly handles empty sets 0.006s
    [info] Passed: Total 1, Failed 0, Errors 0, Passed 1
    
  6. Each subset must be of size k, and must be a subset of the original set (use forall and subsetOf):

    ensuring (subs => subs.forall(sub => sub.size == k && sub.subsetOf(set)))
    

    This specification is not complete (the property we wrote is necessary, but not sufficient), because it does not guarantee that all subsets of size k will be returned.

  7. Calling the function with Set(1, 2) and k == 1 causes a violation of the ensuring clause, which materializes as an AssertionFailure.

  8. See the inline comments below:

    /** Construct all subsets of `s` of size `k` (FIXED!) */
    def badCombinations[T](set: Set[T], k: Int): Set[Set[T]] = {
      require(k >= 0)
      if k == 0 then Set(Set())
      // Bug one: ‘else’ was missing
      else if set.isEmpty then Set()
      else
        for
          item <- set
          rest = set - item
          subset <- Iterable.concat(
            // Bug two: ‘rest’ was used intead of ‘s’
            for s <- badCombinations(rest, k - 1) yield s + item,
            for s <- badCombinations(rest, k) yield s
          )
        yield subset
    } ensuring (subs => subs.forall(sub => sub.size == k && sub.subsetOf(set)))
    

    specs/src/main/scala/specs/Tracing.scala

  9. This function is written as if we wanted to produce all ordered subsequences of the original set, not all subsets. We can make this obvious by changing it to return lists:

    badCombinations(Set.from("abcdefg"), 3).size // 35
    badCombinations_lists(Set.from("abcdefg"), 3).size // 143850 (!!)
    

    In other words, for this small example, the badCombinations functions is computing the same thing 4110 times (but because we put the results in a set, duplicated calculations were not obvious!).

    This redundancy is due to the fact that our bad combination functions iterate over set. This would be good to do if we cared which element went first into the resulting combinations, but our combinations are sets, so order is irrelevant. In our case, it’s enough to select a single arbitrary element and to distinguish two cases (the element is present in the set, or not).

    Here is a better implementation.

    def betterCombinations[T](set: Set[T], k: Int): Set[Set[T]] =
      require(k >= 0)
      if k == 0 then Set(Set())
      else if set.isEmpty then Set()
      else
        val item = set.head
        val rest = set - item
        betterCombinations(rest, k - 1).map(_ + item) ++
          betterCombinations(rest, k)
    

    specs/src/main/scala/specs/Tracing.scala

    🔜 This function still isn’t optimal: can you see why? Later in the course we’ll study a way to make it even faster, using a technique called memoization.

Bad transactions

The following code defines a class BankAccount and associated operations deposit, withdraw and transfer:

protected class BankAccount(private var _balance: Double):
  import AccOpResult.*

  def balance = _balance

  private def updateBalance(amount: Double): AccOpResult =
    val oldBalance = balance
    _balance = amount
    Ok(oldBalance)

  /** Deposits the specified amount into the bank account.
    *
    * @param amount
    *   The amount to be deposited. Must be non-negative.
    */
  def deposit(amount: Double): AccOpResult = {
    updateBalance(balance + amount)
  }

  /** Withdraws the specified amount from the bank account.
    *
    * @param amount
    *   The amount to be withdrawn. Must be non-negative.
    */
  def withdraw(amount: Double): AccOpResult = {
    if balance >= amount then
      updateBalance(balance - amount)
    else
      InsufficientFund(balance)
  }

  /** Transfers the specified amount from this bank account to `that` bank
    * account.
    *
    * @param amount
    *   The amount to be transferred. Must be non-negative.
    */
  def transfer(that: BankAccount, amount: Double): (AccOpResult, AccOpResult) = {
    if this.balance >= amount then
      (this.withdraw(amount), that.deposit(amount))
    else
      (InsufficientFund(this.balance), Ok(that.balance))
  }

specs/src/main/scala/specs/BankAccount.scala

Type AccOpResult is used to memorize a “screenshot” of the balance before the operation and whether the operation is successful:

enum AccOpResult:
  case Ok(oldBalance: Double)
  case InsufficientFund(oldBalance: Double)

specs/src/main/scala/specs/BankAccount.scala

  1. Write the preconditions and postconditions for the functions deposit, withdraw and transfer using require and ensuring.
  2. Can you identify the bugs in the code that violate these conditions?
  1. protected class BankAccount(private var _balance: Double):
      import AccOpResult.*
    
      def balance = _balance
    
      private def updateBalance(amount: Double): AccOpResult =
        val oldBalance = balance
        _balance = amount
        Ok(oldBalance)
    
      /** Deposits the specified amount into the bank account.
        *
        * @param amount
        *   The amount to be deposited. Must be non-negative.
        */
      def deposit(amount: Double): AccOpResult = {
        require(amount >= 0)
        updateBalance(balance + amount)
      } ensuring (res =>
        balance == res.old + amount
      )
    
      /** Withdraws the specified amount from the bank account.
        *
        * @param amount
        *   The amount to be withdrawn. Must be non-negative.
        */
      def withdraw(amount: Double): AccOpResult = {
        require(amount >= 0)
        if balance >= amount then
          updateBalance(balance - amount)
        else
          InsufficientFund(balance)
      } ensuring (res =>
        if res.old >= amount then balance == res.old - amount
        else balance == res.old
      )
    
      /** Transfers the specified amount from this bank account to `that` bank
        * account.
        *
        * @param amount
        *   The amount to be transferred. Must be non-negative.
        */
      def transfer(that: BankAccount, amount: Double): (AccOpResult, AccOpResult) = {
        require(amount >= 0)
        if this.balance >= amount then
          (this.withdraw(amount), that.deposit(amount))
        else
          (InsufficientFund(this.balance), Ok(that.balance))
      } ensuring ((res0, res1) =>
        if this.balance >= amount then
          this.balance == res0.old - amount && that.balance == res1.old + amount
        else
          this.balance == res0.old && that.balance == res1.old
      )
    

    specs/src/main/scala/specs/BankAccount.scala

  2. In the original code, there’s no check to ensure that amount in deposit, withdraw and transfer is non-negative. Therefore, one can use the original code to withdraw a negative amount of money, causing a loss of money for the bank; or transfer a negative amount of money to another account, causing a loss of money for the target account.

Translating specs from English

Ambiguous specs

The following statements are ambiguous. For each of them, explain the ambiguity (either in words, or by giving at least two different, unambiguous expressions in Scala code that are compatible with the original sentence):

  1. “Access to the system is restricted to senior engineers and developers.”
  2. “The argument to this function must be a supertype of a serializable type that implements IsFinite
  3. “Only finite Double lists are supported.”
  4. “Any sibling of a node that makes a request must be ready to process answers.”
  5. “Objects passed to this method must be unlockable.”

A particularly good source of examples of ambiguities is newspapers; just look up “Ambiguous newspaper headlines” on Google.

  1. It’s ambiguous who needs to be “senior” (the engineers only, or both?):

    • “Only (senior engineers) and (developers)”
      def hasAccess(p: Person) =
        (p.isSenior && p.isEngineer) || p.isDeveloper
      
    • “Only senior (engineers and developers)”
      def hasAccess(p: Person) =
        p.isSenior && (p.isEngineer || p.isDeveloper)
      
  2. It’s ambiguous what “that implements IsFinite” attaches to (who implements it, the supertype or the serializable type?):

    • “a supertype of (a serializable type that implements IsFinite)”
    • “(a supertype of a serializable type) that implements IsFinite
  3. It’s ambiguous what “finite” attaches to (the numbers or the list?):

    • “finite list of Doubles”
    • “list of finite Doubles”
  4. It’s ambiguous what “that makes a request” attaches to (who makes the request, the node or the sibling?):

    • “Any sibling of (a node that makes a request)”
    • “(Any sibling of a node) that makes a request”
  5. “Unlockable” is ambiguous:

    • “Un-lockable”: Cannot be locked
    • “Unlock-able”: Can be unlocked

Translating simple specs ⭐️

Translate the following English statements to mathematical statements or to Scala code. Aim to be as succinct and clear as possible; do not optimize for performance. For some statements, it may not be possible to write Scala code (which ones?).

  1. The list of integers l is sorted in ascending order
  2. All values in map m1 are keys in map m2.
  3. Even numbers are always immediately followed by odd numbers in list l.
  4. There are no negative numbers in the output of function f.
  5. Functions f and g compute the same output when given a positive number.
  6. List l1 is a permutation of list l2.
  7. Number p is prime.
  1. With a simple zip:

    l.zip(l.tail).forall((a, b) => a <= b)
    

    specs/src/main/scala/specs/Specs.scala

  2. Either with a subset…

    m1.values.toSet.subsetOf(m2.keys.toSet)
    

    specs/src/main/scala/specs/Specs.scala

    … or with forall:

    m1.values.forall(m2.contains)
    

    specs/src/main/scala/specs/Specs.scala

  3. Either by checking indices (note: != 0 instead of == 1, because (-1) % 2 is -1)…

    (0 until l.length - 1).forall(i => l(i) % 2 != 0 || l(i + 1) % 2 != 0)
    

    specs/src/main/scala/specs/Specs.scala

    … or by checking for consecutive even numbers:

    !l.map(_ % 2).containsSlice(List(0, 0))
    

    specs/src/main/scala/specs/Specs.scala

  4. This one requires math (or a tool like Stainless!), since we may have an infinite codomain: $\forall i, f(i) > 0$

    🔜 In a later week we’ll see two techniques, “property-based testing” and “formal verification” that can help check this property.

  5. Again, this requires math: $\forall i, i > 0 \Rightarrow f(i) = g(i)$

  6. If we have an order on the values, we can compare sorted lists:

    l1.sorted == l2.sorted
    

    specs/src/main/scala/specs/Specs.scala

    … otherwise if we have a way to check equality we can count elements (careful to include both l1 and l2 — what happens otherwise?):

    (l1 ++ l2).forall(a => l1.count(_ == a) == l2.count(_ == a))
    

    specs/src/main/scala/specs/Specs.scala

    … or even just look at all permutations:

    l1.permutations.contains(l2)
    

    specs/src/main/scala/specs/Specs.scala

  7.  

    n > 1 && (2 to n - 1).forall(n % _ != 0)
    

    specs/src/main/scala/specs/Specs.scala

Paths and cycles

Translate the following English statements to Scala code:

A path is a nonempty sequence of edges (pairs of points) such that the endpoint of each edge is the starting point of the next edge.

A cycle is a path whose starting point equals its endpoint.

There are many possible ways to express this property. We start with a type definition for edges:

case class Edge[+Point](src: Point, dst: Point)

specs/src/main/scala/specs/Specs.scala

The following are five different ways to express the property of being a path, and two ways to express that of being a cycle. Do you have a favorite? A second favorite? Do others agree with you?

def isPathMap[Point](edges: Seq[Edge[Point]]): Boolean =
  !edges.isEmpty && edges.map(_.dst).dropRight(1) == edges.drop(1).map(_.src)

specs/src/main/scala/specs/Specs.scala

def isPathSliding[Point](edges: Seq[Edge[Point]]): Boolean =
  // “A *path* is a nonempty sequence of edges”
  !edges.isEmpty &&
    edges.sliding(2).forall {
      case Seq(e0, e1) =>
        // such that the endpoint of each edge
        e0.dst
        // is the starting point of the next edge.
          == e1.src
    }

specs/src/main/scala/specs/Specs.scala

def isPathFold[Point](edges: Seq[Edge[Point]]): Boolean =
  !edges.isEmpty &&
    !edges.tail.foldLeft[Option[Edge[Point]]](Some(edges.head)) {
      case (Some(e0), e1) => if e0.dst == e1.src then Some(e1) else None
      case _              => None
    }.isEmpty

specs/src/main/scala/specs/Specs.scala

def isPathRec[Point](edges: Seq[Edge[Point]]): Boolean =
  def consistent(first: Edge[Point], rest: Seq[Edge[Point]]): Boolean =
    rest.isEmpty ||
      (first.dst == rest.head.src &&
        consistent(rest.head, rest.tail))
  !edges.isEmpty && consistent(edges.head, edges.tail)

specs/src/main/scala/specs/Specs.scala

def isPathRecMatch[Point](edges: Seq[Edge[Point]]): Boolean =
  def consistent(edges: Seq[Edge[Point]]): Boolean =
    edges match
      case e0 :: e1 :: tl => e0.dst == e1.src && consistent(e1 :: tl)
      case _              => true
  !edges.isEmpty && consistent(edges)

specs/src/main/scala/specs/Specs.scala

def isCycleEq[Point](edges: Seq[Edge[Point]]): Boolean =
  isPathMap(edges) && edges.head.src == edges.last.dst

specs/src/main/scala/specs/Specs.scala

def isCycleCons[Point](edges: Seq[Edge[Point]]): Boolean =
  isPathMap(edges.last +: edges)

specs/src/main/scala/specs/Specs.scala

Course policies

This course has a reasonably complex grading policy: one lab is dropped, we accept medical reasons for skipping a lab, callbacks combine with labs, we have a midterm and a final, etc.

Translate the grading policy of this course (found in Overall Grade) into a Scala function, OverallGrade. First, start with the easy version:

def OverallGrade(labScore: Double, midtermScore: Double, finalScore: Double): Double =
  ???

specs/src/main/scala/specs/Specs.scala

🔥 Then, try the full grading policy.

Here is the easy version:

def OverallGrade(labScore: Double, midtermScore: Double, finalScore: Double): Double =
  val overallScore = labScore * 0.3 + midtermScore * 0.3 + finalScore * 0.4
  (((5.25 * overallScore + 0.875) * 4).round * 0.25).min(6.0)

specs/src/main/scala/specs/Specs.scala

The hard version is left as an exercise. Is it correct to use Double values?

Finding incompleteness in specs ⭐️

The following ensuring specifications are incomplete: they do not detect all incorrect behaviors.

For each one, replace the existing function body with an incorrect implementation that still obeys the ensuring clause, then find an input that proves that your modified function is incorrect and confirm that the ensuring clause is incomplete by checking that it does not raise an assertion.

  1. def filterWithIncompleteSpec[T](l: List[T], p: T => Boolean) = {
      l.filter(p)
    } ensuring (res => res.forall(p))
    

    specs/src/main/scala/specs/Specs.scala

  2. def mapWithIncompleteSpec[T, Q](ts: List[T])(f: T => Q) = {
      ts.map(f)
    } ensuring (qs =>
      qs.length == ts.length &&
        qs.forall(q => ts.exists(t => f(t) == q))
    )
    

    specs/src/main/scala/specs/Specs.scala

  3. def flattenWithIncompleteSpec[T](tss: List[List[T]]) = {
      tss.flatten
    } ensuring (ts =>
      ts.length == tss.map(_.length).sum &&
        tss.forall(ts.containsSlice(_))
    )
    

    specs/src/main/scala/specs/Specs.scala

  1. A function that always returns Nil would satisfy this spec, and be incorrect for any non-empty input with at least one element satisfying the predicate (e.g. filterWithIncompleteSpec(List(1), _ > 0))

  2. Nil won’t work for this one, but since the spec doesn’t check that all source elements are reflected in the result, a simple List.fill(ts.length)(f(ts.head)) will pass the ensuring clause and fail for mapWithIncompleteSpec(List(1, 2), _ + 1).

    Why is it OK to use ts.head without checking whether ts is empty?

  3. This time all elements are indeed reflected in the output, but… there’s nothing to guarantee that sublists are properly ordered relative to each other, so tss.reverse.flatten would pass the ensuring clause (unlike tss.flatten.reverse).