17. Effects of your own: effect and with handle

  • chapter 17 of 20
  • 9 min read

!io is the one effect the compiler knows about. You can declare your own: a set of operations whose implementation the caller picks at the point of use.

effect Ask {
  fn ask() -> Int
}

fn sum_three() -> Int !Ask = ask() + ask() + ask()

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 42 }
  println(to_string(sum_three()))
}
126

Three things are happening there:

More than one operation, and operations with parameters

An effect may have several operations, and a handler has to answer every one of them — no more and no fewer:

effect Log {
  fn note(msg: String) -> Unit
  fn level() -> Int
}

fn work(n: Int) -> Int !Log = {
  note("working on ${n}")
  n * level()
}

pub fn main() -> Unit !io = {
  with handle Log {
    note(m) => println("[log] ${m}")
    level() => 3
  }
  println(to_string(work(7)))
}
[log] working on 7
21

An arm's body may do anything, io included. That counts against the block that installed the handler — which is why main above is !io — and not against work, which emitted the operation: work owes only !Log.

Who answers: the nearest one lexically

A handler is found by where it is written, not by the runtime stack. An inner one shadows an outer one:

effect Ask {
  fn ask() -> Int
}

fn twice() -> Int !Ask = ask() + ask()

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 1 }
  println(to_string(twice()))
  with handle Ask { ask() => 10 }
  println(to_string(twice()))
}
2
20

An arm that emits its own effect again finds the outer handler — a handler does not answer itself — so with handle Ask { ask() => ask() * 10 } means "take the answer from outside and multiply it by ten", not an infinite loop.

A closure does not keep the handler it was written under. Carry it out of the block and it carries the label with it: its row still says !Ask, and the handler that answers is the one in scope where the closure is finally called. The type says who has to supply a handler, not what any particular arm would have done, so a handler with pure arms leaves you an !Ask closure just as an io one does.

A function type may therefore name an effect, and it means exactly what it reads as. fn(f: fn() -> Int !Ask) says "whoever calls f supplies the Ask handler", which is how the call really works. fn(f: fn() -> Int !e) is the other spelling rather than a workaround: an effect variable takes a closure with any row at all and forwards that row into your own.

Higher-order functions need no change

An effect variable (!e) forwards a named effect along with everything else, so map, fold and for loops carry on as before:

effect Ask {
  fn ask() -> Int
}

fn shifted(xs: List[Int]) -> List[Int] !Ask = map(xs, x => x + ask())

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 100 }
  println(to_string(shifted([1, 2, 3])))
}
[101, 102, 103]

Handler state: a var in the arm table

An arm table may open with var declarations. Each one is a cell: mutable state that belongs to this one installation of the handler. The arms read and write it, and the block after the with handle reads it afterwards, which is how a handler that accumulates something hands what it accumulated back to the code that installed it.

effect Spend {
  fn spend(item: String, n: Int) -> Bool
}

## No budget parameter, no running total: `shop` only knows that it asks.
fn shop(orders: List[(String, Int)]) -> List[String] !Spend = {
  var bought: List[String] = []
  for o in orders {
    let (item, cost) = o
    if spend(item, cost) {
      bought = bought ++ [item]
    }
  }
  bought
}

pub fn main() -> Unit !io = {
  with handle Spend {
    var left: Int = 10
    var skipped: List[String] = []
    spend(item, n) =>
      if n <= left {
        left = left - n
        true
      } else {
        skipped = skipped ++ [item]
        false
      }
  }
  let bought = shop([("bread", 3), ("cheese", 12), ("apples", 4)])
  println("${bought} left=${left} skipped=${skipped}")
}
["bread", "apples"] left=3 skipped=["cheese"]

shop has no budget parameter, no accumulator and no return value carrying one. The budget is the handler's, the two cells are where it is kept, and the last line of main is the block after the with handle reading them. There is no return arm; the cells are the only surface a stateful handler hands anything back through.

Cells must be declared before the arms, and their type annotation is not optional: inside the arm table there is no context to infer it from.

Two rules keep a cell apart from an ordinary var:

Each installation gets its own cells. A nested with handle for the same effect keeps a separate set, and what the inner one accumulates does not reach the outer one.

The standard library's effects, and a file system that is a table

std/io declares effects of the kind this chapter has been writing for the parts of the outside world a program usually just calls: Fs (files), Env (the working directory and environment variables), Proc (running another program), Exit (ending the process) and Console (writing to stdout and stderr). io.read_file and the rest of the file functions are !Fs, io.getenv and io.cwd are !Env, io.run is !Proc. So a signature says which part of the world a function touches, and a test can answer that effect with a fake.

std ships one fake ready-made: std/memfs answers Fs from a table in memory. memfs.with_fs(tree, body) runs body against tree and hands back the result together with the tree as body left it. Env has two operations, and a fake for it is a with handle of two lines:

use std/io
use std/io.{Fs, Env}
use std/memfs
use std/str

# The signature says it all: this touches files and nothing else.
fn archive(path: String) -> Result[Int, ForeignError] !Fs = {
  let text = io.read_file(path)?
  io.write_file(path ++ ".bak", text)?
  Ok(len(str.split(text, "\n")))
}

# And this reads the environment, and nothing else.
fn greeting() -> String !Env =
  match io.getenv("USER") {
    Some(name) -> "hello, ${name}"
    None -> "hello, stranger"
  }

pub fn main() -> Unit !io = {
  let tree = memfs.put(memfs.empty(memfs.BASE), "notes.txt", "one\ntwo\nthree")
  let (lines, after) = memfs.with_fs(tree, () => archive("notes.txt"))
  println("${lines}")
  println("${memfs.file_paths(after)}")
  println("${memfs.text(after, "notes.txt.bak")}")

  with handle Env {
    env_cwd() => "/home/ada"
    env_get(name) => if name == "USER" { Some("ada") } else { None }
  }
  println(greeting())
}
Ok(3)
["/dawn-memfs/notes.txt", "/dawn-memfs/notes.txt.bak"]
Some("one\ntwo\nthree")
hello, ada

archive read one file and wrote another, and nothing touched a disk. memfs.with_fs is pure, so the same lines work in a test block with no temporary directory to make or clean up. A program meets the real world by installing the production handlers once, at the top: io.with_fs_real(() => ...), and likewise with_env_real, with_proc_real, with_exit_real and with_console_real, each of them !io. (println itself is still plain !io; Console is there for code whose output a test wants to read back.)

Control arms: ctl, resume k and discard

Every arm so far answers its operation and lets the caller carry on. An effect declared ctl may also have control arms, which take hold of the rest of the computation instead: op(args) resume k => ... binds k, a function that resumes the code after the operation with the value you pass it. The arm's value is the value of the whole with handle block, so an arm that does not call k ends the block early, with an answer of its own.

ctl effect Check {
  fn check(ok: Bool, why: String) -> Unit
}

fn validate(age: Int) -> String !Check = {
  check(age >= 0, "negative")
  check(age < 150, "too large")
  "age ${age}"
}

fn close(name: String) -> Unit !io = println("closed ${name}")

fn admit(age: Int) -> String !io = {
  with handle Check {
    check(ok, why) resume k =>
      if ok {
        k(())
      } else {
        discard(k)
        "rejected: ${why}"
      }
  }
  with log <- bracket("log", close)
  "${log}: ${validate(age)}"
}

pub fn main() -> Unit !io = {
  println(admit(30))
  println(admit(-1))
  println(admit(200))
}
closed log
log: age 30
closed log
rejected: negative
closed log
rejected: too large

For admit(-1), validate asks check(false, "negative"), and the arm does not resume: the second check and the "age ..." line never run, and "rejected: negative" is the answer of admit's whole block. For admit(30) the arm calls k(()) at each check, and what the rest of the block finally produces comes back out of k as the arm's value.

A continuation is one-shot: resuming it twice is a panic. One you will never resume is abandoned with discard(k), and that is what runs the cleanups between the operation and the handler; here it is the bracket that prints closed log, which ran on all three paths. Just dropping k runs nothing, on purpose: a cleanup that ran whenever a collector got round to it would behave differently on the two backends. k is an ordinary function value, so it may also be stored and resumed later. std/io's Exit is a ctl effect for the reason this section starts from: a test answers io.exit(1) with an arm that does not resume and hands the status back as a value, where the production arm ends the process.

The v1 boundary

The full rules are in spec.en.md §6.5 and the design trade-offs in effects-design.md.

See also: spec.en.md §6.5, §11