08 — Effects (the heart of vibe)

Previous: Structs, enums, and match

日本語版: 08_effects.vibe.md

A function in vibe is pure unless its type says otherwise. Anything it can do besides compute — fail, print, read a file — is named in a with clause on the signature, and that clause propagates to every caller until someone handles it.

That is the whole mechanism. This chapter is what it looks like.

Declaring that a function can fail

Exception is the effect for failure. A function that may throw says so, and callers inherit the obligation:

fn risky(x: Int) -> Int with Exception {
  if x == 0 {
    perform Exception::Throw("division by zero")
  }
  100 / x
}

fn main allows Console {
  let safe = handle {
    risky(0)
  } with {
    Exception::Throw(message) => {
      println("exception: \{message}")
      -1
    }
  }
  let fine = handle {
    risky(4)
  } with {
    Exception::Throw(_) => -1
  }
  println("safe = \{safe}")
  println("fine = \{fine}")
}
exception: division by zero
safe = -1
fine = 25

handle { body } with { Exception::Throw(...) => ... } is the boundary. Inside it, risky may throw; outside it, main has no Exception in its row, because the obligation was discharged.

throw("...") — the spelling chapter 2 used — is the short form of perform Exception::Throw("..."): the same operation, and one handler catches both.

Exception is abortive: a throw does not come back. The handler arm's value becomes the value of the whole handle — that is why safe is -1 and fine is 25. There is no resume in an Exception arm.

Written without a type argument, Exception is erased: it accepts any Exception[E]. An erased arm that reads its payload gets it as a string, so the checker lets it catch only strings; Exception::Throw(_) catches anything. When you want the error type preserved, write Exception[E] — see the exception effect.

Declaring your own effect

An effect declaration is a list of operations with no implementation. The caller supplies the implementation, in the handler. This is the part Exception is a special case of:

effect Ask {
  Value(String) -> Int
}

fn answer_of(q: String) -> Int with Ask {
  perform Ask::Value(q) + 1
}

fn main allows Console {
  let v = handle {
    answer_of("life")
  } with {
    Ask::Value(_q) => resume(41)
  }
  println("v = \{v}")
}
v = 42

answer_of does not know where the number comes from. It performs Ask::Value and continues with whatever the handler resumes — here 41, so answer_of returns 42. Unlike Exception, Ask is resumable: resume(v) sends v back to the perform site and the function carries on.

Resumption is one-shot and tail-resumptive: a handler arm resumes at most once, as its last act. And it has to say so: every way out of the arm ends in resume(v), or leaves with return or throw. An arm that just produces a value — Ask::Value(_q) => 41 — is refused, because nothing in it says whether the function should carry on with 41 or stop there. A Unit operation resumes with resume(()).

Reach for your own effect when the caller genuinely has to swap the implementation — a clock in tests, a different source for a value. For ordinary failure use Exception; for local state try let mut first. Effects vs let mut has the criteria.

Rows can be variables

A higher-order function should not have to know which effects the function it was handed performs. Write the row as a variable and it carries whatever arrives:

fn apply_twice(f~: (Int) -> Int with e, x~: Int) -> Int with e {
  f(f(x))
}

fn main allows Console {
  println("apply_twice = \{apply_twice(f=(n) -> n * 2, x=10)}")
}
apply_twice = 40

apply_twice is pure when f is pure, and carries Exception when f throws. One definition, both cases, checked.

The one rule about handle

A handle has to be able to see every perform it covers. For each call in the handled body, the compiler needs to know what that call performs.

Most calls it can see: a top-level fn, a builtin, a closure whose binding or parameter carries an effect row, a closure written inside the handled body, and a rowless closure bound outside it whose body is only pure computation. bump below performs nothing. The perform is ask_once(), written in the handle, so the handle sees it:

effect Ask {
  Once() -> Int
}

fn ask_once() -> Int with Ask {
  perform Ask::Once()
}

fn main allows Console {
  let bump = (x: Int) -> Int { x + 1 }
  let n = handle { bump(ask_once()) } with {
    Ask::Once() => resume(41)
  }
  println("\{n}")
}
42

What it still cannot see is a call with no visible body: an immediately-applied closure, or a lambda whose own body performs or calls something this pass cannot name. That program type-checks and is rejected. The diagnostic names the edit. It does not name a compiler pass.

Effects you do not handle: capabilities

Fs, Env, Http and Console ride the same row, but you do not write handlers for them — the host provides them, and what you declare is permission to use them. That is the next chapter.

Next: Capabilities.