Closure Move Semantics
Tutorial Video
Text description (accessibility)
This video demonstrates the "Closure Move Semantics" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. When a closure is sent to another thread or returned from a function, it must own its captured data. Key difference from OCaml: 1. **Explicit vs. implicit move**: Rust requires `move` to be explicit; OCaml always captures by GC reference (conceptually always "moved to the heap").
Tutorial
The Problem
When a closure is sent to another thread or returned from a function, it must own its captured data. The enclosing scope (and its stack frame) will be gone when the closure executes. move || data.len() moves data into the closure's environment at creation time. Without move, the borrow checker would reject the code because the borrowed reference would dangle. This is why thread::spawn requires move closures: the spawned thread may outlive the spawning thread's stack frame.
🎯 Learning Outcomes
move to transfer ownership of captured values into a closuremove with Copy types copies the value (semantically the same as moving)move closure that outlives its creating scopemove as mandatory for thread::spawn and async blocksCode Example
#![allow(clippy::all)]
//! # Closure Move Semantics — Ownership Transfer
use std::thread;
/// Move closure for threads
pub fn spawn_with_data(data: Vec<i32>) -> thread::JoinHandle<i32> {
thread::spawn(move || {
data.iter().sum() // data moved into closure
})
}
/// Move individual values
pub fn move_multiple() -> impl FnOnce() -> (String, Vec<i32>) {
let s = String::from("hello");
let v = vec![1, 2, 3];
move || (s, v) // Both moved
}
/// Partial move
pub fn partial_move() {
let data = (String::from("hello"), 42);
let f = move || {
let (s, n) = data; // Takes ownership of both
println!("{} {}", s, n);
};
f();
}
/// Clone before move
pub fn clone_then_move(s: String) -> (impl Fn() -> usize, String) {
let cloned = s.clone();
let f = move || cloned.len();
(f, s) // Return both the closure and original
}
/// Force move with move keyword
pub fn force_move() -> impl Fn() -> i32 {
let x = 42;
move || x // x is Copy, but move forces ownership transfer semantics
}
/// Move into async block (conceptual)
pub fn move_for_async_like() -> impl FnOnce() -> String {
let data = String::from("async data");
move || {
// Simulates async - data must be owned
data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_with_data() {
let data = vec![1, 2, 3, 4, 5];
let handle = spawn_with_data(data);
assert_eq!(handle.join().unwrap(), 15);
}
#[test]
fn test_move_multiple() {
let f = move_multiple();
let (s, v) = f();
assert_eq!(s, "hello");
assert_eq!(v, vec![1, 2, 3]);
}
#[test]
fn test_clone_then_move() {
let s = String::from("test");
let (f, original) = clone_then_move(s);
assert_eq!(f(), 4);
assert_eq!(original, "test");
}
#[test]
fn test_force_move() {
let f = force_move();
assert_eq!(f(), 42);
assert_eq!(f(), 42); // Can still call because i32 is Copy
}
#[test]
fn test_async_like() {
let f = move_for_async_like();
assert_eq!(f(), "async data");
}
}Key Differences
move to be explicit; OCaml always captures by GC reference (conceptually always "moved to the heap").'static bound on threads**: Rust's thread::spawn requires F: 'static + Send — move ensures 'static; OCaml has no such bound.clone_then_move requires explicit .clone() to retain both a closure and the original; OCaml captures by shared reference automatically.FnOnce enforcement**: A move closure that consumes a non-Copy value can only be called once (FnOnce); OCaml has no type-level equivalent.OCaml Approach
OCaml closures automatically capture variables by reference to GC-managed values — the GC prevents dangling:
let spawn_with_data data =
let result = ref 0 in
Domain.spawn (fun () -> result := List.fold_left (+) 0 data);
result (* data captured by reference; GC keeps it alive *)
In Multicore OCaml, domains capture the enclosing value by reference; the GC ensures safety. There is no move keyword because values never "move" — they stay on the heap and the GC manages their lifetime.
Full Source
#![allow(clippy::all)]
//! # Closure Move Semantics — Ownership Transfer
use std::thread;
/// Move closure for threads
pub fn spawn_with_data(data: Vec<i32>) -> thread::JoinHandle<i32> {
thread::spawn(move || {
data.iter().sum() // data moved into closure
})
}
/// Move individual values
pub fn move_multiple() -> impl FnOnce() -> (String, Vec<i32>) {
let s = String::from("hello");
let v = vec![1, 2, 3];
move || (s, v) // Both moved
}
/// Partial move
pub fn partial_move() {
let data = (String::from("hello"), 42);
let f = move || {
let (s, n) = data; // Takes ownership of both
println!("{} {}", s, n);
};
f();
}
/// Clone before move
pub fn clone_then_move(s: String) -> (impl Fn() -> usize, String) {
let cloned = s.clone();
let f = move || cloned.len();
(f, s) // Return both the closure and original
}
/// Force move with move keyword
pub fn force_move() -> impl Fn() -> i32 {
let x = 42;
move || x // x is Copy, but move forces ownership transfer semantics
}
/// Move into async block (conceptual)
pub fn move_for_async_like() -> impl FnOnce() -> String {
let data = String::from("async data");
move || {
// Simulates async - data must be owned
data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_with_data() {
let data = vec![1, 2, 3, 4, 5];
let handle = spawn_with_data(data);
assert_eq!(handle.join().unwrap(), 15);
}
#[test]
fn test_move_multiple() {
let f = move_multiple();
let (s, v) = f();
assert_eq!(s, "hello");
assert_eq!(v, vec![1, 2, 3]);
}
#[test]
fn test_clone_then_move() {
let s = String::from("test");
let (f, original) = clone_then_move(s);
assert_eq!(f(), 4);
assert_eq!(original, "test");
}
#[test]
fn test_force_move() {
let f = force_move();
assert_eq!(f(), 42);
assert_eq!(f(), 42); // Can still call because i32 is Copy
}
#[test]
fn test_async_like() {
let f = move_for_async_like();
assert_eq!(f(), "async data");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_with_data() {
let data = vec![1, 2, 3, 4, 5];
let handle = spawn_with_data(data);
assert_eq!(handle.join().unwrap(), 15);
}
#[test]
fn test_move_multiple() {
let f = move_multiple();
let (s, v) = f();
assert_eq!(s, "hello");
assert_eq!(v, vec![1, 2, 3]);
}
#[test]
fn test_clone_then_move() {
let s = String::from("test");
let (f, original) = clone_then_move(s);
assert_eq!(f(), 4);
assert_eq!(original, "test");
}
#[test]
fn test_force_move() {
let f = force_move();
assert_eq!(f(), 42);
assert_eq!(f(), 42); // Can still call because i32 is Copy
}
#[test]
fn test_async_like() {
let f = move_for_async_like();
assert_eq!(f(), "async data");
}
}
Deep Comparison
Closure Move Semantics: Comparison
See src/lib.rs for the Rust implementation.
Exercises
fn parallel_map<T: Send + 'static, U: Send + 'static>(data: Vec<T>, f: impl Fn(T) -> U + Send + Sync + 'static) -> Vec<U> using thread::scope or Arc + move closures.Vec<String> with 1000 elements and measure the time of thread::spawn(move || ...) vs. cloning the vec before spawn using criterion.fn make_async_task(data: String) -> impl FnOnce() -> String returning a move closure that simulates a deferred computation — verify it can be called from a different scope.