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:

TraitWhen it’s usedCan be called
FnOnly reads or captures nothingMultiple times
FnMutModifies captured variablesMultiple times (but needs mut)
FnOnceMoves captured variablesOnly 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 let variables.
  • 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 return is only for early returns — use return only to exit early from the middle of a function, not on the last line.
  • Choose the passing mode based on need&T for read-only, &mut T for modification, T (move) for functions that need ownership. &str is more flexible than &String for string parameters.
  • Closures capture their environment — automatically choosing borrow or move based on usage. Use move for 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. Fn is the most flexible, FnOnce the most restrictive.
  • Function pointer fn vs trait bound Fn — use a trait bound to accept closures and regular functions alike; use fn if 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 in match arms 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.

← Previous: Loops   Next: Struct →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact