Closures: Anonymous Functions That Capture Their Environment

Oxide's closures are anonymous functions you can save in a variable or pass as arguments to other functions. Unlike regular functions, closures can capture values from the scope in which they're defined. This makes them incredibly useful for customizing behavior and creating concise, expressive code.

Closure Syntax

Oxide uses a Swift/Kotlin-inspired syntax for closures that differs from Rust:

// No parameters
let sayHello = { println!("Hello!") }

// Single parameter with explicit name
let double = { x -> x * 2 }

// Multiple parameters
let add = { x, y -> x + y }

// With type annotations
let format = { x: Int -> "Number: \(x)" }

// Multi-statement body
let process = { item ->
    let validated = validate(item)
    let transformed = transform(validated)
    transformed
}

Rust comparison: Oxide uses { params -> body } instead of Rust's |params| body:

#![allow(unused)]
fn main() {
// Rust
let double = |x| x * 2;
let add = |x, y| x + y;
let format = |x: i32| format!("Number: {}", x);
}

Type Inference with Closures

Unlike functions, closures don't require you to annotate the types of parameters or the return value. The compiler can usually infer these types from context.

fn main() {
    let numbers = vec![1, 2, 3, 4, 5]

    // Type of x is inferred as &Int from the iterator
    let doubled: Vec<Int> = numbers.iter().map { x -> x * 2 }.collect()

    // Can also add explicit annotations when needed
    let parsed = { s: &str -> s.parse<Int>().unwrapOr(0) }
}

However, once the compiler infers concrete types for a closure, those types are fixed:

fn main() {
    let identity = { x -> x }

    let s = identity("hello")  // x is inferred as &str
    let n = identity(5)        // Error: expected &str, found integer
}

If you need a closure that works with multiple types, you'll need to define a generic function instead.

Capturing the Environment

One of the most powerful features of closures is their ability to capture values from the enclosing scope. This is something regular functions cannot do:

fn main() {
    let multiplier = 3

    // This closure captures `multiplier` from the environment
    let multiply = { x -> x * multiplier }

    println!("5 * 3 = \(multiply(5))")  // Prints: 5 * 3 = 15
}

Closures can capture values in three ways, corresponding to the three ways a function can take a parameter: borrowing immutably, borrowing mutably, and taking ownership.

Immutable Borrow (Default)

By default, closures borrow values immutably:

fn main() {
    let list = vec![1, 2, 3]

    // Closure borrows `list` immutably
    let printList = { println!("List: \(list:?)") }

    printList()
    printList()

    // We can still use `list` here because it was only borrowed
    println!("Original list: \(list:?)")
}

Mutable Borrow

If the closure needs to modify a captured value, it will borrow mutably:

fn main() {
    var list = vec![1, 2, 3]

    // This closure borrows `list` mutably
    var addToList = { item -> list.push(item) }

    addToList(4)
    addToList(5)

    // After the closure is done being used, we can use `list` again
    println!("Updated list: \(list:?)")  // Prints: Updated list: [1, 2, 3, 4, 5]
}

Note that between the point where the mutable closure is defined and where it's last used, you can't have any other borrows of list. The borrow checker enforces this:

fn main() {
    var list = vec![1, 2, 3]

    var addToList = { item -> list.push(item) }

    println!("\(list:?)")  // Error: cannot borrow `list` as immutable
                            // because it is also borrowed as mutable

    addToList(4)
}

Taking Ownership with move

Sometimes you want a closure to take ownership of the values it captures, even when the body of the closure doesn't strictly need ownership. This is common when passing a closure to a new thread:

import std.thread

fn main() {
    let list = vec![1, 2, 3]

    // Use `move` to force the closure to take ownership
    thread.spawn(move { println!("From thread: \(list:?)") })
        .join()
        .unwrap()

    // Error: `list` has been moved into the closure
    // println!("\(list:?)")
}

The move keyword is placed before the opening brace of the closure. Without move, the closure would try to borrow list, but since the thread might outlive the function, the borrow checker would reject this.

The Fn Traits

Closures automatically implement one or more special traits that define how they can be called. These traits determine what a closure can do with the values it captures:

TraitWhat it meansCan be called...
FnOnceMight move captured values outOnce only
FnMutMight mutate captured valuesMultiple times
FnOnly reads captured valuesMultiple times

Every closure implements FnOnce because every closure can be called at least once. Closures that don't move captured values also implement FnMut, and closures that don't need mutable access also implement Fn.

FnOnce: Called Once

A closure that moves a value out of its environment can only be called once:

fn consumeWithCallback<F>(f: F)
where
    F: FnOnce(),
{
    f()
}

fn main() {
    let greeting = "Hello".toString()

    // This closure moves `greeting` out when called
    let consume = move {
        let moved = greeting  // Takes ownership of greeting
        println!("\(moved)")
    }

    consumeWithCallback(consume)
    // consume() // Error: closure cannot be called again
}

FnMut: Mutable Access

A closure that mutates captured values but doesn't move them out implements FnMut:

fn main() {
    var count = 0

    // This closure mutates `count`
    var increment = { count += 1 }

    increment()
    increment()
    increment()

    println!("Count: \(count)")  // Prints: Count: 3
}

Fn: Immutable Access

A closure that only reads from its environment implements Fn:

fn callTwice<F>(f: F)
where
    F: Fn(),
{
    f()
    f()
}

fn main() {
    let message = "Hello"

    // This closure only reads `message`
    let greet = { println!("\(message)") }

    callTwice(greet)
}

Closures as Function Parameters

When writing functions that accept closures, you specify the trait bound:

// Accepts any closure that can be called with an Int and returns a Bool
fn filterNumbers<F>(numbers: Vec<Int>, predicate: F): Vec<Int>
where
    F: Fn(Int) -> Bool,
{
    var result = vec![]
    for n in numbers {
        if predicate(n) {
            result.push(n)
        }
    }
    result
}

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    let evens = filterNumbers(numbers, { x -> x % 2 == 0 })
    println!("Evens: \(evens:?)")
}

Choose the most flexible trait that works for your use case:

  • Use FnOnce when the closure only needs to be called once
  • Use FnMut when the closure might mutate state
  • Use Fn when the closure only needs to read state

Returning Closures from Functions

Functions can return closures using impl Trait:

fn makeMultiplier(factor: Int): impl Fn(Int) -> Int {
    move { x -> x * factor }
}

fn main() {
    let double = makeMultiplier(2)
    let triple = makeMultiplier(3)

    println!("5 * 2 = \(double(5))")  // Prints: 5 * 2 = 10
    println!("5 * 3 = \(triple(5))")  // Prints: 5 * 3 = 15
}

Note the move keyword - it's needed because factor is a local variable that would go out of scope when makeMultiplier returns. With move, the closure takes ownership of factor.

The Implicit it Parameter

Oxide provides a special convenience for single-parameter closures in trailing closure position: the implicit it parameter. When you write a closure without an explicit parameter list and use it in the body, Oxide automatically creates a single-parameter closure:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5]

    // Using implicit `it`
    let doubled: Vec<Int> = numbers.iter().map { it * 2 }.collect()
    let evens: Vec<Int> = numbers.iter().filter { it % 2 == 0 }.copied().collect()

    // Equivalent explicit forms
    let doubled: Vec<Int> = numbers.iter().map { x -> x * 2 }.collect()
    let evens: Vec<Int> = numbers.iter().filter { x -> x % 2 == 0 }.copied().collect()
}

Important restriction: The implicit it is only available in trailing closure position (closures passed as the last argument to a function call). You cannot use it when assigning a closure to a variable:

// NOT allowed - `it` only works in trailing closures
let f = { it * 2 }  // Error: `it` only valid in trailing closure

// Use explicit parameter instead
let f = { x -> x * 2 }  // OK

This restriction keeps the code clear by ensuring it only appears in contexts where its meaning is obvious.

Trailing Closure Syntax

When the last argument to a function is a closure, you can write it outside the parentheses:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5]

    // Trailing closure - closure after the parentheses
    numbers.iter().forEach { println!("\(it)") }

    // Equivalent non-trailing form
    numbers.iter().forEach({ x -> println!("\(x)") })

    // When there are no other arguments, parentheses can be omitted entirely
    numbers.iter().forEach { println!("\(it)") }
}

Trailing closures make code more readable, especially when the closure body spans multiple lines:

fn main() {
    let result = someFunction(arg1, arg2) {
        let step1 = processStep1(it)
        let step2 = processStep2(step1)
        finalizeResult(step2)
    }
}

Real-World Example: unwrap_or_else

Many methods in the standard library accept closures. A common example is unwrapOrElse on Option (or T? in Oxide):

fn main() {
    let config = loadConfig()  // Returns Config?

    // If config is null, call the closure to create a default
    let settings = config ?? Config.default()

    // The ?? operator is equivalent to:
    let settings = config.unwrapOrElse { Config.default() }
}

The closure is only called if the value is null, allowing you to defer expensive default computation:

fn expensiveDefault(): Config {
    println!("Computing expensive default...")
    // ... expensive computation ...
    Config { /* ... */ }
}

fn main() {
    let config: Config? = Some(loadedConfig)

    // expensiveDefault() is never called because config is Some
    let settings = config ?? expensiveDefault()
}

Closures in Iterator Methods

Closures really shine when combined with iterator methods. Here's a preview of what we'll cover in the next section:

#[derive(Debug, Clone)]
struct User {
    name: String,
    age: Int,
    active: Bool,
}

fn main() {
    let users = vec![
        User { name: "Alice".toString(), age: 30, active: true },
        User { name: "Bob".toString(), age: 25, active: false },
        User { name: "Carol".toString(), age: 35, active: true },
    ]

    // Find active users over 28, get their names
    let activeAdultNames: Vec<String> = users
        .iter()
        .filter { it.active && it.age > 28 }
        .map { it.name.clone() }
        .collect()

    println!("Active adults: \(activeAdultNames:?)")
    // Prints: Active adults: ["Alice", "Carol"]
}

Summary

Closures in Oxide provide:

  • Clean syntax: { params -> body } is concise and readable
  • Environment capture: Closures can access variables from their enclosing scope
  • Flexible ownership: Choose immutable borrow, mutable borrow, or move as needed
  • Trait-based polymorphism: Fn, FnMut, and FnOnce allow generic closure parameters
  • Implicit it: Single-parameter trailing closures can use it for brevity
  • Trailing syntax: Closures can appear outside parentheses for readability

Understanding closures is essential for writing idiomatic Oxide code, especially when working with iterators, which we'll explore next.