In this lecture, we see all that functions have to offer in Rust and their unique Ownership management. Everything from associated functions, to closures, and more!
With pass-by-value, the compiler can often keep pixel entirely in CPU registersβnever touching main memory. With references, every access requires a memory read.
π Rule of Thumb: If your type is β€ 16 bytes and implements Copy, pass by value. For larger types, use references.
Pass by Reference
A function is said to "pass by reference" when it receives a pointer to data owned elsewhere, rather than taking ownership of the data itself.
In Rust, references come in two flavors:
Immutable Reference (&T): Read-only access to the data. Multiple immutable references can coexist.
Mutable Reference (&mut T): Read-write access to the data. Only one mutable reference can exist at a time (no other references allowed).
π€ When does pass-by-reference help memory footprint?
For large values (like vectors, strings, or structs with many fields), pass-by-reference is dramatically better because:
Only an 8-byte pointer is passed, regardless of data size
No copyingβthe original data stays in place
No additional allocation or deallocation overhead
The caller retains ownership (no need to return the value back)
Example: When Pass-by-Reference Wins
Consider a Document struct representing a text file in memory:
structDocument {
title: String, // 24 bytes (String is ptr + len + capacity)
author: String, // 24 bytes
content: Vec<String>, // 24 bytes + heap data (could be megabytes!)
metadata: [u8; 256], // 256 bytes
} // Total: 328+ bytes (plus heap allocations)// Pass by VALUE: Copies 328+ bytes AND clones all heap data!// The stack-resident fields are passed by value; heap allocations are not always implicitly cloned.fnword_count_value(doc: Document) ->usize {
doc.content.iter()
.map(|line| line.split_whitespace().count())
.sum()
}
// Pass by REFERENCE: Only passes an 8-byte pointerfnword_count_ref(doc: &Document) ->usize {
doc.content.iter()
.map(|line| line.split_whitespace().count())
.sum()
}
Approach
Data Passed
Memory Cost
Ownership
Pass by Value
328+ bytes + heap clones
Potentially megabytes
Transferred to function
Pass by Reference
8 bytes (pointer)
8 bytes total
Retained by caller
The difference becomes critical when working with real documents:
fnanalyze_library(books: &Vec<Document>) -> LibraryStats {
// Without the &, (And assuming we couldn't perform a move instead) // this would CLONE the entire vector of documents!// With a library of 10,000 books, that could be gigabytes of copying.
LibraryStats {
total_books: books.len(),
total_words: books.iter().map(|b| word_count_ref(b)).sum(),
avg_length: calculate_avg_length(books), // Also borrows
}
}
π‘ Key Insight: Pass-by-reference lets you "look at" data without owning it. This is essential for:
Functions that only need to read data
Avoiding expensive copies of large structures
Allowing the caller to continue using the data after the call
Mutable References: Editing in Place
When you need to modify data without taking ownership, use &mut:
structCounter {
value: u64,
max_seen: u64,
}
// Takes mutable referenceβcan modify the originalfnincrement(counter: &mut Counter) {
counter.value += 1;
if counter.value > counter.max_seen {
counter.max_seen = counter.value;
}
}
fnmain() {
letmut c = Counter { value: 0, max_seen: 0 };
increment(&mut c); // Modifies c in placeincrement(&mut c); // Still validβwe never gave up ownershipincrement(&mut c);
println!("Count: {}, Max: {}", c.value, c.max_seen); // "Count: 3, Max: 3"
}
Reference Type
Syntax
Can Read?
Can Write?
How Many?
Immutable
&T
Yes
No
Many
Mutable
&mut T
Yes
Yes
Exactly one
π Rule of Thumb: For large or non-Copy types, start by considering a reference. Use &T for read-only access and &mut T when modification is needed. Then measure if performance is important.
Choosing Between Value and Reference
Here's a quick decision guide:
Situation
Recommendation
Why
Small Copy types (integers, floats, small structs β€16 bytes)
Need to read data in multiple places simultaneously
Pass by immutable reference (&T)
Multiple borrows allowed
Function Declaration Syntax
Now that we understand how values move through functions, let's examine the anatomy of a Rust function.
Basic Syntax
fnfunction_name(param1: Type1, param2: Type2) -> ReturnType {
// function body
expression // Last expression is implicitly returned
}
Every part of this signature serves a purpose:
Component
Purpose
Memory/Optimization Benefit
fn
Declares a function
Compiler knows to generate static code
Parameter types
Explicit type annotations
Enables compile-time type checking and optimization
-> ReturnType
Declares what the function produces
Caller knows exact size to allocate
Expression return
Last expression is the return value
No return keyword overhead
Why Explicit Parameter Types?
Unlike let bindings where types can be inferred, function parameters require explicit type annotations:
letx = 42; // Type inferred as i32fnadd(a: i32, b: i32) ->i32 { // Types MUST be specified
a + b
}
π€ Why the difference? Function signatures form the API boundary of your code. Explicit types serve as documentation and enable the compiler to check callers independentlyβwithout looking inside the function body. This enables faster compilation and clearer error messages.
Expression-Based Returns
Rust functions are expression-based. The last expression (without a semicolon) is the return value:
fnsquare(x: i32) ->i32 {
x * x // No semicolon = this is the return value
}
fnsquare_explicit(x: i32) ->i32 {
return x * x; // Explicit return works too
}
fnsquare_wrong(x: i32) ->i32 {
x * x; // Semicolon makes this a statement returning ()// ERROR: expected i32, found ()
}
π Key Point: Omitting the semicolon on the last line is idiomatic Rust. Use return only for early exits.
The Unit Type ()
Functions without a -> ReturnType implicitly return (), the unit type:
The unit type () is a zero-sized typeβit takes up no memory at runtime. It's Rust's way of saying "this function produces no meaningful value."
Generic Functions and Monomorphization
Generic functions work with multiple types using type parameters:
fnlargest<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
// Can be called with any type implementing PartialOrdletbigger_int = largest(10, 20); // T = i32letbigger_float = largest(3.14, 2.71); // T = f64letbigger_char = largest('a', 'z'); // T = char
π‘ Key Insight: Monomorphization
When you use a generic function, the compiler generates specialized versions for each concrete type used. This is called monomorphization.
// You write:fndouble<T: Copy + std::ops::Mul<Output = T>>(x: T) -> T {
x * x
}
// Compiler generates (conceptually):fndouble_i32(x: i32) ->i32 { x * x }
fndouble_f64(x: f64) ->f64 { x * x }
// ...one for each type you actually use
Result: Generic functions have zero runtime overheadβthey're as fast as if you wrote specialized functions by hand.
Trait Bounds: Constraining Generic Types
The T: PartialOrd syntax is a trait boundβit says "T must implement the PartialOrd trait":
// T must be comparable AND copyablefnmin<T: PartialOrd + Copy>(a: T, b: T) -> T {
if a < b { a } else { b }
}
// Alternative syntax with `where` clause (cleaner for complex bounds)fncomplex_function<T, U>(a: T, b: U) -> T
where
T: Clone + PartialOrd,
U: Into<T>,
{
letb_converted: T = b.into();
if a > b_converted { a } else { b_converted.clone() }
}
Syntax
Use Case
T: Trait
Simple, single bound
T: Trait1 + Trait2
Multiple bounds
where T: Trait
Complex bounds, better readability
Closures (Anonymous Functions)
Basic Syntax
// Regular functionfnadd_one(x: i32) ->i32 {
x + 1
}
// Equivalent closureletadd_one = |x: i32| ->i32 { x + 1 };
// Closure with type inference (more common)letadd_one = |x| x + 1;
// Multi-line closureletcomplex = |x, y| {
letsum = x + y;
sum * 2
};
Syntax
Description
|params|
Parameter list (types often inferred)
|x| expr
Single expression body
|x| { ... }
Block body for multiple statements
Environment Capture
The key difference between closures and functions is that closures can capture variables from their environment:
fnmain() {
letmultiplier = 10;
// This closure captures `multiplier` from the environmentletmultiply = |x| x * multiplier;
println!("{}", multiply(5)); // 50
}
π€ How does this work? The closure "remembers" the value of multiplier even though it's defined outside the closure. But how does it store this captured value?
Closures Are Structs
Under the hood, the compiler transforms closures into anonymous structs. Each captured variable becomes a field:
letmultiplier = 10;
letclosure = |x| x * multiplier;
// Conceptually, the compiler generates something like:structAnonymousClosure<'a> {
multiplier: &'ai32, // Captured by reference
}
implAnonymousClosure<'_> {
fncall(&self, x: i32) ->i32 {
x * self.multiplier
}
}
π‘ Key Insight: This is why closures have unique, unnameable typesβeach closure is its own struct! Even two identical-looking closures have different types.
The Three Closure Traits
Rust categorizes closures by how they capture their environment:
Fn β Borrow Immutably
letdata = vec![1, 2, 3];
// Captures &data (immutable borrow)letprint_data = || println!("{:?}", data);
print_data(); // Can call multiple timesprint_data();
println!("{:?}", data); // data is still usable!
letdata = vec![1, 2, 3];
// Takes ownership of dataletconsume = || {
drop(data); // data is moved into the closureprintln!("Data dropped!");
};
consume(); // Works// consume(); // ERROR: closure already consumed `data`
Closure Trait Hierarchy
Trait
Captures
Can Call Multiple Times?
Memory Model
Fn
&T (immutable borrow)
Yes
Struct with reference fields
FnMut
&mut T (mutable borrow)
Yes
Struct with mutable reference fields
FnOnce
T (ownership)
No (consumes captured values)
Struct with owned fields
π Key Point: The compiler automatically chooses the least restrictive trait that works. If the closure only reads captured values, it implements Fn. If it modifies them, FnMut. If it moves them, FnOnce.
The move Keyword
Sometimes you need to force ownership transfer, even when borrowing would work:
fncreate_counter() ->implFnMut() ->i32 {
letmut count = 0;
// Without `move`, this would try to borrow `count`// But `count` is local to this functionβit would be dropped!move || {
count += 1;
count
}
}
letmut counter = create_counter();
println!("{}", counter()); // 1println!("{}", counter()); // 2
π€ Why move? When returning a closure or passing it to another thread, the closure must own its captured valuesβreferences to local variables would be invalid after the function returns.
Memory Efficiency of Closures
Because closures are structs, their size depends on what they capture:
In Rust, functions can be associated with a type using impl blocks. This organizes related functionality and enables object-oriented-style programming.
Associated Functions: Type-Level Operations
Associated functions are called using Type::function() syntax:
structRectangle {
width: u32,
height: u32,
}
implRectangle {
// Associated function β no `self` parameterfnnew(width: u32, height: u32) ->Self {
Rectangle { width, height }
}
// Another associated function β creates a squarefnsquare(size: u32) ->Self {
Rectangle { width: size, height: size }
}
}
// Called on the TYPE, not an instanceletrect = Rectangle::new(10, 20);
letsquare = Rectangle::square(15);
π Key Point: Associated functions are often used as constructors. The new function is a convention (not a keyword) for creating instances.
Methods: Instance-Level Operations
Methods take self in some form as their first parameter:
Use &mut self when you need to modify the instance
Use self when transforming into something else or when the method logically "uses up" the instance
Method Call Syntax Sugar
Rust provides automatic referencing and dereferencing for method calls:
letrect = Rectangle::new(10, 20);
// These are all equivalent:
rect.area(); // Rust automatically borrows &rect
(&rect).area(); // Explicit borrow
Rectangle::area(&rect); // Fully qualified syntax
π‘ Key Insight: This automatic referencing is why rect.scale(2) works even though scale takes &mut self. Rust inserts (&mut rect).scale(2) automatically.
Multiple impl Blocks
You can split implementations across multiple impl blocks:
Methods are just functions with a special first parameter. They don't add any memory overhead to the struct:
structTinyStruct {
value: u8, // 1 byte
}
implTinyStruct {
fnget(&self) ->u8 { self.value }
fnset(&mutself, v: u8) { self.value = v; }
fnconsume(self) ->u8 { self.value }
fncreate(v: u8) ->Self { TinyStruct { value: v } }
}
// TinyStruct is still just 1 byte!// Methods are compiled to regular functionsassert_eq!(std::mem::size_of::<TinyStruct>(), 1);
Diverging Functions (The Never Type)
Some functions never return to their caller. Rust has a special type for this: the never type, written as !.
Examples of Diverging Functions
// Loops forever β never returnsfninfinite_loop() -> ! {
loop {
// Process events, run a server, etc.
}
}
// Terminates the program β never returnsfnexit_program(code: i32) -> ! {
std::process::exit(code)
}
// Always panics β never returns normallyfnalways_panic(msg: &str) -> ! {
panic!("{}", msg)
}
// Calls another diverging functionfnfatal_error() -> ! {
eprintln!("Fatal error occurred!");
std::process::exit(1)
}
The Type System Power of !
The never type has a unique property: it can coerce to any other type. This makes it incredibly useful in expressions:
fnget_value(opt: Option<i32>) ->i32 {
match opt {
Some(x) => x, // Returns i32None => panic!("No value!"), // Returns !, which coerces to i32
}
}
π€ Why does this work? The compiler knows that panic! never actually produces a valueβexecution will never reach the point where a value is needed. So it's safe to pretend it returns any type.
fncomplex_algorithm(input: &str) ->Vec<i32> {
todo!() // Returns !, so this type-checks even though we haven't implemented it
}
fnlegacy_feature() ->String {
unimplemented!("This feature is no longer supported")
}
When the compiler sees a diverging function, it knows:
No return value needs to be stored β no stack space for the result
Code after the call is dead β it can be eliminated
Control flow doesn't continue β simplifies analysis
fnexample() ->i32 {
letx = 5;
ifsome_condition() {
return x;
}
panic!("Condition failed!");
// The compiler knows this code is unreachable// It won't generate any instructions for itlety = 10; // Dead code β compiler may warn or eliminate
y
}
π Key Point: The ! type is part of what makes Rust's type system so expressive. It allows functions to participate in expressions even when they never produce a value.
Higher-Order Functions
Functions as Parameters
// apply_twice takes a function `f` and applies it twice to `x`fnapply_twice<F>(f: F, x: i32) ->i32where
F: Fn(i32) ->i32,
{
f(f(x))
}
fnmain() {
letdouble = |x| x * 2;
letadd_ten = |x| x + 10;
println!("{}", apply_twice(double, 5)); // double(double(5)) = 20println!("{}", apply_twice(add_ten, 5)); // add_ten(add_ten(5)) = 25
}
Functions as Return Values
// Returns a closure that adds `n` to its argumentfnmake_adder(n: i32) ->implFn(i32) ->i32 {
move |x| x + n // `move` because `n` must be owned by the closure
}
fnmain() {
letadd_five = make_adder(5);
letadd_hundred = make_adder(100);
println!("{}", add_five(10)); // 15println!("{}", add_hundred(10)); // 110
}
π€ Why impl Fn(i32) -> i32? Each closure has a unique, unnameable type. The impl Trait syntax lets us return "something that implements Fn" without naming the exact type.
Iterator Methods: HOFs in Action
You've already used higher-order functions! Iterator methods like map, filter, and fold are all HOFs:
π‘ Key Insight: Monomorphization Makes HOFs Free
When you use a closure with a HOF, the compiler generates specialized code for that exact closure. There's no function pointer indirection, no dynamic dispatch.
// This idiomatic Rust...letsum: i32 = (1..=100)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.sum();
// ...compiles to code equivalent to this hand-written loop:letmut sum = 0;
forxin1..=100 {
if x % 2 == 0 {
sum += x * x;
}
}
The iterator chain is fully inlinedβno heap allocations, no function call overhead. This is what "zero-cost abstractions" means.
Common HOF Patterns
Function
Takes
Returns
Use Case
map
Fn(T) -> U
Iterator of U
Transform elements
filter
Fn(&T) -> bool
Iterator of T
Select elements
fold
Fn(Acc, T) -> Acc
Acc
Reduce to single value
for_each
Fn(T) -> ()
()
Side effects
find
Fn(&T) -> bool
Option<T>
First matching element
any
Fn(&T) -> bool
bool
Does any element match?
all
Fn(&T) -> bool
bool
Do all elements match?
Building Your Own HOFs
// A HOF that retries an operation up to `n` timesfnretry<F, T, E>(mut operation: F, max_attempts: u32) ->Result<T, E>
where
F: FnMut() ->Result<T, E>,
{
letmut attempts = 0;
loop {
matchoperation() {
Ok(value) => returnOk(value),
Err(e) if attempts < max_attempts => {
attempts += 1;
continue;
}
Err(e) => returnErr(e),
}
}
}
// Usageletresult = retry(|| fetch_data_from_network(), 3);
Memory Considerations
Approach
Memory
Performance
Flexibility
Concrete closure (impl Fn())
Size of captured values
Inlined, zero overhead
One specific closure type
Closure reference (&dyn Fn())
16 bytes (fat pointer)
Virtual call overhead
Any Fn closure
Boxed closure (Box<dyn Fn()>)
Heap allocation + 16 bytes
Virtual call + allocation
Stored, returned from functions
Dynamic Dispatch with dyn
So far, all our functions have used static dispatchβthe compiler knows exactly which function to call at compile time. But sometimes we need dynamic dispatch, where the function to call is determined at runtime.
Function Pointers: The Simple Case
For simple cases where you just need to swap between known functions, use function pointers:
fnadd(a: i32, b: i32) ->i32 { a + b }
fnmultiply(a: i32, b: i32) ->i32 { a * b }
fnsubtract(a: i32, b: i32) ->i32 { a - b }
fnmain() {
// `fn(i32, i32) -> i32` is a function pointer typeletoperation: fn(i32, i32) ->i32 = add;
println!("{}", operation(5, 3)); // 8letoperation = multiply;
println!("{}", operation(5, 3)); // 15// Store in a collectionletoperations: Vec<fn(i32, i32) ->i32> = vec![add, multiply, subtract];
foropin &operations {
println!("{}", op(10, 3)); // 13, 30, 7
}
}
π Key Point: Function pointers are 8 bytes (a memory address) and can only point to actual fn itemsβnot closures that capture environment.
Trait Objects: The Flexible Case
When you need to work with different types that share behavior, use trait objects:
// STATIC dispatch β type known at compile timefndraw_static<T: Drawable>(item: &T) {
item.draw(); // Compiler knows exactly which `draw` to call
}
// DYNAMIC dispatch β type determined at runtimefndraw_dynamic(item: &dyn Drawable) {
item.draw(); // Must look up `draw` in vtable at runtime
}
Heterogeneous Collections
The real power of dyn is storing different types in the same collection:
fnmain() {
// This WON'T work β Vec needs a single, known type:// let shapes = vec![Circle { radius: 5.0 }, Rectangle { width: 10, height: 20 }];// This WORKS β all items are `Box<dyn Drawable>`letshapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 10, height: 20 }),
Box::new(Circle { radius: 2.5 }),
];
// Draw all shapes β dispatch happens at runtimeforshapein &shapes {
shape.draw();
}
}
Memory Layout: Fat Pointers and Vtables
A &dyn Trait is a fat pointerβ16 bytes containing:
A pointer to the data (8 bytes)
A pointer to the vtable (8 bytes)
The vtable contains pointers to the trait's methods for that specific type:
// Use STATIC dispatch when:// - Performance is critical// - Working with a single concrete type// - The type is known at compile timefnprocess_fast<T: Processor>(item: T) { ... }
// Use DYNAMIC dispatch when:// - You need heterogeneous collections// - Binary size matters more than speed// - The type isn't known until runtimefnprocess_any(item: &dyn Processor) { ... }
Object Safety
Not all traits can be made into trait objects. A trait is object-safe if:
π‘ Key Insight: Object safety ensures the compiler can create a vtable. If a method depends on the concrete type (via Self or generics), the vtable can't represent it.
Summary
Here's a comprehensive reference for all function types in Rust:
Function Type
Syntax
Memory Model
Use Case
Regular function
fn name() { }
Static, compiled
General purpose
Generic function
fn name<T>() { }
Monomorphized per type
Type-flexible operations
Closure (Fn)
|x| expr
Struct with &T fields
Read-only callbacks
Closure (FnMut)
|x| expr
Struct with &mut T fields
Stateful callbacks
Closure (FnOnce)
move |x| expr
Struct with owned fields
One-shot operations
Associated function
Type::func()
Static
Constructors, utilities
Method (&self)
self.method()
Borrows instance
Read-only operations
Method (&mut self)
self.method()
Mutably borrows
In-place modification
Method (self)
self.method()
Takes ownership
Transformations
Diverging
fn f() -> !
Never returns
Panic, exit, infinite loops
Higher-order
fn f(g: impl Fn())
Monomorphized
Abstraction over behavior
Function pointer
fn(T) -> U
8 bytes
Simple function switching
Trait object
&dyn Trait
16-byte fat pointer
Heterogeneous collections
Key Optimization Principles
Monomorphization β Generics and impl Trait generate specialized code at compile time
Zero-cost closures β Closures compile to structs; no heap allocation unless boxed
Static dispatch by default β Only pay for dynamic dispatch when you explicitly use dyn
Pass small types by value β Avoids indirection for types β€16 bytes
Pass large types by reference β Avoids copying for larger structures
π Lecture Notes
Key Definitions:
Function β A named, reusable block of code with typed parameters
Closure β An anonymous function that captures its environment
Method β A function associated with a type instance via self
Associated Function β A function associated with a type (no self)
Diverging Function β A function that never returns (-> !)
Higher-Order Function β A function that takes or returns functions
Trait Object β A type-erased reference enabling dynamic dispatch