一句话说清核心思想
Zero 程序做 I/O,靠的是被授予权限,而不是去调用一个随处可用的全局对象。
这个权限就是一个 World 类型的值。运行时在调用 main 之前构造好它,你的程序再把它(或它的一部分)传到任何需要和外部世界交互的地方。
为什么不用全局变量?
大多数语言允许任何地方的任何函数写 stdout 或打开文件。JavaScript 有 console.log,Python 有 print,C 有 printf。这种便利是真实的,代价也是真实的:只看函数签名,你没法知道它会不会做 I/O。想知道答案,就得去读函数体,而且要一层层递归地读下去。
Zero 选择了另一种立场。它没有全局的 print,也没有随处可用的 os.Stdout。如果你的函数要做 I/O,这件事就必须体现在签名里,因为做 I/O 的唯一途径就是先拿到一个能力。
好处体现在三个方面:
- 读签名就知道函数能做什么。 一个签名里没有
World的函数,不能写 stdout,不能打开 socket,也不能读文件。类型系统把这一点变成了硬性保证。 - 测试纯代码非常简单。 纯函数不需要替身
print,也不需要模拟文件系统,因为它们从一开始就访问不到这些东西。 - 智能体可以只看局部就做出判断。 生成或修复 Zero 代码的 AI 智能体,不用读完整个代码库,就能知道眼前这个函数有没有副作用。
典型用法
你已经在 hello-world 里见过它的基本形式:
这里发生了三件事:
main把参数声明为world: World。运行时把一个World值交给程序,并绑定到这里。world.out是标准输出流,它作为World能力的一个字段暴露出来。world.out.write(...)写入一个字符串。它返回一个可能失败的值(写入可能出错),由check把错误向上传递。
你也可以给这个参数改名,比如 w: World 或 io: World,但 world 是约定俗成的写法,为了和整个 Zero 生态保持一致,最好沿用它。
World 上有什么
World 能力暴露了程序可能用到的各种接口。具体形态取决于运行时支持什么,但你可以预期会有这些:
world.out:标准输出。world.err:标准错误。world.in:标准输入。- 打开文件、读取环境变量和进行网络连接的途径。
字段的权威列表请参考当前的 Zero 标准库文档。有些接口(网络、文件系统)可能不在顶层,而是放在更窄的能力类型后面,再通过 World 访问。
在代码中传递能力
显式副作用的代价在于:任何需要做 I/O 的函数都必须接收这个能力。你不能隐式地拿到它。
fun log(world: World, message: String) -> Void raises {
check world.out.write(message)
}
pub fun main(world: World) -> Void raises {
log(world, "starting\n")
log(world, "done\n")
}
log 接收 world,这样才能通过它写入。如果 log 不接收 world,函数体里就没法调用 world.out.write,因为这个绑定根本不存在。
和支持全局 I/O 的语言相比,这样的参数传递更多。换来的好处是,main 的整个调用图只看签名就一目了然:
main接收world,所以它可能做 I/O。log接收world,所以它可能做 I/O。- 签名里没有
world的函数都做不了 I/O。
更窄的能力
把整个 World 传给每个函数是一种粗放的做法,就像随手发放 root 权限。Zero 提倡的模式是只接收你真正需要的那一部分 World:
fun log(out: Stream, message: String) -> Void raises {
check out.write(message)
}
pub fun main(world: World) -> Void raises {
log(world.out, "starting\n")
log(world.out, "done\n")
}
现在 log 只能拿到一个 Stream(也就是 world.out 暴露的那个类型)。它可以通过它写入,但不能打开文件,也不能读取网络。log 能做什么,由调用方决定。
你在真实 Zero 代码里看到的具体类型名(Stream、Writer、能力切片)会跟随你所用工具链版本的标准库命名。但“只传最少的,不传最多的”这个模式是通用的。
纯函数
不需要 World 的函数就不应该接收 World。这一半是风格上的偏好,一半由类型系统强制:没有能力,就没法做 I/O。
fun sum(point: Point) -> i32 {
return point.x + point.y
}
sum 相对于外部世界是纯的。调用方只看签名就能确定,这个函数不会打印任何东西,不会打开文件,也不会去请求服务器。静态分析器(或智能体)不用读函数体就能依赖这一性质。
能力与 raises
能力上的几乎每个操作都可能失败。world.out.write 可能因为流已关闭而失败,打开文件可能因为文件不存在而失败。能力的 API 与 raises 和 check 配合使用:可能失败的操作在签名里声明自己的失败方式,调用方用 check 来承接它们。
两者结合,正是 Zero 副作用模型的核心:
- 会发生什么:
raises { ... }。 - 通过什么发生:
World(或它的一部分)。 - 在哪里发生:在能力和
raises可见的任何地方。
有了这些信息,只看签名就能准确推断一个函数的副作用。
关于测试
基于能力的 I/O 天然让测试变得简单。想捕获被测函数的输出?传给它一个假的 out 能力,记录下它被要求写入的内容就行。想测试一个本该是纯函数的函数?什么能力都不传给它,它的类型签名不允许它接触外部世界。
标准库可以提供测试工具,专门用来构造假的或内存中的能力。具体 API 会随语言演进而变化,但核心原则不变:能力是可以替换的值。
下一篇:Raises 与 Check
World 只是副作用模型的一半,也就是函数能接触到哪些外部接口。另一半是失败:出错时,错误怎样传递?下一篇 Raises 与 Check 会讲到。
常见问题
Zero 中的 World 是什么?
World 是由运行时提供的能力对象,它让 Zero 程序可以访问外部世界:stdout、stdin、文件、网络、环境变量等。运行时会构造一个 World 值并传给 main。需要做 I/O 的函数必须接收这个 World(或它的一个更窄的部分),没有任何全局的后门可走。
为什么 main 要接收一个 World 参数?
Zero 没有随处可用的全局变量。它没有类似 printf、console.log 或 os.Stdout 这样任何函数都能直接调用、无需授权的东西。运行时把 World 能力交给 main,main(以及它调用的任何函数)只能通过这个值来做 I/O。这样一来,每个副作用都会体现在函数签名里。
基于能力的 I/O 和普通 I/O 有什么区别?
在大多数语言里,I/O 是隐式的:任何函数在任何时候都可以写 stdout 或读文件系统。基于能力的 I/O 把“做 I/O 的权限”变成了一个值:你必须先拿到 World(或它的一部分)才能使用它。纯计算函数不接收 World,所以根本做不了 I/O,这一点由类型系统强制保证。
能不能在调用栈深处隐式地拿到 World?
不能,这是有意的设计。如果调用栈深处的某个辅助函数需要写 stdout,你就必须显式地把 World(或一个更窄的能力)传给它。和支持全局 I/O 的语言相比,参数传递会多一些,但换来的是:只看函数签名就能知道它会不会做 I/O。
world.out.write 是做什么的?
world.out.write("text\n") 通过运行时提供的能力,把给定的字符串写入程序的标准输出流。它返回一个可能失败的值(写入可能出错),所以要用 check 包住这次调用,把错误沿调用栈向上传递。