Functions

Designing Small, Composable Functions

Writing small, single-purpose functions with clear input and output types is the foundation of maintainable Rust programs. Because rustc aggressively inlines small functions across call sites, breaking a complex routine into focused helper functions carries zero runtime overhead while making each step easier to test, read, and reason about with the borrow checker.

Core Principles of Composable Function Design

Principle Guideline Why It Matters in Rust
1. Single Responsibility Each function should do one well-defined transformation or side effect Makes function names accurate and unit tests trivial to write
2. Separate Pure Logic from I/O Keep parsing, validation, and calculation separate from println!, file I/O, or network calls Pure functions (&str -> Result<Data, Error>) can be tested without mocking files or stdin
3. Accept the Most General Borrowed Type Accept &str instead of &String, and &[T] instead of &Vec<T> or &[T; N] Allows callers to pass literals, stack arrays, vectors, or sub-slices without cloning
4. Return Rich Types (Option, Result, Tuples/Structs) Encode fallibility and edge cases in the return type rather than panicking or using sentinel values like -1 Allows callers to compose functions using ?, .map(), and .and_then()
5. Prefer Immutable Inputs -> Owned Outputs for Transforms Take shared references (&T) and return a computed value (U) unless in-place mutation (&mut T) is needed for performance Avoids overlapping mutable borrows and makes data flow explicit

Parameter Type Selection: Generalizing Inputs

Choosing borrowed slice types in function parameters maximizes how many caller types can plug into your function via automatic deref coercion:

Caller Types:                             Function Parameter:
  "literal"        (&'static str) ──┐
  &owned_string    (&String)      ──┼──>  fn process_text(input: &str)
  &owned_string[..] (&str)        ──┘

  &[1, 2, 3]       (&[i32; 3])    ──┐
  &vec_numbers     (&Vec<i32>)    ──┼──>  fn analyze_numbers(data: &[i32])
  &vec_numbers[1..] (&[i32])      ──┘
fn count_vowels(text: &str) -> usize {
    text.chars()
        .filter(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u'))
        .count()
}

fn average(values: &[f64]) -> Option<f64> {
    if values.is_empty() {
        None
    } else {
        Some(values.iter().sum::<f64>() / (values.len() as f64))
    }
}

fn main() {
    let literal = "Rust Programming";
    let owned = String::from("Composable Functions");

    // Both `&str` and `&String` work seamlessly with `count_vowels(&str)`:
    println!("Vowels in literal: {}", count_vowels(literal));
    println!("Vowels in owned:   {}", count_vowels(&owned));

    // Both stack arrays and heap vectors work with `average(&[f64])`:
    let stack_arr = [10.0, 20.0, 30.0];
    let heap_vec = vec![5.0, 15.0];
    println!("Array avg:  {:?}", average(&stack_arr));
    println!("Vector avg: {:?}", average(&heap_vec));
    println!("Empty avg:  {:?}", average(&[]));
}
Vowels in literal: 4
Vowels in owned:   7
Array avg:  Some(20.0)
Vector avg: Some(10.0)
Empty avg:  None

Decomposing a Monolithic Function into a Pipeline

Consider parsing a list of "item:price" lines, filtering out comments and invalid entries, applying a tax rate, and summing the total. Instead of writing one long loop that mixes string splitting, parsing, tax math, and printing, decompose each step into a small, composable function:

/// Extracts `(name, price_cents)` from a single `"name: cents"` line.
fn parse_item_line(line: &str) -> Option<(&str, u64)> {
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') {
        return None;
    }

    let (name, price_str) = trimmed.split_once(':')?;
    let price_cents = price_str.trim().parse::<u64>().ok()?;
    Some((name.trim(), price_cents))
}

/// Applies an integer percentage tax (e.g., `8` for 8%) to an amount in cents.
fn add_tax_cents(cents: u64, tax_percent: u64) -> u64 {
    cents + (cents * tax_percent) / 100
}

/// Computes the taxed total across all valid lines in `raw_catalog`.
fn calculate_order_total(raw_catalog: &[&str], tax_percent: u64) -> u64 {
    raw_catalog
        .iter()
        .filter_map(|&line| parse_item_line(line))
        .map(|(_name, cents)| add_tax_cents(cents, tax_percent))
        .sum()
}

fn main() {
    let lines = [
        "# Shopping List",
        "Notebook: 1000",
        "Pen: 200",
        "InvalidLineWithoutColon",
        "   ",
        "Backpack: 3000",
    ];

    let total_cents = calculate_order_total(&lines, 10);
    println!("Total with 10% tax: ${}.{:02}", total_cents / 100, total_cents % 100);
}
Total with 10% tax: $46.20

Look at how clean each piece is:

  • parse_item_line handles a single line and uses ? on Option to bail out cleanly on comments, missing colons, or non-numeric prices.
  • add_tax_cents is a 1-line pure arithmetic function.
  • calculate_order_total chains parse_item_line and add_tax_cents with .filter_map() and .map() without a single mut variable.

How Small Functions Help the Borrow Checker

Breaking work into small functions also makes ownership and borrowing rules easier to satisfy:

  • Any temporary borrows (&T or &mut T) created inside a helper function automatically end as soon as the helper function returns.
  • Passing specific struct fields (update_score(&mut player.score, &player.stats)) into a helper function makes it obvious to both the reader and the compiler which fields are borrowed mutably and which are borrowed immutably.