!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
三件事在这段里:
effect Ask { ... }声明效果与它的操作。操作是没有体的函数签名——体由 handler 给。sum_three直接调ask(),签名里写下!Ask。不写会报错,并告诉你两条出路。with handle Ask { ask() => 42 }装上 handler:这一句之后的整个块在它的作用域内。 臂ask() => 42就是一个闭包,调用ask()就是调它,返回值就是ask()的值。- 这个「块的剩余部分」自己也是闭包,所以它按值捕获:
with handle之前声明的var, 在它之后既不能读也不能赋值;之后声明的var没问题。诊断会说明这一点,并给两条 出路:在with handle之前先let一个快照,或者把值作为参数传进来。
多个操作,和有参数的操作#
一个效果可以有多个操作,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 分开:
- 臂是闭包,所以臂里不能写外层的
var(本节开头那条规则)。例外是它自己的格子:那是这次 安装自己的状态,不是从外面捕获进来的绑定。 - 格子出不去。臂里面手写的 lambda 不许捕获它,格子本身也没有可拼写的类型,所以没有第二 个名字够得着它。
每次安装各有各的格子。同一个效果再嵌套装一次,格子是另一套,内层攒的东西不会跑到外层去。
标准库的效果,以及一张表做的文件系统#
程序平常直接调用的那几块外部世界,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 的边界#
- 「操作不返回调用点」的用法通常走既有的失败机制:
Result+?、catch_fault/catch_panic/bracket。 - 效果不带类型参数(没有
effect Yield[T])。 - comptime / const 初始化不发具名效果,也装不了 handler。trait / impl 方法可以带标签,
任何写出来的函数类型(
alias目标、record 字段、参数位)也可以;impl 仍欠的是一条 标签与 trait 恰相等的行,因为每个标签都是这个方法的一格隐藏参数。 - 带标签的函数可以当函数值传:标签进到值的类型里,由调用点供 handler。只有操作本身
不行,它背后没有可取的函数符号;包一层 lambda(
() => ask())即可。 - 臂是闭包,所以臂里不能写外层的
var,也不能return/break跳出去。例外是它自己的 格子,也就是上面那一节。
完整规则见 spec.md §6.5,设计取舍见 effects-design.md。
另见:spec.md §6.5、§11