一文で言うアイデア
Zero のプログラムは、アンビエントなグローバルに手を伸ばすのではなく、許可を渡されることによって I/O を行います。
その許可が World 型の値です。ランタイムが main を呼ぶ前にひとつ組み立て、プログラムはそれ(あるいはその一部)を、外の世界とやり取りが必要な場所へ流していきます。
なぜグローバルがないのか
ほとんどの言語では、どの関数からでもどこでも標準出力に書いたりファイルを開いたりできます。JavaScript には console.log、Python には print、C には printf。便利さは本物ですが、コストもあります。関数のシグネチャからは、その関数が I/O を行う可能性があるかどうかを判断できません。知るには本文を再帰的に読むしかありません。
Zero は別の立場を取ります。グローバルな print も、アンビエントな os.Stdout もありません。関数が I/O を行うなら、その事実はシグネチャに現れなければなりません。なぜなら、I/O をするための唯一の方法はケイパビリティを渡されることだからです。
メリットは 3 つの場面で現れます。
- シグネチャを読めば、関数に何が できるか がわかる。
Worldを言及しない関数は、標準出力に書けず、ソケットを開けず、ファイルを読めません。型システムがそれを保証します。 - 純粋なコードのテストが簡単。 純粋関数はスタブの
printやモックのファイルシステムを必要としません——そもそもアクセス権を持っていないからです。 - エージェントがローカルに推論できる。 Zero コードを生成・修復する AI エージェントは、コードベース全体を読まずに、見ている関数にエフェクトがあるかどうかを判断できます。
定番の使い方
hello-world ですでに基本形は見ました。
3 つのことが起きています。
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 全体をすべての関数に渡すのは大ざっぱな手口です——ルート権限を配るようなものですから。Zero が推奨するパターンは、本当に必要なスライスだけを取ることです。
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 は外の世界に対して純粋です。シグネチャを見た呼び出し側は、この関数が何かを表示しない、ファイルを開かない、サーバーに ping を打たないと確信できます。これは静的解析器(やエージェント)が本文を読まずに頼れる性質です。
ケイパビリティと 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 プログラムが外の世界(標準出力、標準入力、ファイル、ネットワーク、環境変数など)にアクセスするための、ランタイムから渡されるケイパビリティオブジェクトです。ランタイムが World 値を組み立てて main に渡します。I/O が必要な関数は World(あるいはその狭いスライス)を受け取らなければなりません——グローバルな抜け道はありません。
なぜ main は World パラメータを取るのですか?
Zero にはアンビエントなグローバルがありません。許可なくどこからでも呼べる printf、console.log、os.Stdout のような相当物が存在しないのです。ランタイムは main に World ケイパビリティを渡し、main(およびそれが呼ぶ関数)はその値経由でしか I/O を行えません。これにより、すべてのエフェクトが関数のシグネチャに可視化されます。
ケイパビリティベースの I/O は普通の I/O とどう違いますか?
ほとんどの言語では I/O は暗黙で、どの関数からでもいつでも標準出力に書き込んだりファイルを読んだりできます。ケイパビリティベースの I/O は「I/O を行う権限」を値にします——使うには World(あるいはその一部)を渡されなければなりません。純粋な計算関数は World を取らないので、文字通り I/O ができません。それを型システムが強制します。
コールスタックの深い場所で暗黙に World を取得できますか?
いいえ、設計上できません。コールスタックの奥にあるヘルパーが標準出力に書きたい場合は、World(あるいはより狭いケイパビリティ)を明示的に渡す必要があります。アンビエントな I/O を持つ言語よりもパラメータの引き回しは多くなりますが、それは関数のシグネチャを読むだけで I/O の可能性を判断できることの対価です。
world.out.write は何をしますか?
world.out.write("text\n") は、ランタイムが提供したケイパビリティを通じて、与えられた文字列をプログラムの標準出力ストリームに書き込みます。失敗しうる値を返します——書き込みは失敗する可能性があるので——、呼び出しは check で包んでエラーをコールスタックの上に伝播させます。