Functions #
Functions in Rust look simple on the surface, but a few rules set them apart from other languages. Parameters always need explicit type annotations — there’s no inference for function parameters. Return values can be implicit from the last expression without a semicolon, not just via return. And one thing that’s most often overlooked: how a function interacts with the ownership system determines whether you pass a value (move), borrow (&T), or mutably borrow (&mut T), and this choice has direct consequences for how the caller can use its data after calling the function. This article covers all dimensions of functions in Rust — from basic definitions to closures, higher-order functions, and the idiomatic patterns that make Rust code expressive and safe.
Function Definition and Anatomy #
Functions in Rust are declared with the fn keyword. Every parameter must have a type annotation — unlike let variables which can rely on inference. The return type is written after ->.
// Function without parameters and without a return value
fn sapa() {
println!("Hello from Rust!");
}
// Function with parameters — types are always explicit
fn tambah(a: i32, b: i32) -> i32 {
a + b // expression without ; = implicit return value
}
// Multiple parameters with different types
fn format_harga(nama: &str, harga: f64, diskon: u8) -> String {
let harga_akhir = harga * (1.0 - diskon as f64 / 100.0);
format!("{}: Rp{:.0} ({}% off)", nama, harga_akhir, diskon)
}
fn main() {
sapa();
println!("{}", tambah(5, 3));
println!("{}", format_harga("Kopi", 25_000.0, 10));
}
Implicit vs Explicit Return Values #
This is one of the most confusing things for new developers: the last expression in a function without a semicolon is the return value. A semicolon turns an expression into a statement — and statements don’t have values.
// CORRECT: implicit return — last expression without ;
fn kuadrat(x: i32) -> i32 {
x * x
}
// CORRECT: explicit return — for early returns
fn bagi_aman(a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
return None; // early return — explicit return makes sense here
}
Some(a / b) // implicit return at the end
}
// ANTI-PATTERN: explicit return at the end of a function that doesn't need it
fn kuadrat_buruk(x: i32) -> i32 {
return x * x; // explicit return here isn't wrong, but it's redundant
}
// ANTI-PATTERN: accidental semicolon — the function returns ()
fn kuadrat_rusak(x: i32) -> i32 {
x * x; // semicolon! this becomes a statement, not a return value
// error[E0308]: mismatched types — expected i32, found ()
}
fn main() {
println!("{:?}", kuadrat(5)); // 25
println!("{:?}", bagi_aman(10.0, 3.0)); // Some(3.333...)
println!("{:?}", bagi_aman(10.0, 0.0)); // None
}
The Unit Type ()
#
A function without a return type implicitly returns the unit type () — an empty tuple. This isn’t void like in C: () is a real value that can be stored in a variable.
fn cetak_pesan(pesan: &str) {
println!("{}", pesan);
// implicitly returns ()
}
fn main() {
// () can be stored, though it's rarely useful
let hasil: () = cetak_pesan("Hello");
println!("{:?}", hasil); // ()
}
Parameters: Ownership, Borrow, and Mutable Borrow #
Choosing how to pass arguments to a function is a design decision, not just syntax. The three main options — move, borrow, mutable borrow — have different implications for the caller.
flowchart TD
A{Does the function\nneed to modify data?}
A -- Yes --> B["&mut T\nMutable borrow\nThe caller keeps its data"]
A -- No --> C{Does the function\nneed to own the data?}
C -- Yes --> D["T (Move)\nOwnership transfers\nThe caller can't use it anymore"]
C -- No --> E{"Large type\nor non-Copy?"}
E -- Yes --> F["&T\nImmutable borrow\nMost often used"]
E -- No --> G["T (Copy)\nAutomatic copy\nThe caller keeps the value"]// Move — the function takes ownership
fn konsumsi(s: String) -> String {
format!("Processed: {}", s)
// s is dropped here because the function owns it
}
// Immutable borrow — the function only reads
fn panjang(s: &str) -> usize {
s.len()
}
// Mutable borrow — the function modifies the caller's data
fn tambahkan_tanda_seru(s: &mut String) {
s.push('!');
}
fn main() {
let nama = String::from("Rust");
// Move: nama can't be used after this
let hasil = konsumsi(nama);
// println!("{}", nama); // error: nama has been moved
println!("{}", hasil);
let kalimat = String::from("Hello world");
// Borrow: kalimat stays valid
println!("Length: {}", panjang(&kalimat));
println!("Sentence: {}", kalimat); // ✓ still usable
let mut ucapan = String::from("Hello");
// Mutable borrow: modify ucapan from inside the function
tambahkan_tanda_seru(&mut ucapan);
println!("{}", ucapan); // "Hello!"
}
Choosing &str or &String
#
For string parameters, &str is always better than &String because it’s more flexible — it accepts string literals, &String, and slices all at once:
// ANTI-PATTERN: too specific, only accepts &String
fn hitung_kata_buruk(teks: &String) -> usize {
teks.split_whitespace().count()
}
// CORRECT: &str is more generic
fn hitung_kata(teks: &str) -> usize {
teks.split_whitespace().count()
}
fn main() {
let owned = String::from("Rust is a systems language");
let literal = "just three words";
println!("{}", hitung_kata(&owned)); // deref coercion: &String → &str
println!("{}", hitung_kata(literal)); // &str directly
println!("{}", hitung_kata(&owned[5..])); // slices are also valid
}
Multiple Return Values via Tuple #
Rust doesn’t have multiple return values directly, but tuples work just as well:
fn statistik(data: &[f64]) -> (f64, f64, f64) {
let n = data.len() as f64;
let rata: f64 = data.iter().sum::<f64>() / n;
let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
let maks = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(rata, min, maks) // return three values at once
}
fn main() {
let nilai = [85.0, 92.0, 78.0, 95.0, 88.0];
let (rata, min, maks) = statistik(&nilai);
println!("Average: {:.1}, Min: {}, Max: {}", rata, min, maks);
}
Generic Functions #
Generic functions let a single implementation work for many types. The compiler generates a specific version for every type used — monomorphization — so there’s no runtime overhead.
// T must be comparable (PartialOrd) and displayable (Display)
fn cetak_terbesar<T: PartialOrd + std::fmt::Display>(daftar: &[T]) {
if daftar.is_empty() {
println!("Empty list");
return;
}
let mut terbesar = &daftar[0];
for item in daftar {
if item > terbesar {
terbesar = item;
}
}
println!("Largest: {}", terbesar);
}
// Multiple type parameters
fn pertukaran<T: Clone, U: Clone>(a: T, b: U) -> (U, T) {
(b.clone(), a.clone())
}
// Where clause — easier to read for long constraints
fn proses<T, U>(nilai: T, transformasi: U) -> String
where
T: std::fmt::Debug + Clone,
U: Fn(T) -> String,
{
transformasi(nilai)
}
fn main() {
cetak_terbesar(&[3, 1, 4, 1, 5, 9, 2, 6]);
cetak_terbesar(&["apel", "mangga", "jeruk"]);
cetak_terbesar(&[3.14, 2.71, 1.41]);
let (b, a) = pertukaran(42, "halo");
println!("{} {}", a, b);
let hasil = proses(vec![1, 2, 3], |v| format!("{:?}", v));
println!("{}", hasil);
}
Closures #
Closures are anonymous functions that can capture variables from their surrounding scope. Their syntax uses |parameter| expression, far more concise than a regular fn.
fn main() {
// Simple closure
let tambah = |a: i32, b: i32| a + b;
println!("{}", tambah(3, 4)); // 7
// Type inference — annotations often unnecessary
let kuadrat = |x| x * x;
println!("{}", kuadrat(5i32)); // 25
// Multi-line closure with a block
let proses = |data: &[i32]| {
let jumlah: i32 = data.iter().sum();
let rata = jumlah as f64 / data.len() as f64;
(jumlah, rata)
};
let angka = [10, 20, 30, 40, 50];
let (total, avg) = proses(&angka);
println!("Total: {}, Average: {:.1}", total, avg);
}
The Three Closure Capture Modes #
Closures can capture variables from their environment in three different ways, chosen automatically by the compiler based on what’s needed:
fn main() {
// 1. Capture by immutable borrow (&T) — the default when only reading
let pesan = String::from("halo");
let cetak = || println!("{}", pesan); // borrows pesan
cetak();
cetak();
println!("pesan still valid: {}", pesan); // ✓
// 2. Capture by mutable borrow (&mut T) — when the closure modifies a value
let mut counter = 0;
let mut tambah_satu = || {
counter += 1; // mutable borrow of counter
println!("Counter: {}", counter);
};
tambah_satu();
tambah_satu();
// println!("{}", counter); // error: still borrowed by the closure
drop(tambah_satu);
println!("Final counter: {}", counter); // ✓ after the closure is dropped
// 3. Capture by move — with the `move` keyword
let nama = String::from("Budi");
let sapa = move || println!("Hello, {}!", nama); // nama is moved into the closure
sapa();
// println!("{}", nama); // error: nama has been moved into the closure
// move is required for closures that outlive their original scope
// (for example, sent to another thread)
}
Closure Traits: Fn, FnMut, FnOnce
#
Rust distinguishes closures by how they use the captured variables:
| Trait | When it’s used | Can be called |
|---|---|---|
Fn | Only reads or captures nothing | Multiple times |
FnMut | Modifies captured variables | Multiple times (but needs mut) |
FnOnce | Moves captured variables | Only once |
// Fn — a closure that only reads
fn panggil_dua_kali<F: Fn()>(f: F) {
f();
f(); // can be called more than once
}
// FnMut — a closure that modifies state
fn panggil_dengan_mut<F: FnMut()>(mut f: F) {
f();
f();
}
// FnOnce — a closure that consumes something
fn panggil_sekali<F: FnOnce() -> String>(f: F) -> String {
f() // can only be called once
}
fn main() {
let x = 10;
panggil_dua_kali(|| println!("x = {}", x)); // Fn
let mut jumlah = 0;
panggil_dengan_mut(|| {
jumlah += 1;
println!("jumlah = {}", jumlah);
}); // FnMut
let nama = String::from("Rust");
let hasil = panggil_sekali(move || format!("Hello, {}!", nama)); // FnOnce
println!("{}", hasil);
}
Higher-Order Functions #
A higher-order function is a function that takes another function as a parameter or returns a function as a value. This is a very common pattern in Rust, especially together with iterators.
Functions as Parameters #
There are two ways to accept a function as a parameter: with a function pointer (fn) or with a closure trait bound (Fn/FnMut/FnOnce):
// Function pointer — only accepts regular functions, not capturing closures
fn terapkan(f: fn(i32) -> i32, nilai: i32) -> i32 {
f(nilai)
}
fn kali_dua(x: i32) -> i32 { x * 2 }
fn tambah_satu(x: i32) -> i32 { x + 1 }
// Trait bound — accepts both regular functions AND closures
fn terapkan_closure<F: Fn(i32) -> i32>(f: F, nilai: i32) -> i32 {
f(nilai)
}
fn main() {
// Function pointer
println!("{}", terapkan(kali_dua, 5)); // 10
println!("{}", terapkan(tambah_satu, 5)); // 6
// With closures
let faktor = 3;
println!("{}", terapkan_closure(|x| x * faktor, 5)); // 15 — closure captures faktor
// Built-in functions as function pointers
let angka = vec![1, -2, 3, -4, 5];
let positif: Vec<i32> = angka.iter()
.copied()
.filter(|x| x.is_positive())
.collect();
println!("{:?}", positif); // [1, 3, 5]
}
Functions Returning Closures #
Returning a closure from a function requires Box<dyn Fn...> because a closure’s size isn’t known at compile time:
// Return a closure that multiplies by a given factor
fn buat_pengali(faktor: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |x| x * faktor)
}
// Return a closure based on a condition
fn pilih_operasi(operasi: &str) -> Box<dyn Fn(f64, f64) -> f64> {
match operasi {
"tambah" => Box::new(|a, b| a + b),
"kurang" => Box::new(|a, b| a - b),
"kali" => Box::new(|a, b| a * b),
_ => Box::new(|a, b| if b != 0.0 { a / b } else { f64::NAN }),
}
}
fn main() {
let kali_tiga = buat_pengali(3);
let kali_lima = buat_pengali(5);
println!("{}", kali_tiga(4)); // 12
println!("{}", kali_lima(4)); // 20
let tambah = pilih_operasi("tambah");
let kali = pilih_operasi("kali");
println!("{}", tambah(10.0, 5.0)); // 15
println!("{}", kali(10.0, 5.0)); // 50
}
Recursive Functions #
Rust supports recursion, but note: every recursive call uses a new stack frame. For deep recursion (millions of levels), use iteration or manual tail call optimization.
// Recursive factorial — simple but risks stack overflow for large n
fn faktorial(n: u64) -> u64 {
match n {
0 | 1 => 1,
n => n * faktorial(n - 1),
}
}
// Naive Fibonacci — very slow due to lots of recomputation
fn fib_naif(n: u32) -> u64 {
match n {
0 => 0,
1 => 1,
n => fib_naif(n - 1) + fib_naif(n - 2),
}
}
// Fibonacci with an accumulator — more efficient (tail-call like)
fn fib_akumulator(n: u32, a: u64, b: u64) -> u64 {
match n {
0 => a,
_ => fib_akumulator(n - 1, b, a + b),
}
}
fn fib(n: u32) -> u64 {
fib_akumulator(n, 0, 1)
}
fn main() {
println!("10! = {}", faktorial(10)); // 3628800
println!("20! = {}", faktorial(20)); // 2432902008176640000
println!("fib(10) = {}", fib(10)); // 55
println!("fib(50) = {}", fib(50)); // 12586269025 — fast
// fib_naif(50) would be very slow
}
Diverging Functions #
A function that never returns to its caller has the return type ! (the never type). This isn’t void — it means the function diverges: it ends with panic!, an infinite loop, or exits the process.
// A function that always panics
fn error_kritis(pesan: &str) -> ! {
eprintln!("CRITICAL ERROR: {}", pesan);
panic!("{}", pesan);
}
// A function that loops forever
fn server_loop() -> ! {
loop {
// process connections...
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn main() {
let input: Option<i32> = None;
// The never type (!) is compatible with any type
// this is valid because ! can "become" whatever type is needed
let nilai = match input {
Some(n) => n,
None => error_kritis("Input must not be empty"), // return type !
};
println!("{}", nilai);
}
Nested Functions #
Rust allows defining functions inside functions. Nested functions don’t capture variables from the outer scope (unlike closures), but they’re useful for breaking up local logic that isn’t relevant outside:
fn proses_data(data: &[i32]) -> String {
// Local helper function — only relevant inside proses_data
fn format_item(n: i32) -> String {
if n < 0 {
format!("({})", n.abs())
} else {
n.to_string()
}
}
fn adalah_prima(n: i32) -> bool {
if n < 2 { return false; }
(2..=(n as f64).sqrt() as i32).all(|i| n % i != 0)
}
data.iter()
.map(|&n| {
let label = if adalah_prima(n) { "*" } else { " " };
format!("{}{}", label, format_item(n))
})
.collect::<Vec<_>>()
.join(", ")
}
fn main() {
let data = [2, 3, -4, 5, 6, 7, -8, 11];
println!("{}", proses_data(&data));
// *2, *3, (4), *5, 6, *7, (8), *11
}
Summary #
- Parameters always need type annotations — no type inference for function parameters, unlike
letvariables.- Implicit return from the last expression — an expression without a semicolon at the end of a function is the return value. A semicolon turns it into a statement that returns
().- Explicit
returnis only for early returns — usereturnonly to exit early from the middle of a function, not on the last line.- Choose the passing mode based on need —
&Tfor read-only,&mut Tfor modification,T(move) for functions that need ownership.&stris more flexible than&Stringfor string parameters.- Closures capture their environment — automatically choosing borrow or move based on usage. Use
movefor closures that need to outlive their original scope (for example, in threads).Fn/FnMut/FnOnce— the three closure traits that determine how many times a closure can be called.Fnis the most flexible,FnOncethe most restrictive.- Function pointer
fnvs trait boundFn— use a trait bound to accept closures and regular functions alike; usefnif you only need regular functions.- Functions returning closures need
Box<dyn Fn...>— because a closure’s size isn’t known at compile time.- Diverging functions
-> !— functions that never return. The!type is compatible with all types, useful inmatcharms that always panic or loop forever.- Nested functions for local logic — unlike closures, they don’t capture outer-scope variables; useful for breaking up complex logic without exposing helpers to a wider scope.