Menu

Zero 的 World 能力:不靠全局变量的显式 I/O

Zero 没有全局 stdout,没有随处可用的文件系统,也没有隐式的网络访问。所有与外部世界打交道的操作,都要通过传给 main 的 World 能力完成。本文讲清楚为什么这样设计,以及具体怎么用。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

一句话说清核心思想

Zero 程序做 I/O,靠的是被授予权限,而不是去调用一个随处可用的全局对象。

这个权限就是一个 World 类型的值。运行时在调用 main 之前构造好它,你的程序再把它(或它的一部分)传到任何需要和外部世界交互的地方。

为什么不用全局变量?

大多数语言允许任何地方的任何函数写 stdout 或打开文件。JavaScript 有 console.log,Python 有 print,C 有 printf。这种便利是真实的,代价也是真实的:只看函数签名,你没法知道它会不会做 I/O。想知道答案,就得去读函数体,而且要一层层递归地读下去。

Zero 选择了另一种立场。它没有全局的 print,也没有随处可用的 os.Stdout。如果你的函数要做 I/O,这件事就必须体现在签名里,因为做 I/O 的唯一途径就是先拿到一个能力。

好处体现在三个方面:

  1. 读签名就知道函数能做什么。 一个签名里没有 World 的函数,不能写 stdout,不能打开 socket,也不能读文件。类型系统把这一点变成了硬性保证。
  2. 测试纯代码非常简单。 纯函数不需要替身 print,也不需要模拟文件系统,因为它们从一开始就访问不到这些东西。
  3. 智能体可以只看局部就做出判断。 生成或修复 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 包住这次调用,把错误沿调用栈向上传递。

Coddy 编程语言插图

和 Coddy 一起学编程

开始