Effects and capabilities
On this page
In most languages any function can open a file, read the clock, or call the network, and you find out by reading its body. In Xo the signature says it. Two ideas work together:
- an effect is a name in the
usesclause (fs.read,clock,stdio); - a capability is a value (
Fs,Clock,Stdio) that grants the permission.mainreceives them all inos: Os; everything else must be passed one.
Pure, and what it costs to stop being pure
caps-basics/main.xo
/// Pure: no `uses` clause, so it cannot touch the outside world.
pub fn parse_pair(line: Str) -> (Str, Str)? {
let (k, v) = line.cut("=")?
(k.trim(), v.trim())
}
/// Reads one file: the signature says so, and the caller must hand over an `Fs`.
pub fn count_keys(fs: Fs, path: Str) -> Int ! FsErr uses fs.read {
let text = fs.read_text(path)?
var n = 0
for line in text.lines() {
if parse_pair(line) is Some(_) {
n += 1
}
}
n
}
/// Reads the clock and prints: both effects are in the signature.
pub fn stamp(clock: Clock, out: Stdio, msg: Str) uses (clock, stdio) {
out.println("${clock.now().unix()}: ${msg}")
}
fn main(os: Os) {
os.stdio.println("pure: ${parse_pair("port = 8080"):?}")
match count_keys(os.fs, "no-such-file.conf") {
Ok(n) => os.stdio.println("keys: ${n}")
Err(e) => os.stdio.println("could not read: ${e.display()}")
}
}
test "count_keys reads the fake file system" {
os.fs.write("app.conf", b"a = 1\nb = 2\nnot a pair\n")?
expect(count_keys(os.fs, "app.conf")?) == 2
}
test "stamp reads the virtual clock" {
stamp(os.clock, os.stdio, "hello")
expect(os.stdio.output()) == "1767225600: hello\n"
}Output:
pure: Some(("port", "8080"))
could not read: no-such-file.conf: no such file or directory
parse_pairhas nousesclause, so it cannot reach the outside world. Calling something that does is a compile error.count_keystakes anFsand declaresuses fs.read. The effect is the promise, theFsargument is the means.stampdeclares two effects,clockandstdio, in oneuses (...)list.- The tests run against a fake
os: an in-memory file system, a virtual clock fixed at 1767225600, and captured output. No test touches the disk.
Forgetting the declaration is caught at compile time, with the effect named:
pub fn greet(out: Stdio, name: Str) {
out.println("hello, ${name}")
}
fn main(os: Os) { greet(os.stdio, "Ada") }xo check says:
main.xo:1:8: error XO0701: `greet` is pub with no `uses` clause, so it is pure, but performs `stdio`
note: declare them: `uses stdio` (`xo fix` writes it)
note: main.xo:2:3: `Stdio.println` uses `stdio` here
fix: add the clause `uses stdio`
1 module(s), 1 error(s), 0 warning(s)
Handing out less
A capability can be narrowed before it is passed on. A function that only needs to read gets a read-only view; one that writes under a directory gets a sub-directory.
caps-narrow/main.xo
/// A report writer should only be able to write under one directory.
pub fn save_report(out: Fs, name: Str, body: Str) -> () ! FsErr uses fs.write {
out.write("${name}.txt", body.bytes())?
}
/// A previewer should only read.
pub fn preview(src: Fs, name: Str) -> Str ! FsErr uses fs.read {
src.read_text("${name}.txt")?
}
fn main(os: Os) {
let reports = os.fs.sub("/tmp").read_only()
match save_report(reports, "xo-book-demo", "hello") {
Ok(_) => os.stdio.println("saved")
Err(e) => os.stdio.println("refused: ${e.display()}")
}
}
test "a read only capability refuses a write" {
let reports = os.fs.sub("/reports").read_only()
match save_report(reports, "q1", "numbers") {
Ok(_) => fault("the write should have been denied")
Err(e) => expect(e.display()) == "q1.txt: permission denied"
}
}
test "the same function writes through a writable one" {
os.fs.create_dir_all("/reports")?
let reports = os.fs.sub("/reports")
save_report(reports, "q1", "numbers")?
expect(preview(reports.read_only(), "q1")?) == "numbers"
}
test "paths cannot leave the sub capability" {
let reports = os.fs.sub("/reports")
match reports.read_text("../etc/passwd") {
Ok(_) => fault("escaped")
Err(Denied(_)) => ()
Err(e) => fault("unexpected: ${e.display()}")
}
}Output:
refused: xo-book-demo.txt: permission denied
os.fs.sub("/tmp")is anFsrooted at/tmp;.read_only()removes the write effects from what it will grant.save_reportcannot tell that itsFsis narrowed. It still returns an error value,permission denied, instead of failing in some other way.- A path that climbs out with
..is refused too (the third test).
Because the narrowing is a value, you can audit a program by reading main:
whatever it never hands to a function, that function cannot use.
Effects that pass through
A higher order function should not guess what its argument does. An effect
variable stands for “whatever f does”:
caps-fx/main.xo
/// Runs `f` twice. The effect variable `fx` is whatever `f` does, so `twice` does the same.
pub fn twice[fx: Effects](f: fn() -> () uses fx) -> () uses fx {
f()
f()
}
fn shout(out: Stdio, word: Str) uses stdio {
out.println("${word.to_upper()}!")
}
fn main(os: Os) {
// `twice` of a pure closure is pure; `twice` of a printing closure prints
twice(fn() { let _ = "x".to_upper() })
twice(fn() { shout(os.stdio, "hi") })
}
test "twice passes the effects through" {
twice(fn() { shout(os.stdio, "go") })
expect(os.stdio.output()) == "GO!\nGO!\n"
}Output:
HI!
HI!
twice[fx: Effects] is pure when given a pure closure and prints when given a
printing one, so one definition serves both.
What to take away
- Read a signature’s
usesclause to know what a function can reach. - Pass the narrowest capability that does the job.
- Test with the fake
os: the fakes are the same types as the real ones.
Next: Error handling.
↑ index