header source
my icon
esplo.net
ぷるぷるした直方体
Cover Image for Scala Functional Design & Programming - Chapter 3 (3)

Scala Functional Design & Programming - Chapter 3 (3)

だいたい 24 分で読めます

Table of Contents

I bought "Scala Functional Design & Programming - A Comprehensive Guide to Functional Programming by Scalaz Contributors".
I'll read it bit by bit and summarize each chapter.

amazon img

This time, I'll cover up to Chapter 3. The main topic is data updates using pure functions, which is a key aspect of functional programming.

https://www.esplo.net/posts/2016/09/sfdp-2

Chapter 3

This chapter focuses on how to update data using pure functions, which do not have side effects. It starts with the definition of a list and explains pattern matching.

As expected, match is very handy.

There's a simple quiz on pattern matching (omitted).

Next, it discusses how to take advantage of the fact that data is immutable to reduce memory usage. This is followed by a series of exercises.

From here, a barrage of practice problems begins.
Many appear one after another; it feels intense.

It really feels like cramming-style programming.

Implement tail, setHead, drop, dropWhile, and init for lists.

These exercises are designed to help you understand how to work with lists in a functional programming style.

They’re short and focused, which helps build intuition.

The list data type and exercise stubs are available on GitHub.

https://github.com/fpinscala/fpinscala/blob/master/exercises/src/main/scala/fpinscala/datastructures/List.scala

For error handling, there are several possible approaches, but I chose to return Nil for simplicity here.

  def tail[A](l: List[A]): List[A] = l match {
    case Cons(_, xs) => xs
    case Nil => Nil
  }

  def setHead[A](l: List[A], h: A): List[A] = l match {
    case Cons(_, xs) => Cons(h, xs)
    case Nil => Nil
  }

  def drop[A](l: List[A], n: Int): List[A] = {
    if(n <= 0)
      l
    else {
      l match {
        case i =>
          l match {
            case Cons(_, xs) => drop(xs, n - 1)
            case _ => l
          }
      }
    }
  }

  def dropWhile[A](l: List[A], f: A => Boolean): List[A] = l match {
    case Cons(x, xs) if f(x) => dropWhile(xs, f)
    case _ => l
  }

  def init[A](l: List[A]): List[A] = l match {
    case Cons(_, Nil) => Nil
    case Cons(x, xs) => Cons(x, init(xs))
    case Nil => Nil
  }

The following is test code:

object Main extends App {
  val x = List(1,2,3,4,5)
  val y = List()

  println("  ## ex 3.2")
  println(List.tail(x))
  println(List.tail(y))

  println("  ## ex 3.3")
  println(List.setHead(x, 10))
  println(List.setHead(y, 10))

  println("  ## ex 3.4")
  println(List.drop(x, 1))
  println(List.drop(x, 4))
  println(List.drop(x, 10))
  println(List.drop(y, 1))

  println("  ## ex 3.5")
  println(List.dropWhile(x, (a:Int)=> a < 4))
  println(List.dropWhile(x, (a:Int)=> a > 4))
  println(List.dropWhile(x, (a:Int)=> a < 100))

  println("  ## ex 3.6")
  println(List.init(x))
  println(List.init(y))
}

The drop function is a bit messy, but it's a good exercise.

As a small tip, the book shows how to improve type inference for dropWhile by currying so that the second argument becomes inferable. That makes the call sites cleaner. Nice.

Can product via foldRight immediately stop recursion and return 0.0 when it detects a 0.0?

You can certainly write a custom product that returns 0.0 once it encounters a 0.0 element and avoids recursing further. However, with foldRight, the combining function f is applied after the entire structure is expanded, so you can’t short-circuit “immediately” in the strict sense. In that sense, the answer is No.

What happens if we pass Nil and Cons themselves to foldRight? How does this relate to data constructors?

Through folding, you can reconstruct the list using its own constructors. Treating “combine two elements” as an operation and repeating it lets you build a singly linked list—an example of abstract thinking with data constructors.

Next, the book introduces foldRight and foldLeft, which are essential functions in functional programming.

Implement product using foldRight.

  def product(l: List[Double]): Double = foldRight(l, 1.0)(_ * _)

Implement length using foldRight.

  def length[A](l: List[A]): Int = foldRight(l, 0)((_, y) => y + 1)

Implement foldLeft, which is tail-recursive.

  @tailrec
  def foldLeft[A, B](l: List[A], z: B)(f: (B, A) => B): B = l match {
    case Nil => z
    case Cons(x, xs) => foldLeft(xs, f(z, x))(f)
  }

Implement sum, product, and length using foldLeft.

  def flSum(l: List[Int]) = foldLeft(l, 0)(_ + _)
  def flProduct(l: List[Double]) = foldLeft(l, 1.0)(_ * _)
  def flLength[A](l: List[A]): Int = foldLeft(l, 0)((x,y) => x + 1)

Implement reverse using foldLeft.

  def reverse[A](l: List[A]): List[A] = foldLeft(l, Nil: List[A])((x,y) => Cons(y, x))

It’s neat to see how foldLeft can build a reversed list succinctly.

When you construct with foldRight you get the normal order, while foldLeft naturally builds the reverse.

Implement foldRight using foldLeft.

  def foldLeft2[A, B](l: List[A], z: B)(f: (B, A) => B): B = {
    val f2: (A, B) => B = (a: A, b: B) => f(b, a)
    foldRight(reverse(l), z)(f2)
  }

At first glance this looks straightforward, but the book also presents a gnarly implementation that flips evaluation order using higher-order functions.

Because the application order differs, I also prepared f2 with flipped argument order.

def foldRightViaFoldLeft_1[A,B](l: List[A], z: B)(f: (A,B) => B): B =
  foldLeft(l, (b:B) => b)((g,a) => b => g(f(a,b)))(z)

This is hard to grasp without concrete examples, but by substituting actual values you can see how the control flow changes.

The book also covers more advanced topics, such as implementing append, concat, plusOne, doubleToString, map, filter, and flatMap using foldRight and foldLeft.

Here are some additional implementations I tried, following the flow of the chapter:

Implement append using fold.

def flAppend[A](a1: List[A], a2: List[A]): List[A] =
  foldRight(a1, a2)(Cons(_, _))

[Hard] Concatenate a list of lists into a single list.

def concat[A](l: List[List[A]]): List[A] =
  foldLeft(l, Nil: List[A])(append)

The problems marked [Hard] in the book are indeed a bit painful.

With IDE help (e.g., IntelliJ), you can partially apply append directly:

foldLeft(l, Nil: List[A])(append)

Return a list where each element is incremented by 1.

def plusOne(l: List[Int]): List[Int] =
  foldRight(l, Nil: List[Int])((x,y) => Cons(x + 1, y))

Convert List[Double] to List[String].

def doubleToString(l: List[Double]): List[String] =
  foldRight(l, Nil: List[String])((x,y) => Cons(x.toString, y))

Implement map.

def map[A, B](l: List[A])(f: A => B): List[B] =
  foldRight(l, Nil: List[B])((x,y) => Cons(f(x), y))

Implement filter.

def filter[A](as: List[A])(f: A => Boolean): List[A] =
  foldRight(as, Nil: List[A])((x,y) => if (f(x)) Cons(x, y) else y)

Implement flatMap.

def flatMap[A, B](as: List[A])(f: A => List[B]): List[B] =
  concat(map(as)(f))

Clean.

Implement filter using flatMap.

def ffilter[A](as: List[A])(f: A => Boolean): List[A] = {
  val t = (a: A) => if (f(a)) Cons(a, Nil) else Nil
  flatMap(as)(t)
}

Implement plus that adds two List[Int] element-wise.

def plus(l1: List[Int], l2: List[Int]): List[Int] = {
  def loop(xs1: List[Int], xs2: List[Int]): List[Int] = xs1 match {
    case Cons(x, xs) => xs2 match {
      case Cons(y, ys) => Cons(x + y, loop(xs, ys))
      case Nil => Nil
    }
    case Nil => Nil
  }
  loop(l1, l2)
}

Generalize to zipWith.

def zipWith[A, B, C](l1: List[A], l2: List[B])(f: (A,B) => C): List[C] = {
  def loop(xs1: List[A], xs2: List[B]): List[C] = xs1 match {
    case Cons(x, xs) => xs2 match {
      case Cons(y, ys) => Cons(f(x, y), loop(xs, ys))
      case Nil => Nil
    }
    case Nil => Nil
  }
  loop(l1, l2)
}

Alternative patterns for two lists at once.

def plus2(l1: List[Int], l2: List[Int]): List[Int] = (l1, l2) match {
  case (Nil, _) => Nil
  case (_, Nil) => Nil
  case (Cons(x,xs), Cons(y,ys)) => Cons(x + y, plus2(xs, ys))
}

def zipWith2[A](l1: List[A], l2: List[A])(f: (A,A) => A): List[A] = (l1, l2) match {
  case (Nil, _) => Nil
  case (_, Nil) => Nil
  case (Cons(x,xs), Cons(y,ys)) => Cons(f(x, y), zipWith2(xs, ys)(f))
}

Implement take and then use it to implement hasSubsequence.

def take[A](l: List[A], n: Int): List[A] = n match {
  case 0 => Nil
  case _ => l match {
    case Cons(x, xs) => Cons(x, take(xs, n-1))
    case Nil => Nil
  }
}

def hasSubsequence[A](sup: List[A], sub: List[A]): Boolean = {
  def make(l: List[A], n: Int): Boolean =
    if (take(l, n) == sub) true else l match {
      case Nil => false
      case Cons(_, xs) => make(xs, n)
    }
  make(sup, length(sub))
}

The behavior looks fine, though it still feels a bit clunky.

Looking at the book’s answer, it first defines startsWith and then uses that. The idea is to keep checking prefix matches from the front—simple and sufficient.

Both versions are tail-recursive and pleasantly simple, but as the authors note, it’s not always obvious to convince yourself they’re correct. This gets revisited in Chapter 5, so I’ll wait until then.

Finally, it introduces trees as a new data structure and explains how to implement functions such as size, maximum, depth, and map for trees.

Trees

After a large number of list exercises, a new data structure appears: the tree. There is an explanation of algebraic data types, implementations for Tree, and practice problems. Finally, we generalize these functions with a fold over trees and reimplement them using that abstraction.

Implement size, maximum, depth, and map for a binary tree.

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

def maximum(t: Tree[Int]): Int = t match {
  case Leaf(a) => a
  case Branch(l, r) => maximum(l).max(maximum(r))
}

def depth[A](t: Tree[A]): Int = t match {
  case Leaf(_) => 1
  case Branch(l, r) => depth(l).max(depth(r))
}

def map[A, B](t: Tree[A])(f: A => B): Tree[B] = t match {
  case Leaf(a) => Leaf(f(a))
  case Branch(l, r) => Branch(map(l)(f), map(r)(f))
}

We can also define a general fold over trees and reimplement the above using it:

def fold[A,B](t: Tree[A])(f: A => B)(g: (B,B) => B): B = t match {
  case Leaf(a) => f(a)
  case Branch(l, r) => g(fold(l)(f)(g), fold(r)(f)(g))
}

def size2[A](t: Tree[A]): Int = fold(t)(_ => 1)(_ + _)
def maximum2(t: Tree[Int]): Int = fold(t)(a => a)(_ max _)
def depth2[A](t: Tree[A]): Int = fold(t)(_ => 1)((x,y) => 1 + (x max y))
def map2[A,B](t: Tree[A])(f: A => B): Tree[B] =
  fold(t)(a => Leaf(f(a)): Tree[B])(Branch(_, _))

These implementations are pleasantly short, and the types help guide the design.

It’s finally over. The chapter closes by encouraging readers to hone their generalization skills through practice.

Thoughts

I was surprised by how short the implementations were. As I worked through the exercises, I started to see patterns and ways of thinking that are characteristic of functional programming.

Even for tasks like tree traversals that tend to feel “tedious,” being able to write them simply is worth experiencing at least once.

The next chapter will cover error handling, which is an essential aspect of programming.

Continued:

https://www.esplo.net/posts/2016/09/sfdp-4

Share