Effects

Every function in a Nexium program carries a set of effects: the things it might do besides compute its result. The compiler infers them, you can see them, and you can forbid them on a signature. When a promise is broken, the diagnostic walks the call chain to the line responsible. No other part of the language does more to make a program's behaviour visible, and no other part costs less: an effect is a fact about the code, not a check at run time.

topo/code/effects.nx
/// No effects at all: pure arithmetic over a slice. `!allocates !panics` is a
/// promise the compiler checks, not a comment.
fn mean(xs: []f64) -> f64 !allocates !panics {
    if xs.len == 0 { return 0.0 }
    var total = 0.0
    for x in xs { total += x }
    return total / xs.len as f64
}

/// Indexing with `i` from `0..xs.len` is proven in bounds, so this cannot panic either.
fn dot(a: []f64, b: []f64) -> f64 !allocates {
    var sum = 0.0
    for x, i in a { if i < b.len { sum += x * b[i] } }
    return sum
}

/// This one allocates (it builds a String) and the compiler knows.
fn describe(xs: []f64) -> String {
    return format("{} values, mean {:.2}", .{xs.len, mean(xs)})
}

/// A function type may carry bounds too: only functions that satisfy them fit.
fn apply(xs: []mut f64, f: fn(f64) -> f64 !allocates !panics) {
    for i in 0..xs.len { xs[i] = f(xs[i]) }
}

fn halve(x: f64) -> f64 { return x / 2.0 }

/// Checked arithmetic can panic; the wrapping form cannot.
fn hash(data: []u8) -> u32 !panics {
    var h: u32 = 2166136261
    for b in data {
        h ^= b as u32
        h *%= 16777619
    }
    return h
}

fn main() {
    var xs = [2.0, 4.0, 9.0]
    println("{}", .{describe(xs[..])})
    println("{}", .{dot(xs[..], xs[..])})
    apply(xs[..], halve)
    println("{} {} {}", .{xs[0], xs[1], xs[2]})
    println("{x}", .{hash("nexium")})
}
topo/code/effects.expected
3 values, mean 5.00
101.0
1.0 2.0 4.5
9d0fa4a9

The eight effects#

effectthe function may...
allocatestake memory from the heap: build a String, grow a List
refcountsretain or release a ref class reference
blockswait: I/O, sockets, sleeping, joining a thread, locking
shared_mutableread or write a mutable global, or lock
nondeterministicdepend on the clock, randomness, the environment, a thread's timing
panicsstop the program: an index that may be out of bounds, checked arithmetic that may overflow
fficall foreign code through @cImport
unbounded_stackrecurse without a bound the compiler can see, or call through a function value

nx effects file.nx prints the inferred set for every function:

$ nx effects topo/code/effects.nx
inferred effects (7 functions)

fn mean(xs: []f64) -> f64  (pure)
    at topo/code/effects.nx:3:1
fn dot(a: []f64, b: []f64) -> f64  (pure)
    at topo/code/effects.nx:11:1
fn describe(xs: []f64) -> String  allocates
    at topo/code/effects.nx:18:1
fn apply(xs: []mut f64, f: fn(f64) -> f64 !allocates !panics) -> void  refcounts blocks shared_mutable nondeterministic ffi unbounded_stack
    at topo/code/effects.nx:23:1
fn halve(x: f64) -> f64  (pure)
    at topo/code/effects.nx:27:1
fn hash(data: []u8) -> u32  (pure)
    at topo/code/effects.nx:30:1
fn main() -> void  allocates refcounts blocks shared_mutable nondeterministic ffi unbounded_stack
    at topo/code/effects.nx:39:1

A function's effects are its own, plus the effects of everything it calls, computed as a fixpoint over the whole program. describe allocates because format does. main has everything but panics: describe brings allocates, and apply the rest, because it calls through a function value: the value's type promises !allocates !panics and nothing else, so the call is assumed to do anything else it could. Nothing in the program can panic (the section on proofs below says why), so neither can main. A call into foreign code is assumed to do everything.

Promises#

fn mean(xs: []f64) -> f64 !allocates !panics {

A negative bound on a signature is checked. Break it and the compiler says exactly how:

topo/code/effects_fails.nx
fn label(n: i32) -> String {
    return format("#{}", .{n})
}

/// The promise cannot be kept: `label` allocates, and the compiler says where.
fn tag(n: i32) -> String !allocates {
    return label(n)
}

fn main() { println("{}", .{tag(1)}) }
topo/code/effects_fails.expected
error: function `tag` is declared `!allocates` but has the `allocates` effect
  --> topo/code/effects_fails.nx:6:1
   |
  6| fn tag(n: i32) -> String !allocates {
   | ^
  note: this call acquires `allocates`
  --> topo/code/effects_fails.nx:7:12
   |
  7|     return label(n)
   |            ^
  note: introduced by `label` here: `format` builds an owned String
  --> topo/code/effects_fails.nx:2:12
   |
  2|     return format("#{}", .{n})
   |            ^
1 error(s)

The first note points at the call inside tag that brings the effect in; the second at the exact line that introduces it (the format call inside label) and the reason, however far down the chain. Rename label to something in another module, five calls deep, and the notes still lead you to the line.

Bounds go on function types too. apply accepts only functions that are !allocates !panics; passing one that could panic is a type error at the call site, and a closure has to satisfy the same bounds. dyn Trait !allocates is the same idea for trait objects (chapter 7): a List(dyn Shape !allocates) can only hold implementations that keep the promise.

Proofs: why hash is pure#

hash uses ^= and *%=: the exclusive-or cannot overflow and the wrapping multiply is defined to wrap, so nothing in it can panic. mean indexes nothing and divides by a count it checked is not zero. The compiler works this out; you do not annotate anything.

The panics effect is discharged by proof where the compiler can see that an operation cannot fail: indexing with the loop variable of a for over the same slice, an index the compiler knows is in range, arithmetic whose operands have known ranges, a division whose divisor was compared against zero on the way in, an index a comparison with the length guards. dot indexes b[i] with an index drawn from a, which proves nothing about b; the guard i < b.len around it does, so dot is pure. Delete the guard and dot carries panics, and nx explain topo/code/effects.nx dot panics answers "index may be out of bounds" at b[i]. That is what the effect is for: it tells you.

Why this matters#

nx audit file.nx is the effect system's sibling for the things it excludes: it lists every unsafe block and every mutable global, the two places a program steps outside what the compiler proves.

Next: threads and parallel loops.

Exercises#

The compiler grades these: nx topo 15 writes them into a directory, nx topo check runs them. Or do them here: the code is editable, Run runs it with the compiler in the page (it loads on the first run and never sends your code anywhere), Check grades it as nx topo check would, and Reset puts the exercise back. Ctrl+Enter runs.

fill_boundfill in the blank

Replace ??? with the bounds that let only functions which neither allocate nor panic be passed to apply.

fill_bound.nx
fn apply(xs: []mut f64, f: fn(f64) -> f64 ???) {
    for i in 0..xs.len { xs[i] = f(xs[i]) }
}

fn halve(x: f64) -> f64 { return x / 2.0 }

fn main() {
    var xs = [2.0, 4.0]
    apply(xs[..], halve)
    println("{:.1} {:.1}", .{xs[0], xs[1]})
}
The recorded output
1.0 2.0
Hints
  • bounds on a function type are written after its return type, each with a !
Solution
fn apply(xs: []mut f64, f: fn(f64) -> f64 !allocates !panics) {
    for i in 0..xs.len { xs[i] = f(xs[i]) }
}

fn halve(x: f64) -> f64 { return x / 2.0 }

fn main() {
    var xs = [2.0, 4.0]
    apply(xs[..], halve)
    println("{:.1} {:.1}", .{xs[0], xs[1]})
}

fix_promisefix the error

total promises !allocates and breaks it. Keep the promise; the output stays the same.

fix_promise.nx
fn total(xs: []i32) -> i64 !allocates {
    var copy = List(i64).new()
    for x in xs { copy.append(x as i64) }
    var sum: i64 = 0
    for c in copy { sum += c }
    return sum
}

fn main() {
    let xs: [4]i32 = [1, 2, 3, 4]
    println("{}", .{total(xs[..])})
}
The recorded output
10
Hints
  • a List allocates; a running sum does not
Solution
fn total(xs: []i32) -> i64 !allocates {
    var sum: i64 = 0
    for x in xs { sum += x as i64 }
    return sum
}

fn main() {
    let xs: [4]i32 = [1, 2, 3, 4]
    println("{}", .{total(xs[..])})
}

Quizno peeking

  1. What does !allocates after a return type mean?
    • the function allocates
    • the function promises never to allocate, and the compiler checks it
    • the function frees what it allocated
    Answer

    the function promises never to allocate, and the compiler checks it. A negative effect bound is a promise the checker enforces through every callee.

  2. Which of these carries the panics effect?
    • a + b on two i32 values
    • a +% b
    • a +| b
    Answer

    a + b on two i32 values. Checked arithmetic traps on overflow, a defined panic; the wrapping and saturating forms never fail.