Revisiting Tagless Final Interpreters

  • Post author:
  • Post category:其他




Revisiting Tagless Final Interpreters

Tageless Final interpreters are an alternative to the traditional Algebraic Data Type (and generalized ADT) based implementation of the interpreter pattern. This document presents the Tageless Final approach with Scala, and shows how Dotty with it’s recently added implicits functions makes the approach even more appealing. All examples are direct translations of their Haskell version presented in the

Typed Tagless Final Interpreters: Lecture Notes

(section 2).

The interpreter pattern has recently received a lot of attention in the Scala community. A lot of efforts have been invested in trying to address the biggest shortcomings of ADT/GADT based solutions: extensibility. One can first look at cats’


Inject

typeclass

for an implementation of


Data Type à la Carte


ideas. The

Freek

library provides facilities to combine more than two algebras using some pretty involved type-level machinery. The solution proposed by the

Freer Monads, More Extensible Effects

paper also focuses on extensibility, and inspired a bunch of work of small Scala libraries such as

eff

,

emm

and

paperdoll

. Tagless Final interpreters take a somewhat of a dual approach by having typeclasses at their very core instead of the more traditional ADT/GADT. They also come with the great advantage of being out of the box extensible without having any apparent pitfall.

The

lecture notes

goes into great details in presenting and comparing both ADT/GADT and typeclass based approaches, referencing the former as

initial

and the later as

final

. For the sake of conciseness, this document focuses mainly on

final

interpreters.



Introduction

We will be working with simple mathematic expressions similar to the ones manipulated on calculators. Our task will not only consist in computing these expressions, but also serialize, deserialize, and simplify them. As lazy engineers, it makes perfect sense to represent the domain using an (embedded) domain specific language. Among other things this saves us from caring about all the possible incorrect representations of our domain: the host language compiler takes care of that for us. Our domain consists in integer literals with addition and negation. The encoding that might have popped right up into your mind might look like the following:

sealed trait IExp
final case class Lit(i: Int) extends IExp
final case class Neg(e: IExp) extends IExp
final case class Add(r: IExp, l: IExp) extends IExp

A mathematical expression such as

8 - (1 + 2)

can be encoded as a value of type

IExp

:

val fe: IExp = Add(Lit(8), Neg(Add(Lit(1), Lit(2))))

Now everything looks easy right? Interpreting

fe

as integer uses recursive function of type

IExp => Int

, serializing is done with a

IExp => Json

function, deserialize goes the other way around with

Json => Option[IExp]

, and transformations are done

IExp => IExp

functions.

In the lecture notes’ terminology, the

IExp

data type corresponds to the

initial

encoding of our domain. You can forget about it for now, as we will instead be using the

final

encoding:

trait Exp[T] {
   
  def lit(i: Int): T
  def neg(t: T): T
  def add(l: T, r: T): T
}

How do we represent

8 - (1 + 2)

with

Exp

? Something like the following:

def tf0[T](implicit e: Exp[T]): T =
  e.add(e.lit(8), e.neg(e.add(e.lit(1), e.lit(2))))

In Haskell (with proper language extension)

tf0

could be a polymorphic value. In Scala we use a function with a type parameter

T

and an

implicit Exp[T]

constraint. The syntax can be simplified (for example) by getting rid of the

e.

using helper functions:

object ExpSyntax {
   
  def lit[T](i: Int)    (implicit e: Exp[T]): T = e.lit(i)
  def neg[T](t: T)      (implicit e: Exp[T]): T = e.neg(t)
  def add[T](l: T, r: T)(implicit e: Exp[T]): T = e.add(l, r)
}
import ExpSyntax._ // It's safe to always have these in scope

def tf1[T: Exp]: T =
  add(lit(8), neg(add(lit(1), lit(2))))

At this point you probably wonder,

how do we write interpreters for this

tf1

thing?

. The answer is simple, by creating instances of

Exp

!

implicit val evalExp: Exp[Int] = new Exp[Int] {
   
  def lit(i: Int): Int = i
  def neg(t: Int): Int = -t
  def add(l: Int, r: Int): Int = l + r
}
implicit val printExp: Exp[String] = new Exp[String] {
   
  def lit(i: Int): String = i.toString
  def neg(t: String): String = s"(-$t)"
  def add(l: String, r: String): String = s"($l + $r)"
}

Interpretation is done by specifying types. Let’s looks at

tf1

as an

Int

:

scala> tf1[Int]
res0: Int = 5

What about

tf1

as a

String

?

scala> tf1[String]
res1: String = (8 + (-(1 + 2)))



Extensibility

What if we decide to extend our mathematical expressions with multiplication? With the

initial

(ADT based)



版权声明:本文为weixin_37275456原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。