17. 自己的效果:effect 与 with handle

  • 第十七章,共二十章
  • 阅读约 8 分钟

!io 是编译器内建的那一种效果。你也可以声明自己的:一组操作,谁来实现由调用方 在使用点决定。

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

三件事在这段里:

多个操作,和有参数的操作

一个效果可以有多个操作,handler 必须每个都答,一个不多一个不少:

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

臂的身体可以做任何事——包括 io。它算在装 handler 的那个块头上(上面的 main 因此 是 !io),不算在发出操作的 work 头上:work 只欠 !Log。

谁来应答:词法上最近的那个

handler 是按写在哪里找的,不是按运行时的栈找的。内层遮蔽外层:

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

臂里再发本效果,找的是外层的 handler(handler 不答自己)——所以 with handle Ask { ask() => ask() * 10 } 是「把外面那个答案乘十」,不是死循环。

闭包不会留住它写在谁下面的那个 handler。带它跑出块外,标签也跟着跑:它的行里仍写着 !Ask,应答的是最终调用它的地方在场的那个 handler。类型说的是谁得供 handler, 不是某个臂会做什么,所以纯臂的 handler 交给你的同样是一个 !Ask 闭包,io 臂的也是。

于是函数类型可以写具名效果,而且字面读法就是它的意思。fn(f: fn() -> Int !Ask) 说的是 「谁调用 f,谁供 Ask 的 handler」,调用真就是这么走的。fn(f: fn() -> Int !e) 是另一种写法,不是迁移写法:效果变量收任何行的闭包,并把那条行转发进你自己的行。

高阶函数不用改一行

效果变量(!e)会连同具名效果一起转发,所以 map、fold、for 循环都照旧:

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 的状态:臂表里的 var

臂表的开头可以声明若干 var。每一个都是一格状态格子:属于这一次 handler 安装的可变 状态。臂读它写它,with handle 之后的块剩余再读它,这就是一个攒东西的 handler 把攒下来的 东西交回给安装点的方式。

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

## 没有预算参数,也没有累加量:`shop` 只知道自己在问。
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 没有预算参数、没有累加器,返回值里也不带一个。预算是 handler 的,两格格子是它存 预算的地方,main 的最后一行就是「with handle 之后的块剩余」在读它们。没有 return 臂, 格子是带状态的 handler 唯一的交回表面。

格子必须全部排在臂之前,而且类型标注不可省:臂表的花括号里没有可推的上下文。

两条规则把格子和普通 var 分开:

每次安装各有各的格子。同一个效果再嵌套装一次,格子是另一套,内层攒的东西不会跑到外层去。

标准库的效果,以及一张表做的文件系统

程序平常直接调用的那几块外部世界,std/io 用本章一直在写的那种效果声明出来:Fs (文件)、Env(工作目录与环境变量)、Proc(运行另一个程序)、Exit(结束进程)和 Console(写 stdout 与 stderr)。io.read_file 等文件函数是 !Fs,io.getenv 和 io.cwd 是 !Env,io.run 是 !Proc。于是签名说出了函数碰的是世界的哪一块,测试也 可以用一个假的 handler 来应答那个效果。

std 自带一个现成的假实现:std/memfs 用内存里的一张表应答 Fs。 memfs.with_fs(tree, body) 在 tree 上跑 body,把结果连同 body 跑完后的那棵树一起 交回来。Env 只有两个操作,给它写个假的就是两行 with handle:

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

# 签名把话说完了:它碰文件,别的什么都不碰。
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")))
}

# 这个读环境,别的什么都不碰。
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 读了一个文件、写了另一个,自始至终没碰磁盘。memfs.with_fs 是纯的,所以同样 几行放进 test 块也能用,不用建临时目录,也不用收拾。程序要接触真实世界,就在最外层 装一次生产 handler:io.with_fs_real(() => ...),以及同样形状的 with_env_real、 with_proc_real、with_exit_real 和 with_console_real,它们都是 !io。(println 本身目前仍是普通的 !io;Console 是给那些想让测试读回输出的代码准备的。)

控制臂:ctl、resume k 与 discard

到目前为止,每条臂都是应答操作,然后让调用方接着往下走。声明为 ctl 的效果还可以带 控制臂,它拿到的是剩下的那段计算:op(args) resume k => ... 绑定 k,一个函数, 用你传给它的值从操作之后接着往下跑。臂的值就是整个 with handle 块的值,所以不调用 k 的臂会提前结束这个块,答案由它自己给。

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

admit(-1) 里,validate 发出 check(false, "negative"),臂不恢复:第二个 check 和 "age ..." 那一行都不会跑,"rejected: negative" 就是 admit 整个块的答案。 admit(30) 里臂在每次 check 都调用 k(()),块的其余部分最后算出的值从 k 里返回, 成为臂的值。

续延只能用一次:恢复两次是 panic。确定不再恢复的续延用 discard(k) 放弃,操作与 handler 之间的清理靠它来跑;这里就是打印 closed log 的那个 bracket,三条路径上它都跑了。 只是把 k 扔掉则什么都不跑,这是有意的:要是清理在回收器哪天轮到它时才跑,两个后端上的 行为就会不一样。k 是普通函数值,也可以存起来以后再恢复。std/io 的 Exit 是 ctl 效果,理由正是本节开头那一条:测试用一条不恢复的臂应答 io.exit(1),把退出码当作值交回来, 而生产环境的臂结束进程。

v1 的边界

完整规则见 spec.md §6.5,设计取舍见 effects-design.md。

另见:spec.md §6.5、§11