Object Algebras

The content of this chapter is available as a Scala file here.

Object algebras: A practical way of using Church Encodings

In previous lectures, we have talked about the "expression problem": The problem of encoding a data structure (like expressions) and functions on that data structure (like evaluators, or pretty-printers) in such a way that both the data structure and the functions thereon can be extended without modifying code, while at the same time maintaining static type safety.

Church encodings look like an obscure theoretical idea at first, but we will see that their incarnation as "object algebras" leads to a quite attractive and practical solution to the expression problem.

Let's look at a Scala version of the Church encodings we saw for the lambda calculus. We use objects instead of functions, but otherwise it is the same. Let's start with booleans.

trait Bool {
  def ifthenelse[T](t: T, e: T): T
}

case object T extends Bool {
  def ifthenelse[T](t: T, e: T) = t
}
case object F extends Bool {
  def ifthenelse[T](t: T, e: T) = e
}
def and(a: Bool, b: Bool): Bool = a.ifthenelse(b, a)

In Smalltalk and related languages, booleans are actually implemented like that (at least conceptually).

In the same way, we can encode Church numerals.

trait NumC {
  def fold[T](z: T, s: T => T): T
}
case object Zero extends NumC {
  def fold[T](z: T, s: T => T) = z
}

case class Succ(n: NumC) extends NumC {
  def fold[T](z: T, s: T => T) = s(n.fold(z, s))
}

def plus(a: NumC, b: NumC) = {
  new NumC {
    def fold[T](z: T, s: T => T): T = {
      a.fold(b.fold(z, s), s)
    }
  }
}
val oneC = Succ(Zero)
val twoC = Succ(oneC)
val threeC = Succ(twoC)

def testplusC = plus(twoC, threeC).fold[Unit]((), _ => print("."))

Object algebras are a different way to do Church encodings in object-oriented languages. This is what the encoding of Church numbers looks like in object-algebra style.

trait NumSig[T] {
  def z: T
  def s(p: T): T
}

trait Num {
  def apply[T](x: NumSig[T]): T
}

In other words, every constructor of the data type is turned into a function of the NumSig type, with recursive occurences being replaced by the type constructor T. Actual numbers have the type Num, i.e., they are basically functions of type NumSig[T] => T.

In the terminology of universal algebra, NumSig is an algebraic signature, and NumSig[T] => T is the type of algebras for that signature. Compared to the Church encoding above, we bundle the arguments of the fold-function into a trait NumSig and pass them as a single object. The advantage of this encoding is that we can extend the set of parameters of the fold by using inheritance.

In this encoding, the plus-function looks like this:

def plus(a: Num, b: Num) = new Num {
  def apply[T](x: NumSig[T]): T = a(new NumSig[T] {
    def z = b(x)
    def s(p: T) = x.s(p)
  })
}

Here is the representation of some numbers.

val zero: Num = new Num { def apply[T](x: NumSig[T]) = x.z }
val one: Num = new Num { def apply[T](x: NumSig[T]) = x.s(x.z) }
val two: Num = new Num { def apply[T](x: NumSig[T]) = x.s(one(x)) }
val three: Num = new Num { def apply[T](x: NumSig[T]) = x.s(two(x)) }

This is an interpretation of the Num-"language" as Scala integers:

object NumAlg extends NumSig[Int] {
  def z = 0
  def s(x: Int) = x + 1
}
val testplus = plus(two, three)(NumAlg)
// testplus: Int = 5

Let's look at a more useful application of object algebras. We encode expression trees as object algebras.

trait Exp[T] {
  implicit def id(name: String): T
  def fun(param: String, body: T): T
  def ap(funExpr: T, argExpr: T): T
  implicit def num(n: Int): T
  def add(e1: T, e2: T): T
  def wth(x: String, xdef: T, body: T): T = ap(fun(x, body), xdef)
}

The structure of expressions forces compositional interpretations. Hence we use the compositional interpretation using meta-level closures to represent closures.

sealed abstract class Value
type Env = Map[String, Value]
case class ClosureV(f: Value => Value) extends Value
case class NumV(n: Int) extends Value

An interpretation of expressions is now an implementation of the Exp interface

trait eval extends Exp[Env => Value] {
  def id(name: String) = env => env(name)
  def fun(param: String, body: Env => Value) = env => ClosureV(v => body(env + (param -> v)))
  def ap(funExpr: Env => Value, argExpr: Env => Value) = env => funExpr(env) match {
    case ClosureV(f) => f(argExpr(env))
    case _ => sys.error("can only apply functions")
  }
  def num(n: Int) = env => NumV(n)
  def add(e1: Env => Value, e2: Env => Value) = env => (e1(env), e2(env)) match {
    case (NumV(n1), NumV(n2)) => NumV(n1 + n2)
    case _ => sys.error("can only add numbers")
  }
}

object eval extends eval

An example program becomes a function that is parametric in the chosen interpretation:

def test[T](semantics: Exp[T]) = {
  import semantics._

  ap(ap(fun("x", fun("y", add("x", "y"))), 5), 3)
}

We evaluate the program by folding the eval-visitor over it.

val testres = test(eval)(Map.empty)
// testres: Value = NumV(n = 8)

The object algebra encoding of expressions is quite extensible. For instance, we can add another case to the expression data type by extending the interface.

trait ExpWithMult[T] extends Exp[T] {
  def mult(e1: T, e2: T): T
}

trait evalWithMult extends eval with ExpWithMult[Env => Value] {
  def mult(e1: Env => Value, e2: Env => Value) = env => (e1(env), e2(env)) match {
    case (NumV(n1), NumV(n2)) => NumV(n1 * n2)
    case _ => sys.error("can only multiply numbers")
  }
}
object evalWithMult extends evalWithMult

def testMult[T](semantics: ExpWithMult[T]) = {
  import semantics._

  ap(ap(fun("x", fun("y", mult("x", "y"))), 5), 3)
}
val testresMult = testMult(evalWithMult)(Map.empty)
// testresMult: Value = NumV(n = 15)

Note that there is no danger of confusing the language variants. For instance, an attempt to pass testMult to eval will be a static type error.

At the same time, we can add another function on expressions (such as a pretty-printer) without changing existing code, too: Just add a trait prettyPrint extends Exp[String] and, if pretty-printing of ExpWithMult is required, extend with trait prettyPrintMult extends prettyPrint with ExpWithMult[String]. This is the object algebra way of solving the expression problem.

We can also go one step further and combine object algebras with typed higher-order abstract syntax, using higher-kinded type members. Don't panic if you don't understand what is going on here.

trait ExpT {
  type Rep[_]
  def fun[S, T](f: Rep[S] => Rep[T]): Rep[S => T]
  def ap[S, T](funExpr: Rep[S => T], argExpr: Rep[S]): Rep[T]
  implicit def num(n: Int): Rep[Int]
  def add(e1: Rep[Int], e2: Rep[Int]): Rep[Int]
}

Instead of having the semantic domain as a type parameter T as above, we encode it as a higher-kinded abstract type member Rep[_]. This leads to significantly shorter type signatures and more fine-grained typing via Scala's support for "path-dependent types".

Note that, in contrast to eval, no dynamic checks (match ...) are needed in the interpreter. Also, the result type of the evaluator is just X. In contrast to Value, this is a "tagless" type, that is, it does not maintain information at runtime about which variant it is. This is because the ExpT datatype guarantees well-typedness of expressions. This interpreter will, in general, run much faster and can in fact be formulated in such a way that interpreting a program is just as fast as writing the program directly in the meta language.

object evalT extends ExpT {
  type Rep[X] = X
  def fun[S, T](f: S => T) = f
  def ap[S, T](f: S => T, a: S) = f(a)
  def num(n: Int) = n
  def add(e1: Int, e2: Int) = e1 + e2
}

object prettyprintT extends ExpT {
  var counter = 0
  type Rep[X] = String
  def fun[S, T](f: String => String) = {
    val varname = "x" + counter.toString
    counter += 1
    "(" + varname + " => " +  f(varname) + ")"
  }
  def ap[S, T](f: String, a: String) = f + "(" + a + ")"
  def num(n: Int) = n.toString
  def add(e1: String, e2: String) = "(" + e1 + "+" + e2 + ")"
}

def test2(semantics: ExpT) = {
  import semantics._
  ap(ap(fun((x: Rep[Int]) => fun((y: Rep[Int]) => add(x, y))), 5), 3)
}
val testres2 = test2(evalT)
// testres2: Int = 8
val testres3 = test2(prettyprintT)
// testres3: String = "(x0 => (x1 => (x0+x1)))(5)(3)"

An attempt to construct an ill-formed object program will be detected by the Scala type checker. For instance, the following program which adds a number and a function is rejected by the Scala type checker:

def testilltyped(semantics: ExpT) = {
  import semantics._
  add(5, fun((x: Rep[Int]) => x))
}
// error:
// Found:    semantics.Rep[Int => Int]
// Required: semantics.Rep[Int]
//   add(5, fun((x: Rep[Int]) => x))
//          ^^^^^^^^^^^^^^^^^^^^^^^

The type system encoded in the ExpT type is the so-called "simply-typed lambda calculus". Encoding more expressive type system in a similar style (including, e.g., type parameters) is an active area of research.

References: B. Olivira, W.Cook. Extensibility for the Masses: Practical Extensibility with Object Algebras. Proc. ECOOP 2012.