Skip to content

Closures ​

A closure is a function written as a value, together with the variables it mentions from the scope it was written in. It keeps those variables after that scope ends, so a closure returned from a function still has what it captured.

talor
let twice = (a: i64) => a * 2;
let sum = (a: i64, b: i64) => { let s = a + b; s };

Its type is fn(A): R, the type of every function value, so a closure goes wherever a function does: a parameter, a return value, a field, an element of an array. A function named directly is a value of the same type, and can be passed wherever a closure can.

Capturing ​

A closure captures the variables it mentions by move, and a plain value (see types) is copied. A captured local is a private copy inside the closure: writing it inside writes that copy, and the variable outside is not touched.

talor
fn main(): i32 {
    let count = 10;
    let next = () => { count += 1; count };
    println(`inside: ${next()}, ${next()}`);
    println(`outside: ${count}`);
    0
}

That copy lives as long as the closure, which is what makes a closure useful as a return value: it carries its own state.

talor
fn make_counter(start: i64): fn(): i64 {
    let n = start;
    () => { n += 1; n }
}

fn main(): i32 {
    let tick = make_counter(100);
    tick();
    tick();
    println(`third tick: ${tick()}`);
    0
}

A value that is not plain moves into the closure, and the name outside is gone from there on, as after any other move:

talor
let words = ["a", "bb", "ccc"];
let count = () => words.len();
println(`${count()}`);
words.len()        // error: 'words' was moved on line 2; write 'words.clone()' at the move if both uses are needed

What a closure captures, the function around it hands over. A parameter that a closure mentions is moved into the closure where the closure is made, so the function takes that parameter, whatever the closure does with it and whether or not it is called. A caller that uses the argument afterwards is told so:

talor
fn later(xs: Array<i64>): fn(): i64 { () => xs.len() }

let xs = [1, 2, 3];
let f = later(xs);
xs.len()           // error: 'xs' was given to 'later' on line 4, which keeps it; write 'xs.clone()' at the call if both uses are needed

A closure's own parameters shadow the names around it, so a closure that reads only its own xs captures nothing.

Passing closures to functions ​

The standard library takes closures wherever it needs behaviour from the caller. A call to a generic function infers the closure's parameter types from the other arguments, so they are written only when nothing else says them:

talor
use std.sort.{sort_by};
use std.list.{map, filter, fold};

fn main(): i32 {
    let xs = [5, 3, 8, 1, 9, 2];
    let limit = 4;
    let above = filter(xs, (x) => x > limit);
    let squares = map(above, (x) => x * x);
    let total = fold(squares, 0, (acc, x) => acc + x);
    let descending = sort_by(xs, (a, b) => a > b);
    println(`above ${limit}: ${above.len()} values, squares sum to ${total}`);
    println(`largest first: ${descending[0]}, smallest last: ${descending[5]}`);
    0
}

Calling a captured function is not a move. A closure that calls a function value it captured can be called again, and so can the function value itself:

talor
fn twice(h: fn(): i64): i64 { h() + h() }

fn seven(): i64 { 7 }

fn main(): i32 {
    let f = () => seven();
    println(`f() + f() = ${f() + f()}, twice(seven) = ${twice(seven)}`);
    0
}

A closure that hands on a capture is called once ​

A closure whose body hands one of its captures on - passes it to a parameter that takes it, or returns it - gives that value away when it runs. A second call would read what the first gave away, so such a closure can be called once. Nothing is written to say so: the compiler reads it from the body.

talor
struct Job { name: string, steps: Array<string> }

fn finish(j: Job): i64 takes j {
    println(`${j.name} done after ${j.steps.len()} steps`);
    j.steps.len()
}

fn main(): i32 {
    let job = Job { name: "build", steps: ["fetch", "compile", "link"] };
    let run_once = () => finish(job);
    let steps = run_once();
    println(`${steps} steps`);
    0
}

A second call is refused where it is written:

error: 'run_once' hands on what it captured and was called on line 11, so it can be called once

A closure that can be called once may be:

  • called, which spends it;
  • bound with let, and the rule follows the name;
  • passed to a parameter that the callee calls at most once and keeps nowhere. The compiler reads that from the callee's body: a call counts once, a sequence adds, an if or a match takes the most of its branches, and a loop counts as many.

It is refused anywhere else, each time with a message that says why:

  • Called inside a loop declared after it:

    error: 'once' hands on what it captured and is called here inside a loop, so it can be called once
  • Stored in a field, an array or an Option, or returned:

    error: this closure hands on what it captured, so it can be called once; it may be called, bound with 'let' or passed to a call, and not kept here
  • Passed to a function that may call it twice:

    error: 'twice' may call 'f' more than once or keep it, and this closure hands on what it captured, so it can be called once

A capture of a plain type is copied rather than handed on, so a closure that passes a plain value to a takes parameter is not called once. A closure that is never called is fine too: its captures are released with it.

std.fiber's spawn and std.thread's start accept a closure that is called once, because each runs its closure exactly once: that is how a value is moved into another fiber or thread. See concurrency.

In the body of a generic function no closure is called once: it borrows its captures instead, and a capture it would hand on is refused as a move out of a borrow, the same way in every instance of the function.

A closure that captures a view ​

A view is the exception to capturing by move, because it is Copy and read-only: a closure may capture one, and the place it points at stays borrowed for as long as the closure is live, so that place cannot be moved or mutated meanwhile. Such a closure cannot leave the function it was made in - it is not returned and not passed where the callee keeps it - because fn(A): R has nowhere to say which region the view belongs to.

Sharing a closure ​

A closure has a single owner, so it cannot be cloned:

error: 'fn(i64): i64' contains a closure and cannot be cloned

When two holders need the same closure, put it in a shared: a clone of the shared is one more count, and every holder calls the same closure. A closure behind a shared may be called from several fibers or threads at once, so it must write nothing it captured and hand nothing on; one that does is refused at the shared.

talor
fn main(): i32 {
    let base = 3;
    let scale = shared (x: i64) => x * base;
    let other = scale.clone();
    println(`${(*scale)(2)} ${(*other)(5)}`);
    0
}

This is how a server hands one request handler to every connection: a shared of the handler made before the accept loop, and a clone of it captured by each connection's fiber.

What a closure cannot do ​

  • Be compared. A closure has no equality, and neither does a value that holds one: f == g is refused, because two closures are the same only by identity.
  • Stand for a function with a clause. A function that edits or takes a parameter cannot be used as a value: the type fn(A): R carries no clause, so a call through the value would have no way to state the effect.
  • Appear at compile time. A closure allocates its environment, so a const fn and a #[no_alloc] function cannot make one.

Talor v0.1.0 - Released under the MIT OR Apache-2.0 license.