Part II — Functional programming in Scala 3 · Lesson 3

Functional data structures

~30 min of reading2 instruments: a λ-calculus reducer and an evaluation-order tracerLab 03 with solutions

In this lesson

  1. What do generics add?
  2. How do you design a functional data structure?
  3. What does "declarative" mean?
  4. What is the λ-calculus?
  5. How expressive is the λ-calculus?
  6. When does evaluation order matter? Confluence
  7. Call-by-value, call-by-name, call-by-need
  8. How do streams make infinite data possible?
  9. What are extension methods?
  10. Lab 03: exercises and solutions
  11. Check your understanding

The lesson in one paragraph. Generics let one definition work for every type, so the data types of lesson 2 become reusable structures: an optional value, a linked list, a tree, each an enum plus a module of operations. A pipeline such as sum(map(filter(l)(…))(…)) says what to compute, not in which order. The λ-calculus explains why that is safe: it is confluent, so a terminating program has one result whatever the order of reduction. Order still matters for termination and cost, which is why Scala offers call-by-value, call-by-name and call-by-need. Laziness then gives infinite streams, and extension methods give fluent, chained calls without any OOP.

1. What do generics add?

Generics (parametric polymorphism, as in Java) abstract code (functions, types, classes) from some type, making code more reusable and types more expressive. In Scala, types, case classes, methods and enums can be generic, with type parameters in brackets [A, B, …], optional bounds such as A <: B, and good type inference.

The deck starts from an optional integer, either present (Just) or absent (Empty), a safe replacement for null, and then generifies it by replacing Int with a type parameter. The module layout is the one used throughout: an outer module, a type whose structure is public, and a companion with the operations.

// overall module
object Optionals:

  // type "public" definition, exposing structure
  enum Optional[A]:
    case Just(a: A)
    case Empty() // here parens are needed because of genericity

  // operations (algorithms)
  object Optional:

    def isEmpty[A](opt: Optional[A]): Boolean = opt match
      case Empty() => true
      case _       => false

    def orElse[A, B >: A](opt: Optional[A], orElse: B): B = opt match
      case Just(a) => a
      case _       => orElse

    def map[A, B](opt: Optional[A])(f: A => B): Optional[B] = opt match
      case Just(a) => Just(f(a))
      case _       => Empty()

@main def tryOptionals =
  import Optionals.* // to work with Optionals (to see the Optional type)
  import Optional.*  // to access the algorithms directly

  val s1: Optional[Int] = Just(1)
  val s2: Optional[Int] = Empty()

  println(s1)                         // Just(1)
  println(isEmpty(s1))                // false
  println(orElse(s1, 0))              // 1
  println(orElse(s2, 0))              // 0
  println(map(s1)(i => "val: " + i))  // Just(val: 1)
  println(map(s2)(i => "val: " + i))  // Empty()
Beyond the slides

Why does Empty need parentheses once the enum is generic? A parameterless case is one object, which must extend Optional[A] for a fixed A: case Empty fails with "cannot determine type argument for enum parent class Optional, type parameter A is invariant". Empty() is a case class with its own type parameter. Scala's Option[+A] manages with one None because it is covariant: None extends Option[Nothing], a subtype of every Option[A] (variance: lesson 7).

From the slides: the non-generic OptionalInt, and the JUnit tests
// overall module
object OptionalInts:

  // type "public" definition, exposing structure
  enum OptionalInt:
    case Just(i: Int)
    case Empty

  // operations (algorithms)
  object OptionalInt:

    def isEmpty(opt: OptionalInt): Boolean = opt match
      case Empty => true
      case _     => false

    def orElse(opt: OptionalInt, orElse: Int): Int = opt match
      case Just(a) => a
      case _       => orElse

    def map(opt: OptionalInt)(f: Int => Int): OptionalInt = opt match
      case Just(a) => Just(f(a))
      case _       => Empty

@main def tryOptionalInts =
  import OptionalInts.*   // to see the OptionalInt type
  import OptionalInt.*    // to access the algorithms directly
  val s1: OptionalInt = Just(1)
  println(map(s1)(_ + 1))   // Just(2)
  println(orElse(Empty, 0)) // 0

The improved test names the values under test instead of repeating literals, so each expectation says where it comes from (Just(optVal + 1)):

import org.junit.*
import org.junit.Assert.*

class OptionTestImproved:

  // imports needed to work with Optionals
  import u03.Optionals.*
  import Optional.*

  // values under test
  val optVal = 5
  val optSome = Just(optVal)
  val optNone = Empty()

  @Test def testEmpty() =
    assertFalse(isEmpty(optSome))
    assertTrue(isEmpty(optNone))

  @Test def testOrElse() =
    val otherVal = 10
    assertEquals(optVal, orElse(optSome, otherVal))
    assertEquals(otherVal, orElse(optNone, otherVal))

  @Test def testMap() =
    assertEquals(Just(optVal + 1), map(optSome)(_ + 1))
    assertEquals(Just("val: " + optVal), map(optSome)(v => "val: " + v))
    assertEquals(Empty(), map(optNone)(v => "val: " + v))

Generic methods and case classes

object Generics extends App:

  // generic methods, in 1 and 2 type variables
  def f1[A](a: A, b: A): A = a
  def f2[A, B](a: A, b: B): A = a

  // calling a generic method may infer the type variables
  println(f1(10, 20))                 // 10
  println(f2("10", 20))               // 10   (the String "10")
  println(f2[String, Int]("10", 20))  // 10

  // a generic case class, in 2 type variables
  case class Pair[A, B](a: A, b: B)
  // a value of type Pair[Int, String]: type variables can be omitted
  val p = Pair(10, "20")
  // a generic method operating on pairs
  def swap[A, B](p: Pair[A, B]): Pair[B, A] = p match
    case Pair(a, b) => Pair(b, a)

  println(swap(p))                    // Pair(20,10)

Type arguments are inferred (f2("10", 20) has A = String, B = Int) but can be given explicitly; p is a Pair[Int, String] without saying so. Generic case classes are how tuples are built: Tuple2…Tuple22, aliased to (_, _), (_, _, _), …

From the slides: tuples as generic case classes
object Tuples extends App:

  case class Tup1[A](a: A)
  case class Tup2[A, B](a: A, b: B)
  case class Tup3[A, B, C](a: A, b: B, c: C)
  case class Tup4[A, B, C, D](a: A, b: B, c: C, d: D)

  val tu = Tup2(10, true)
  val tu2: Tup2[Int, Boolean] = Tup2(10, true)
  println(s"$tu, $tu2, ${tu == tu2}")  // Tup2(10,true), Tup2(10,true), true

  // built-in tuples: Tuple2, Tuple3, … (any arity in Scala 3), written (a, b), (a, b, c), …
  val t = (10, true)
  val t2 = Tuple2(10, true)
  println(s"$t, $t2, ${t == t2}")      // (10,true), (10,true), true

2. How do you design a functional data structure?

The recipe: a product or sum type on top (enum or case class), usually with its structure exposed so algorithms can also live outside; an object with operations and constructors; generics for reuse; higher-order functions for factorisation; strategy arguments grouped by currying. Key examples: optionals, sequences, trees, streams.

object Sequences: // essentially, generic linked lists

  enum Sequence[E]:
    case Cons(head: E, tail: Sequence[E])
    case Nil()

  object Sequence:

    def sum(l: Sequence[Int]): Int = l match
      case Cons(h, t) => h + sum(t)
      case _          => 0

    def map[A, B](l: Sequence[A])(mapper: A => B): Sequence[B] = l match
      case Cons(h, t) => Cons(mapper(h), map(t)(mapper))
      case Nil()      => Nil()

    def filter[A](l1: Sequence[A])(pred: A => Boolean): Sequence[A] = l1 match
      case Cons(h, t) if pred(h) => Cons(h, filter(t)(pred))
      case Cons(_, t)            => filter(t)(pred)
      case Nil()                 => Nil()

@main def trySequences =
  import Sequences.*
  val l = Sequence.Cons(10, Sequence.Cons(20, Sequence.Cons(30, Sequence.Nil())))
  println(Sequence.sum(l))  // 60

  import Sequence.*

  println(sum(map(filter(l)(_ >= 20))(_ + 1)))  // 52  (21 + 31)

(The slide's comment on the first println says 30; the program prints 60.) The pipeline is evaluated from the inside out, because each function needs its argument:

sum(map(filter(l)(_ >= 20))(_ + 1))
= sum(map(Cons(20, Cons(30, Nil())))(_ + 1))    filter drops 10
= sum(Cons(21, Cons(31, Nil())))                map adds 1 to each element
= 21 + (31 + 0)
= 52
From the slides and the course code: the JUnit tests, and a binary tree
import org.junit.*
import org.junit.Assert.*

class SequenceTest:
  import u03.Sequences.*
  import Sequence.*

  val l: Sequence[Int] = Cons(10, Cons(20, Cons(30, Nil())))

  @Test def testSum() =
    assertEquals(0, sum(Nil()))
    assertEquals(60, sum(l))

  @Test def testMap() =
    assertEquals(Cons(11, Cons(21, Cons(31, Nil()))), map(l)(_ + 1))
    assertEquals(Cons("10", Cons("20", Cons("30", Nil()))), map(l)(_ + ""))

  @Test def testFilter() =
    assertEquals(Cons(20, Cons(30, Nil())), filter(l)(_ >= 20))
    assertEquals(Cons(10, Cons(30, Nil())), filter(l)(_ != 20))

The same recipe applied to a generic binary tree:

object BTrees extends App:

  // a custom and generic binary tree of elements of type A
  enum Tree[A]:
    case Leaf(value: A)
    case Branch(left: Tree[A], right: Tree[A])

  object Tree:
    def size[A](t: Tree[A]): Int = t match
      case Branch(l, r) => size(l) + size(r)
      case _ => 1

    def find[A](t: Tree[A], elem: A): Boolean = t match
      case Branch(l, r) => find(l, elem) || find(r, elem)
      case Leaf(e) => e == elem

    def count[A](t: Tree[A], elem: A): Int = t match
      case Branch(l, r) => count(l, elem) + count(r, elem)
      case Leaf(e) if (e == elem) => 1
      case _ => 0

  import Tree.*

  val tree = Branch(Branch(Leaf(1), Leaf(2)), Leaf(1))
  println(tree)            // Branch(Branch(Leaf(1),Leaf(2)),Leaf(1))
  println(size(tree))      // 3
  println(find(tree, 1))   // true
  println(find(tree, 4))   // false
  println(count(tree, 1))  // 2

Hiding the structure: ADTs versus classes

Classes give constructors and operations and hide the internal state; algebraic data types give constructors and operations but expose the structure (the enum and its cases). To hide it in Scala, make the cases private and put constructors in the object: nobody outside can pattern match any more (the deck notes this can be circumvented, as later lessons show).

object EncapsulatedSequences:

  // a generic sequence with encapsulated state
  enum Sequence[E]:
    private case Cons(head: E, tail: Sequence[E])
    private case Nil()

  // cases are visible only here
  object Sequence:

    def cons[E](head: E, tail: Sequence[E]): Sequence[E] = Cons(head, tail)

    def nil[E](): Sequence[E] = Nil[E]()

    def sum(l: Sequence[Int]): Int = l match
      case Cons(h, t) => h + sum(t)
      case _          => 0

    def map[A, B](l: Sequence[A])(mapper: A => B): Sequence[B] = l match
      case Cons(h, t) => Cons(mapper(h), map(t)(mapper))
      case Nil()      => Nil()

object AdditionalSequencesAlgorithms:
  import EncapsulatedSequences.*
  // cases are not accessible: how could this be implemented?
  def filter[A](l1: Sequence[A])(pred: A => Boolean): Sequence[A] = ???

@main def tryEncapsulatedSequences =
  import EncapsulatedSequences.*, Sequence.*

  val l = cons(10, cons(20, cons(30, nil())))
  println(sum(map(l)(_ + 1)))   // 63

The price: AdditionalSequencesAlgorithms cannot write filter by matching ("private object Cons can only be accessed from object Sequence"). Outside code can only combine exported operations, so the module must export enough of them.

3. What does "declarative" mean?

sum(map(filter(l)(_ >= 20))(_ + 1)) does not say what is performed or in which order: it builds a sequence from other sequences and aggregates it. A multi-core PC or a cloud of machines could evaluate it very differently inside, with the same result; platforms such as Apache Spark and Map-Reduce are essentially FP over the cloud-edge continuum. A program is declarative if it states what is to be achieved, abstracting from the step-by-step how: a sort of holy grail of computer science.

A functional program is a composition such as a(b(c(1, 2), 3), d(4)). With f(x, y) = x + x, f(g(), h()) can be computed by evaluating g() and h() first, or by rewriting to g() + g(), or by evaluating only g(); we expect the order not to matter. Which programs are really independent of evaluation order, and how can the order be controlled when needed? The formal answer takes a fully expressive core of FP, the λ-calculus, and proves it confluent ("don't-care non-determinism"). With Turing machines, the λ-calculus is the backbone of computer science: it grounded computability, then FP, and later OOP.

4. What is the λ-calculus?

A tiny language of one-argument functions and applications; many-argument functions, data types and recursion are all derived. Its syntax (Alonzo Church, 1930s):

L ::= x | (λx.L) | L1 L2

x is a variable; λx.L a function with argument x and body L (Scala's x => L); L1 L2 applies L1 to L2 (Scala's L1(L2)). Application is left-associative (L1 L2 L3 ≡ (L1 L2) L3), abstraction right-associative (λx.λy.L ≡ λx.(λy.L)), and application binds tighter (λx.L1 L2 ≡ λx.(L1 L2)). The only rule is β-reduction, at any depth, where LB{LP/x} substitutes LP for x:

(λx.LB) LP  →  LB{LP/x}

Calling a function gives its body with the formal parameter replaced by the actual one. For example (λx.λy.x) a b → (λy.a) b → a: no types, no numbers, only rewriting.

Beyond the slides

Substitution must not capture variables: (λx.λy.x) y is not λy.y but λy'.y, after renaming the bound y. The reducer in section 6 does this.

Church encoding of booleans

A data type is some values acting as constructors plus some values that manipulate them:

T := λx.λy.x        F := λx.λy.y            (choose the first / the second)
Not := λx.x F T     And := λx.λy.x y F      Or := λx.λy.x T y

The deck's reduction of And (Not F) T (≡ unfolds a name, →→ is two β-steps):

And (Not F) T ≡ And ((λx.x F T) F) T
              → And (F F T) T
              ≡ And ((λx.λy.y) F T) T
             →→ And T T
              ≡ (λx.λy.x y F) T T
             →→ T T F
              ≡ (λx.λy.x) T F
             →→ T                        (no further reduction)

The same encoding runs in Scala, with a type that is a function from itself to itself; the tests (all passing) turn a λ-boolean into a Boolean by applying it to two distinct identity functions and seeing which comes back.

object Lambda:

  // a type Lambda that extends the function type Lambda => Lambda itself
  // (just one of the many magic tricks of Scala)
  trait Lambda extends (Lambda => Lambda)

  val True: Lambda = x => y => x
  val False: Lambda = x => y => y
  val Not: Lambda = b => b(False)(True)
  val And: Lambda = b1 => b2 => b1(b2)(False)
  val Or: Lambda = b1 => b2 => b1(True)(b2)
From the slides: the λ-calculus in Scala style, and the tests

An untyped subset of Scala, L ::= V | V => L | L1(L2), is exactly the λ-calculus:

Scalaλ-term
val t: L = x => y => xλx.λy.x
val f: L = x => y => yλx.λy.y
val n: L = b => b(f)(t)λb.b (λx.λy.y) (λx.λy.x)
val a: L = b1 => b2 => b1(b2)(f)λb1.λb2.b1 b2 (λx.λy.y)
val o: L = b1 => b2 => b1(t)(b2)λb1.λb2.b1 (λx.λy.x) b2

t("a")("b") gives "a" and f("a")("b") gives "b"; the deck asks what n, a and o compute on t and f: they are not, and, or.

import org.junit.*
import org.junit.Assert.*

abstract class LambdaTest:
  import Lambda.*

  def toBoolean(l: Lambda): Boolean =
    val l1: Lambda = x => x
    val l2: Lambda = y => y
    l(l1)(l2) match
      case res if res == l1 => true
      case res if res == l2 => false
      case _ => ??? // throws an exception

class BooleanLambdaTest extends LambdaTest:
  import Lambda.*

  @Test def testNot() =
    assertEquals(Not(False), True)
    assertEquals(Not(True), False)

  @Test def testOr() =
    assertFalse(toBoolean(Or(False)(False)))
    assertTrue(toBoolean(Or(False)(True)))
    assertTrue(toBoolean(Or(True)(False)))
    assertTrue(toBoolean(Or(True)(True)))

  @Test def testAnd() =
    assertFalse(toBoolean(And(False)(False)))
    assertFalse(toBoolean(And(False)(True)))
    assertFalse(toBoolean(And(True)(False)))
    assertTrue(toBoolean(And(True)(True)))

assertEquals(Not(False), True) works without structural equality of functions: Not(False) is False(False)(True), which returns the very object True.

5. How expressive is the λ-calculus?

Many arguments are encoded by currying: (λx1.….λxn.L) L1 … Ln →* L{L1/x1, …, Ln/xn}, Scala's ((x1,…,xn) => L)(L1,…,Ln), where →* is zero or more steps. Naturals are Church numerals: n applies s n times to z.

0 := λs.λz.z     1 := λs.λz.s z     2 := λs.λz.s (s z)     …
inc := λn.λs.λz.s (n s z)            add := λn.λm.λs.λz.n s (m s z)

Recursion comes from the Y combinator, Y := λg.(λx.g (x x)) (λx.g (x x)), with Y F →* F (Y F) →* F (F (Y F)) →* … (as the deck writes it; strictly, Y F and F (Y F) are β-equal rather than one reducing to the other: both reduce to F W with W = (λx.F (x x)) (λx.F (x x)), and W → F W): it hands a function a copy of itself, enough for factorial as f(x) = x == 0 ? 1 : x * f(x - 1). The λ-calculus is Turing-complete: for any computable function, with its domains encoded as λ-terms, some λ-term mimics it. Everything else in FP (two-argument functions, products, sums, …) is syntactic sugar.

From the slides and the course code: the proof strategy, and numerals in Scala

One proof shows that the λ-calculus generates all general recursive functions by encoding constant functions, the successor, projection (identity), functional composition, recursion and minimisation (the smallest argument for which a function is 0). Hence successor, sum, product, factorial, primality, and any Turing-computable function.

object LambdaNat:
  import Lambda.*
  val N0: Lambda = s => z => z
  val N1: Lambda = s => z => s(z)
  val N2: Lambda = s => z => s(s(z))
  val IsZero: Lambda = n => n(x => False)(True)
  val Succ: Lambda = n => s => z => s(n(s)(z))
  val Plus: Lambda = n => m => s => z => n(s)(m(s)(z))
  val Pred: Lambda = n => n(g => h => h(g(Succ)))(u => N0)(u => u)
import org.junit.*
import org.junit.Assert.*

class NaturalLambdaTest extends LambdaTest:
  import Lambda.*, LambdaNat.*

  @Test def testZero() =
    assertTrue(toBoolean(IsZero(N0)))
    assertFalse(toBoolean(IsZero(N1)))

  @Test def testSuccPred() =
    assertTrue(toBoolean(IsZero(Pred(Succ(N0)))))
    assertTrue(toBoolean(IsZero(Pred(Pred(N2)))))
    assertFalse(toBoolean(IsZero(Pred(Succ(N1)))))

  @Test def testPlus() =
    assertTrue(toBoolean(IsZero(Pred(Pred(Plus(N1)(N1))))))
    assertFalse(toBoolean(IsZero(Pred(Plus(N1)(N1)))))
Beyond the slides

The repository's Lambda.scala writes Plus as n(s(m(s)(z))), which is not add: for 1 + 1 it reduces to λs.λz.λz'.s (s z) z', not a numeral. Its tests pass by accident, since they observe results only through Pred and IsZero. The code above uses the slide's add.

6. When does evaluation order matter? Confluence

A λ-term may offer several redexes (add (inc 1) (inc 2), T x (T x y)); may reduce forever (Y x, Y); or may terminate or not depending on the choices (T x Y: reducing the outer T gives x, reducing inside Y never ends). The λ-calculus is confluent (Church–Rosser): if L →* L1 and L →* L2, there is an L3 with L1 →* L3 and L2 →* L3.

L L1 L2 L3 →*→* →*→* there exists L3
Confluence. The solid arrows are any two reduction sequences from L; the dashed arrows are guaranteed to exist.

So a computation has at most one result: two irreducible terms reached from L are identical up to renaming of bound variables (λx.x ≈ λy.y). If L always terminates, the reduction choice is irrelevant; if it may not terminate, the choice decides whether the result is found. A term can thus denote its result, independently of evaluation order.

Reducer: two orders, one result

Normal order reduces the leftmost, outermost redex first, passing arguments unevaluated; applicative order reduces the leftmost, innermost one first, reducing arguments before use. Names such as T are unfolded (≡) when needed. Compare the steps on T x (T x y), check that both orders agree whenever both terminate, and try T x Y, where only normal order terminates. You can type your own term (\ works for λ).

Referential transparency and declarativity

Confluence gives referential transparency: the result of an expression is independent of its context, so an expression can be replaced by its value, which allows equational reasoning and makes code easier to understand and refactor. It also gives declarativity: a program expresses what it computes, not how, and the lower layers are free to optimise and adapt, which is often perceived as magic but is just engineering. FP and logic programming (Part V) are the quintessential declarative paradigms.

7. Call-by-value, call-by-name, call-by-need

Without side effects, a terminating computation does not depend on evaluation order; the order still affects cost, termination, and the result when there are side effects.

StrategyAn argument is evaluated…In Scala
strict, call-by-value (applicative order: Java, Scala)once, just before the calldef f1(a: A)
non-strict, call-by-name (normal order)when and if used, at every usedef f2(a: => A) or a: () => A
non-strict, call-by-need (lazy: Haskell, Miranda)when and if used, at most oncedef f3(a: => A) = { lazy val v = a; … }

The experiment: a function returning (a, a), whose second argument is never used, called with plain values, with blocks that print, and with loop(20), which never returns. With Scala's syntax:

// supported CALL-BY-NAME: => T is a shorthand that avoids verbose 0-ary lambdas
def sPairBN[A](a: => A, dummy: => A): (A, A) = (a, a)
println(sPairBN(10, 20))                                    // (10,10)
println(sPairBN({println("e1"); 10}, {println("e2"); 20}))  // e1 e1 (10,10)
println(sPairBN(10, loop(20)))                              // (10,10)

// half-simulated CALL-BY-NEED: lazy val plus by-name arguments
def sPairL[A](a: => A, dummy: => A): (A, A) =
  lazy val v = a
  (v, v)
println(sPairL(10, 20))                                     // (10,10)
println(sPairL({println("e1"); 10}, {println("e2"); 20}))   // e1 (10,10)
println(sPairL(10, loop(20)))                               // (10,10)
From the slides: by value, and by name and by need simulated with 0-argument functions
def loop[A](a: A): A = loop(a)

// standard CALL-BY-VALUE: arguments are evaluated as they are passed
def sPair[A](a: A, dummy: A): (A, A) = (a, a)
println(sPair(10, 20))                                    // (10,10)
println(sPair({println("e1"); 10}, {println("e2"); 20}))  // e1 e2 (10,10)
// println(sPair(10, loop(20)))                           // never returns

// simulating CALL-BY-NAME: arguments are evaluated each time they occur in the body
def sPairSBN[A](a: () => A, dummy: () => A): (A, A) = (a(), a())
println(sPairSBN(() => 10, () => 20))                                    // (10,10)
println(sPairSBN(() => {println("e1"); 10}, () => {println("e2"); 20}))  // e1 e1 (10,10)
println(sPairSBN(() => 10, () => loop(20)))                              // (10,10)

// simulating CALL-BY-NEED (lazy): arguments are evaluated once, if they occur in the body
def sPairSL[A](a: () => A, dummy: () => A): (A, A) =
  lazy val v = a()
  lazy val d = dummy()
  (v, v)
println(sPairSL(() => 10, () => 20))                                     // (10,10)
println(sPairSL(() => {println("e1"); 10}, () => {println("e2"); 20}))   // e1 (10,10)
println(sPairSL(() => 10, () => loop(20)))                               // (10,10)

The by-value sPair(10, loop(20)) is commented out because it never returns: run in a separate thread, it was still running after 3 seconds. Since loop is tail recursive it becomes an infinite loop, not a stack overflow.

The same call, three traces

by value: sPairby name: sPairBNby need: sPairL
evaluate a: prints e1, a = 10pass both blocks unevaluatedpass both blocks unevaluated
evaluate dummy: prints e2, dummy = 20first use of a: prints e1, gives 10first use of v: runs a, prints e1, stores 10
body (a, a) = (10,10)second use of a: prints e1 againsecond use of v: stored value 10
output: e1 e2 (10,10)output: e1 e1 (10,10)output: e1 (10,10)

Same value, different work: by value pays for the unused argument (and never returns with loop(20)); by name skips it but pays twice for the used one; by need evaluates each argument at most once, and only if needed. The tracer runs any small body under the three strategies; try a + a or if a > 5 then a else dummy.

Which to choose: by value is the default, and you rarely change it. By name when execution must be delayed or avoided if unneeded: pass () => e and call x(), or declare => T to avoid writing the lambda. By need when double evaluation must also be prevented, for performance or side effects: move the argument into a lazy val first.

8. How do streams make infinite data possible?

A stream is a lazy list (Scala 3's LazyList): it manages data lazily, up to infinite flows, connected by iterate, map, filter, take and toList. The trick is to put lazy vals into lambdas at cons time; the rest is the list code.

object Streams:

  import Sequences.*

  enum Stream[A]:
    private case Empty()
    private case Cons(head: () => A, tail: () => Stream[A])

  object Stream:

    def empty[A](): Stream[A] = Empty()

    def cons[A](hd: => A, tl: => Stream[A]): Stream[A] =
      lazy val head = hd
      lazy val tail = tl
      Cons(() => head, () => tail)

    def toList[A](stream: Stream[A]): Sequence[A] = stream match
      case Cons(h, t) => Sequence.Cons(h(), toList(t()))
      case _ => Sequence.Nil()

    def map[A, B](stream: Stream[A])(f: A => B): Stream[B] = stream match
      case Cons(head, tail) => cons(f(head()), map(tail())(f))
      case _ => Empty()

    def filter[A](stream: Stream[A])(pred: A => Boolean): Stream[A] = stream match
      case Cons(head, tail) if (pred(head())) => cons(head(), filter(tail())(pred))
      case Cons(head, tail) => filter(tail())(pred)
      case _ => Empty()

    def take[A](stream: Stream[A])(n: Int): Stream[A] = (stream, n) match
      case (Cons(head, tail), n) if n > 0 => cons(head(), take(tail())(n - 1))
      case _ => Empty()

    def iterate[A](init: => A)(next: A => A): Stream[A] =
      cons(init, iterate(next(init))(next))

  end Stream

@main def tryStreams =
  import Streams.*

  val str1 = Stream.iterate(0)(_ + 1)                     // {0,1,2,3,..}
  val str2 = Stream.map(str1)(_ + 1)                      // {1,2,3,4,..}
  val str3 = Stream.filter(str2)(x => (x < 3 || x > 20))  // {1,2,21,22,..}
  val str4 = Stream.take(str3)(10)                        // {1,2,21,22,..,28}
  println(Stream.toList(str4))
  // Cons(1,Cons(2,Cons(21,Cons(22,Cons(23,Cons(24,Cons(25,Cons(26,Cons(27,Cons(28,Nil()))))))))))

  lazy val corec: Stream[Int] = Stream.cons(1, corec)     // {1,1,1,..}
  println(Stream.toList(Stream.take(corec)(10)))
  // Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Cons(1,Nil()))))))))))

Cons holds thunks (0-argument functions), and its cases are private. cons takes head and tail by name and caches them in lazy vals: call-by-need. The recursive call passed to cons in map, filter and take is therefore not evaluated until someone asks for the tail, so take(str3)(10) pulls exactly ten elements through an infinite pipeline, and the corecursive corec can be its own tail. toList forces everything and works only on finite streams, and a filter that no element passes searches forever. Lists and streams are the same algorithms over strict or lazy constructors, which answers the Java question of lesson 2.

Tracing what is computed, and when (beyond the slides)
@main def traceStreams =
  import Streams.*
  val nats = Stream.iterate(0)(i => { println(s"  next($i)"); i + 1 })
  val evens = Stream.filter(nats)(i => { println(s"  test $i"); i % 2 == 0 })
  println("built")
  println(Stream.toList(Stream.take(evens)(2)))
  test 0
built
  next(0)
  test 1
  next(0)
  next(1)
  test 2
  next(0)
  next(1)
  next(2)
  test 3
  next(0)
  next(1)
  next(2)
  next(3)
  test 4
Cons(0,Cons(2,Nil()))

filter tests 0 before built: to pick a case it must look at the first element, so it runs eagerly up to the first element that passes. And next(0) is recomputed for every element: iterate's by-name init is used twice, once cached by cons and once in next(init), passed on again by name, so element k is next(next(…next(0))) recomputed from scratch, about n²/2 calls for n elements. A lazy val turns this into call-by-need:

// iterate with call-by-need: init is evaluated once, and so is each next(...)
def iterateNeed[A](init: => A)(next: A => A): Streams.Stream[A] =
  lazy val a = init
  Streams.Stream.cons(a, iterateNeed(next(a))(next))

@main def traceIterateNeed =
  import Streams.*
  val nats = iterateNeed(0)(i => { println(s"  next($i)"); i + 1 })
  println(Stream.toList(Stream.take(nats)(5)))
  next(0)
  next(1)
  next(2)
  next(3)
Cons(0,Cons(1,Cons(2,Cons(3,Cons(4,Nil())))))

9. What are extension methods?

An extension method, extension (x: T) def m(arg1: T1, …): To = …, extracts one argument of a method so that it can also be called as if it were the receiver, x.m(…). It is only syntax, but it gives OO-like calls without OOP, chained calls for pipelines, and modules with both plain methods (factories) and extension methods (operations).

object ExtensionMethods extends App:

  // functional style for declaring an operation
  def mult(n: Int, m: Int): Int = n * m

  // functional style of call
  println:
    mult(5, 3)       // 15

  // an extension method: a twist on the definition, extracting an argument
  extension (n: Int) def mult2(m: Int): Int = n * m
  // the standard call, in curried form, is still possible
  println:
    mult2(5)(3)      // 15
  // OO-like syntax, as pure syntactic sugar
  println:
    5.mult2(3)       // 15

  import u03.Sequences.*
  import Sequence.*

  // cannot chain calls!
  val seq = Cons(10, Cons(20, Cons(30, Nil())))
  println:
    Sequence.sum(map(filter(seq)(_ > 10))(_ + 1))   // 52

  // size and filt as extension methods on a generic Sequence[A]
  extension [A](s: Sequence[A])

    def size: Int = s match
      case Cons(_, t) => 1 + t.size
      case _ => 0

    def filt(p: A => Boolean): Sequence[A] = s match
      case Cons(h, t) if p(h) => Cons(h, t.filt(p))
      case Cons(_, t) => t.filt(p)
      case _ => Nil()

  // can chain method calls now!
  println:
    seq.filt(_ > 10).size   // 2

One extension clause can define several methods and be generic; inside, recursion uses the new syntax (t.size). The whole Sequence module rewritten this way, with sum only on Sequence[Int] and of as an ordinary factory:

object Sequences:

  enum Sequence[E]:
    case Cons(head: E, tail: Sequence[E])
    case Nil()

  object Sequence:

    extension (l: Sequence[Int])
      def sum: Int = l match
        case Cons(h, t) => h + t.sum
        case _          => 0

    extension [A](l: Sequence[A])

      def map[B](mapper: A => B): Sequence[B] = l match
        case Cons(h, t) => Cons(mapper(h), t.map(mapper))
        case Nil()      => Nil()

      def filter(pred: A => Boolean): Sequence[A] = l match
        case Cons(h, t) if pred(h) => Cons(h, t.filter(pred))
        case Cons(_, t)            => t.filter(pred)
        case Nil()                 => Nil()

    def of[A](n: Int, a: A): Sequence[A] =
      if (n == 0) then Nil[A]() else Cons(a, of(n - 1, a))

@main def trySequences() =
  import Sequences.*
  import Sequence.*

  val seq = Cons(10, Cons(20, Cons(30, Nil())))
  println(seq.filter(_ >= 20).map(_ + 1).sum)     // 52  (21 + 31)
  println(sum(map(filter(seq)(_ >= 20))(_ + 1)))  // 52: equally possible
  val seq2 = of(10, -1)                           // Cons(-1, Cons(-1, Cons(-1, ...)))
  println(seq2.sum)                               // -10
From the course code: streams with extension methods
object Streams:

  import Sequences.*

  enum Stream[A]:
    case Empty()
    case Cons(head: () => A, tail: () => Stream[A])

  object Stream:

    def cons[A](hd: => A, tl: => Stream[A]): Stream[A] =
      lazy val head = hd
      lazy val tail = tl
      Cons(() => head, () => tail)

    extension [A](stream: Stream[A])
      def toList: Sequence[A] = stream match
        case Cons(h, t) => Sequence.Cons(h(), t().toList)
        case _ => Sequence.Nil()

      def map[B](f: A => B): Stream[B] = stream match
        case Cons(head, tail) => cons(f(head()), tail().map(f))
        case _ => Empty()

      def filter(pred: A => Boolean): Stream[A] = stream match
        case Cons(head, tail) if (pred(head())) => cons(head(), tail().filter(pred))
        case Cons(head, tail) => tail().filter(pred)
        case _ => Empty()

      def take(n: Int): Stream[A] = (stream, n) match
        case (Cons(head, tail), n) if n > 0 => cons(head(), tail().take(n - 1))
        case _ => Empty()

    def generate[A](supplier: () => A): Stream[A] =
      cons(supplier(), generate(supplier))

  end Stream

@main def streamExample =
  import Streams.*
  import Stream.*

  println(generate(() => "a").take(3).toList)  // Cons(a,Cons(a,Cons(a,Nil())))
  println:
    generate(() => Math.random())    // an infinite stream of random numbers
      .map(x => (x * 10).toInt)
      .filter(x => x < 5)
      .take(10)
      .toList                        // e.g. Cons(3,Cons(0,Cons(4,...))): 10 digits below 5

The deck's conclusion: FP needs very few concepts; a stream library takes little boilerplate, and its algorithms are those of lists. Module types, type classes and monads follow in lesson 4.

10. Lab 03: exercises and solutions

The lab (fork unibo-pps/pps-lab03) extends sequences and streams. Its tests for part 1 are not in the available material, so the signatures below follow the task descriptions. Each solution was compiled and run; the outputs are in the last block.

Part 1 (lists): skip, zip, concat, reverse, flatMap, plus foldLeft, contains, distinct

Added inside object Sequence. The first five are mandatory; min, evenIndices, group and partition are further optional exercises.

def skip[A](s: Sequence[A])(n: Int): Sequence[A] = s match
  case Cons(_, t) if n > 0 => skip(t)(n - 1)
  case _ => s

def zip[A, B](first: Sequence[A], second: Sequence[B]): Sequence[(A, B)] =
  (first, second) match
    case (Cons(h1, t1), Cons(h2, t2)) => Cons((h1, h2), zip(t1, t2))
    case _ => Nil()

def concat[A](l1: Sequence[A], l2: Sequence[A]): Sequence[A] = l1 match
  case Cons(h, t) => Cons(h, concat(t, l2))
  case Nil() => l2

def reverse[A](l: Sequence[A]): Sequence[A] =
  @annotation.tailrec
  def loop(rest: Sequence[A], acc: Sequence[A]): Sequence[A] = rest match
    case Cons(h, t) => loop(t, Cons(h, acc))
    case Nil() => acc
  loop(l, Nil())

def flatMap[A, B](l: Sequence[A])(mapper: A => Sequence[B]): Sequence[B] = l match
  case Cons(h, t) => concat(mapper(h), flatMap(t)(mapper))
  case Nil() => Nil()

@annotation.tailrec
def foldLeft[A, B](l: Sequence[A])(acc: B)(op: (B, A) => B): B = l match
  case Cons(h, t) => foldLeft(t)(op(acc, h))(op)
  case Nil() => acc

def contains[A](l: Sequence[A])(a: A): Boolean = l match
  case Cons(h, t) => h == a || contains(t)(a)
  case Nil() => false

def distinct[A](l: Sequence[A]): Sequence[A] =
  def loop(rest: Sequence[A], seen: Sequence[A]): Sequence[A] = rest match
    case Cons(h, t) if contains(seen)(h) => loop(t, seen)
    case Cons(h, t) => Cons(h, loop(t, Cons(h, seen)))
    case Nil() => Nil()
  loop(l, Nil())

foldLeft's accumulator type B may differ from A; with accumulator and operator in separate lists, B is fixed before _ - _ is checked: foldLeft(lst)(0)(_ - _) is (((0 − 3) − 7) − 1) − 5 = −16.

Part 3 (streams): takeWhile, fill, fromList, interleave, cycle

Added inside object Stream, since the cases are private:

def takeWhile[A](stream: Stream[A])(pred: A => Boolean): Stream[A] = stream match
  case Cons(head, tail) if pred(head()) => cons(head(), takeWhile(tail())(pred))
  case _ => Empty()

def fill[A](n: Int)(k: A): Stream[A] =
  if n > 0 then cons(k, fill(n - 1)(k)) else Empty()

def fromList[A](l: List[A]): Stream[A] = l match
  case h :: t => cons(h, fromList(t))
  case _ => Empty()

def interleave[A](s1: Stream[A], s2: Stream[A]): Stream[A] = s1 match
  case Cons(head, tail) => cons(head(), interleave(s2, tail()))
  case _ => s2

def cycle[A](lst: Sequence[A]): Stream[A] =
  def from(rest: Sequence[A]): Stream[A] = rest match
    case Sequence.Cons(h, t) => cons(h, from(t))
    case Sequence.Nil() => from(lst)
  lst match
    case Sequence.Nil() => Empty()
    case _ => from(lst)

Each returns a cons whose tail is a by-name recursive call. cycle restarts from the whole list at the end, and returns an empty stream for an empty list instead of looping.

Part 2 (more on lists) and all the outputs

Filtering teachers and then mapping to courses forces the mapping to handle a case the filter already removed; flatMap does both at once. The distinct count uses filter, map, distinct and foldLeft, as the task asks. The Fibonacci stream is corecursive, like corec.

enum Person:
  case Student(name: String, year: Int)
  case Teacher(name: String, course: String)

import Person.*, Sequences.*, Sequence.*

def courses(s: Sequence[Person]): Sequence[String] = flatMap(s):
  case Teacher(_, course) => Cons(course, Nil())
  case _ => Nil()

def distinctCourses(s: Sequence[Person]): Int =
  val teachers = filter(s):
    case Teacher(_, _) => true
    case _ => false
  val names = map(teachers):
    case Teacher(_, course) => course
    case _ => ""                      // unreachable after the filter
  foldLeft(distinct(names))(0)((n, _) => n + 1)

@main def tryLab03 =
  val l = Cons(10, Cons(20, Cons(30, Nil())))
  println(skip(l)(1))                          // Cons(20,Cons(30,Nil()))
  println(zip(l, Cons("a", Cons("b", Nil())))) // Cons((10,a),Cons((20,b),Nil()))
  println(concat(l, Cons(40, Nil())))          // Cons(10,Cons(20,Cons(30,Cons(40,Nil()))))
  println(reverse(l))                          // Cons(30,Cons(20,Cons(10,Nil())))
  println(flatMap(l)(v => Cons(v, Cons(v + 1, Nil()))))
  // Cons(10,Cons(11,Cons(20,Cons(21,Cons(30,Cons(31,Nil()))))))
  val lst = Cons(3, Cons(7, Cons(1, Cons(5, Nil()))))
  println(foldLeft(lst)(0)(_ - _))             // -16
  val people = Cons(Teacher("Viroli", "PPS"), Cons(Student("Rossi", 2024),
    Cons(Teacher("Aguzzi", "PPS"), Cons(Teacher("Ricci", "PCD"), Nil()))))
  println(courses(people))                     // Cons(PPS,Cons(PPS,Cons(PCD,Nil())))
  println(distinctCourses(people))             // 2

  import Streams.*
  val stream = Stream.iterate(0)(_ + 1)
  println(Stream.toList(Stream.takeWhile(stream)(_ < 5)))
  // Cons(0,Cons(1,Cons(2,Cons(3,Cons(4,Nil())))))
  println(Stream.toList(Stream.fill(3)("a")))  // Cons(a,Cons(a,Cons(a,Nil())))

  def fibs(a: Int, b: Int): Stream[Int] = Stream.cons(a, fibs(b, a + b))
  val fibonacci: Stream[Int] = fibs(0, 1)
  println(Stream.toList(Stream.take(fibonacci)(8)))
  // Cons(0,Cons(1,Cons(1,Cons(2,Cons(3,Cons(5,Cons(8,Cons(13,Nil()))))))))

  val s1 = Stream.fromList(List(1, 3, 5))
  val s2 = Stream.fromList(List(2, 4, 6, 8, 10))
  println(Stream.toList(Stream.interleave(s1, s2)))
  // Cons(1,Cons(2,Cons(3,Cons(4,Cons(5,Cons(6,Cons(8,Cons(10,Nil()))))))))
  val repeat = Stream.cycle(Cons('a', Cons('b', Cons('c', Nil()))))
  println(Stream.toList(Stream.take(repeat)(5)))
  // Cons(a,Cons(b,Cons(c,Cons(a,Cons(b,Nil())))))

Check your understanding

Why does the generic Optional[A] declare case Empty() with parentheses, while OptionalInt has case Empty?

A parameterless case is a single object and must extend Optional[A] for one fixed A, which an invariant type parameter does not allow. Empty() is a case class with its own type parameter, so there is one for each A.

What does the bound in def orElse[A, B >: A](opt: Optional[A], orElse: B): B allow?

The default value may belong to any supertype B of A; the result has that wider type, since it is either the contained A (which is a B) or the default.

What do you gain and what do you lose by declaring the cases of Sequence private?

You gain encapsulation: clients use only the exported constructors and operations, so the representation can change. You lose extensibility: outside modules can no longer write new algorithms, such as filter, by pattern matching.

Reduce Not T with T := λx.λy.x, F := λx.λy.y, Not := λx.x F T.

Not T ≡ (λx.x F T) T → T F T ≡ (λx.λy.x) F T → (λy.F) T → F.

State the Church–Rosser theorem and its consequence for a terminating program.

If L →* L1 and L →* L2, there is an L3 with L1 →* L3 and L2 →* L3. So a term has at most one normal form (up to renaming of bound variables), and if every reduction terminates the order is irrelevant.

Give a term whose termination depends on the reduction order, and explain.

T x Y: reducing the outer T first discards Y and gives x; reducing inside the argument Y first never ends, because Y keeps unfolding.

What does sPairBN({println("e1"); 10}, {println("e2"); 20}) print with def sPairBN[A](a: => A, dummy: => A) = (a, a)? And by value, and by need?

By name: e1 e1 (10,10), since a is evaluated at each use and dummy never. By value: e1 e2 (10,10). By need (lazy val v = a; (v, v)): e1 (10,10).

What happens with sPair(10, loop(20)) where def loop[A](a: A): A = loop(a)? And with sPairBN(10, loop(20))?

By value, loop(20) is evaluated before the call and never returns: since loop is tail recursive, it runs as an infinite loop rather than overflowing the stack. By name, dummy is never used, so the result is (10,10).

How does Stream differ from Sequence in its implementation, and why can it be infinite?

Cons stores thunks () => A and () => Stream[A] instead of values, and the smart constructor cons takes its arguments by name and caches them in lazy vals. The tail is computed only when requested, so a stream can describe infinitely many elements and take decides how many are ever computed.

What is an extension method, and what does it change at run time?

A method defined with one argument extracted, extension (x: T) def m(…), so that it can be called as x.m(…) as well as m(x)(…). Nothing changes at run time: it is syntax, which enables chained, fluent pipelines without OOP.

Where this lesson comes from

Deck 03-fp-structures, "Data Structures in Functional Programming" (all 47 slides: generics, pragmatical expressiveness, declarativity and λ-calculus, evaluation order, streams, extension methods) · Lab 03 (all tasks; part 1 solved with the mandatory methods plus contains and distinct) · course code u03 (OptionalInts, Optionals, Generics, Tuples, Sequences, BTrees, EncapsulatedSequences, Lambda, Lazyness, Streams, extensionmethods/*) and its tests (OptionTestImproved, SequenceTest, LambdaTest, BooleanLambdaTest, NaturalLambdaTest). Every example was compiled and run with Scala 3.3.6 and every JUnit test executed; the printed outputs are the real ones. The two instruments (assets/lambda-lab.js, assets/eval-tracer.js) are tested in node against the derivations and the Scala outputs of this page.