Skip to content

Commit

Permalink
Start describing effects in interpreters
Browse files Browse the repository at this point in the history
  • Loading branch information
noelwelsh committed Nov 1, 2023
1 parent 7ab4628 commit 1b9582f
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 0 deletions.
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ lazy val pages = List(
"adt-interpreters/regexp.md",
"adt-interpreters/reification.md",
"adt-interpreters/tail-recursion.md",
"adt-interpreters/effects.md",
"type-classes/index.md",
"type-classes/anatomy.md",
"type-classes/implicits.md",
Expand Down
45 changes: 45 additions & 0 deletions src/pages/adt-interpreters/Output.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
enum Output[A] {
case Print(value: String) extends Output[Unit]
case Newline() extends Output[Unit]
case FlatMap[A, B](source: Output[A], f: A => Output[B]) extends Output[B]
case Value(a: A)

def andThen[B](that: Output[B]): Output[B] =
this.flatMap(_ => that)

def flatMap[B](f: A => Output[B]): Output[B] =
FlatMap(this, f)

def run(): A =
this match {
case Print(value) => print(value)
case Newline() => println()
case FlatMap(source, f) => f(source.run()).run()
case Value(a) => a
}
}
object Output {
def print(value: String): Output[Unit] =
Print(value)

def newline: Output[Unit] =
Newline()

def println(value: String): Output[Unit] =
print(value).andThen(newline)

def value[A](a: A): Output[A] =
Value(a)
}

val hello = Output.println("Hello")
val helloHello = hello.andThen(" ,").andThen(hello).andThen("?")
// hello.andThen(hello).run()

def oddOrEven(phrase: String): Output[Unit] =
Output
.value(phrase % 2 == 0)
.flatMap(even =>
if even then Output.print(s"$phrase has an even number of letters.")
else Output.print(s"$phrase has an odd number of letters.")
)
138 changes: 138 additions & 0 deletions src/pages/adt-interpreters/effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
## Effectful Interpreters

Let's now turn to effects in interpreters. Sometimes this is an optimisation, and sometimes this is the entire point of using the interpreter strategy. We will look at both, starting with the second.

Remember that the interpreter carries out the actions described in the program or description. These programs can describe effects, like writing to a file or opening a network connection. The interpreter will then carry out these effects. We can still reason about our programs in a simple way using substitution. When we run our programs the effects will happen and we can no longer reason so easily. The goal with the interpreter strategy is to compose the entire program we want to run and then call the interpreter once, so that effects only happen once.

This will become clearer with an example. Possibly the simplest effect is printing to the standard output, as we can do with `println`. A really simple program describing printing output might have:

- printing a `String` to the standard output; and
- printing a newline to the standard output.

This immediately suggests a description

```scala mdoc:silent
trait Output
object Output {
def print(value: String): Output = ???
def newline: Output = ???
}
```

We're lacking any forms of composition.
We might want to print some output and then print some more output.
This suggests a method

```scala mdoc:reset:silent
trait Output {
def andThen(that: Output): Output
}
```

That's a reasonable start, but we can make our programs not much more complex but a lot more interesting by allowing our programs to carry along some value of a generic type, and adding `flatMap` as an operation.

Here's our basic API.

```scala mdoc:reset:silent
trait Output[A] {
def flatMap[B](f: A => Output[B]): Output[B]
}
object Output {
def print(value: String): Output[Unit] = ???
def newline: Output[Unit] = ???
def value[A](a: A): Output[A] = ???
}
```

The `value` constructor creates an `Output` that simply returns the given value.

Now we can reify.

```scala mdoc:reset:silent
enum Output[A] {
case Print(value: String) extends Output[Unit]
case Newline() extends Output[Unit]
case FlatMap[A, B](source: Output[A], f: A => Output[B]) extends Output[B]
case Value(a: A)

def andThen[B](that: Output[B]): Output[B] =
this.flatMap(_ => that)

def flatMap[B](f: A => Output[B]): Output[B] =
FlatMap(this, f)
}
object Output {
def print(value: String): Output[Unit] =
Print(value)

def newline: Output[Unit] =
Newline()

def println(value: String): Output[Unit] =
print(value).andThen(newline)

def value[A](a: A): Output[A] =
Value(a)
}
```

I have added a few conveniences which are defined in terms of the essential operations in our API.

Finally, let's add an interpreter. I recommend you try implementing this yourself before reading on.

I called the interpreter `run`. Here's the implementation.

```scala
def run(): A =
this match {
case Print(value) => print(value)
case Newline() => println()
case FlatMap(source, f) => f(source.run()).run()
case Value(a) => a
}
```

The first point is that the interpreter actually carries out effects, in this case printing to standard output. The second point is that we can compose descriptions using `flatMap`, or `andThen` which is derived from `flatMap`. Here's an example. First we define a program that, when run, prints `"Hello"`.

```scala mdoc:silent
val hello = Output.print("Hello")
```

Now we can compose this program to create a program that prints `"Hello, Hello?"`.

```scala mdoc:silent
val helloHello = hello.andThen(" ,").andThen(hello).andThen("?")
```

Notice that we reused the the value `hello`, showing composition of programs.
This only works because `hello` is a description of an effect, not the effect itself.
If we tried to compose the effect, as in the following

```scala
val hello = print("Hello")
val helloHello = {
hello
print(" ,")
hello
print("?")
}
```

we don't get the output we expect.

We can also mix in pure computation that has no effects.

```scala mdoc:silent
def oddOrEven(phrase: String): Output[Unit] =
Output
.value(phrase.size % 2 == 0)
.flatMap(even =>
if even then Output.print(s"$phrase has an even number of letters.")
else Output.print(s"$phrase has an odd number of letters.")
)
```

These examples show how we can compose programs that describe effects, and still reason about them using the familiar tool of substitution.


Now let's turn to using effects within an interpreter as an optimisation.

0 comments on commit 1b9582f

Please sign in to comment.