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:
| Trait | What it means | Can be called... |
|---|---|---|
FnOnce | Might move captured values out | Once only |
FnMut | Might mutate captured values | Multiple times |
Fn | Only reads captured values | Multiple 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
FnOncewhen the closure only needs to be called once - Use
FnMutwhen the closure might mutate state - Use
Fnwhen 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
moveas needed - Trait-based polymorphism:
Fn,FnMut, andFnOnceallow generic closure parameters - Implicit
it: Single-parameter trailing closures can useitfor 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.