Skip to content

04 - Functions

What this session is

About 45 minutes. You'll learn to define functions, the distinction between expressions and statements (Rust takes this seriously), and the basics of how to return values without writing return.

The shape

fn name(parameters) -> ReturnType {
    // body
}

Concrete:

fn double(x: i32) -> i32 {
    x * 2
}

fn main() {
    let r = double(5);
    println!("{r}");      // 10
}

Note: - Parameters need explicit types: x: i32. - Return type follows ->. For void-equivalent (no return), omit the arrow (or write -> () - () is the "unit" type). - The function body is x * 2 (no semicolon, no return). The last expression of a block is its value.

Expressions vs statements

This is the bit that takes adjusting.

  • Statements perform an action and don't return a value. let x = 5; is a statement.
  • Expressions produce a value. 5, 5 + 5, f(x), if cond { a } else { b } are all expressions.

In Rust, a block ({ ... }) is an expression. Its value is whatever the last expression inside is, as long as that expression doesn't end with a semicolon.

let x = {
    let a = 2;
    let b = 3;
    a + b              // no semicolon - this is the block's value
};
println!("{x}");       // 5

If you put a semicolon:

let x = {
    let a = 2;
    let b = 3;
    a + b;             // semicolon - makes it a statement; block evaluates to ()
};

x is now () (unit), not 5. The compiler will probably complain elsewhere.

For functions: omit the semicolon on the last expression to return its value.

fn double(x: i32) -> i32 {
    x * 2              // no semicolon - value of the function
}

You can use return explicitly:

fn double(x: i32) -> i32 {
    return x * 2;
}

Both work. The implicit form is more idiomatic. Use return for early returns:

fn safe_divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        return 0;     // early return
    }
    a / b             // normal return - no semicolon
}

Multiple parameters

fn add(a: i32, b: i32) -> i32 {
    a + b
}

Each parameter gets its own type annotation.

No defaults; no named arguments

Rust doesn't have default parameter values or named arguments. To get default-like behavior, write multiple functions or accept an Option/struct:

fn greet(name: &str) -> String {
    format!("Hello, {name}")
}

fn greet_with(name: &str, greeting: &str) -> String {
    format!("{greeting}, {name}")
}

(There are libraries that simulate named args via builder patterns - for now, multiple functions is fine.)

Returning nothing

Functions that return nothing have no ->:

fn say_hi(name: &str) {
    println!("Hi, {name}");
}

Equivalently: -> () is the explicit form. () ("unit") is Rust's "nothing" - a type with exactly one value, also written ().

Returning multiple values: tuples

Like Python, Rust returns tuples for "several values at once":

fn divide(a: i32, b: i32) -> (i32, i32) {
    (a / b, a % b)
}

fn main() {
    let (q, r) = divide(17, 5);
    println!("quotient {q}, remainder {r}");
}
  • (i32, i32) is the return type - a 2-tuple of integers.
  • (a / b, a % b) constructs the tuple.
  • let (q, r) = ... destructures it.

Tuples are anonymous - for a return type with named fields, use a struct (page 05).

Functions calling functions

fn square(x: i32) -> i32 { x * x }

fn sum_of_squares(a: i32, b: i32) -> i32 {
    square(a) + square(b)
}

fn main() {
    println!("{}", sum_of_squares(3, 4));   // 25
}

Variable scope

Variables exist only inside the block ({ }) they were declared in:

fn double(x: i32) -> i32 {
    let result = x * 2;
    result
}

fn main() {
    println!("{result}");   // ERROR - `result` doesn't exist out here
}

Where to put functions

In a single-file program, all functions live at the top level of main.rs. Order doesn't matter - Rust scans the whole file, so functions can call each other regardless of which is defined first.

fn main() {
    println!("{}", helper(5));    // OK even though helper is defined below
}

fn helper(x: i32) -> i32 { x + 1 }

In multi-file projects, you'll organize into modules (page 11).

Exercise

In your project, replace src/main.rs:

  1. Write fn is_even(n: i32) -> bool. Use %.
  2. From main, print is_even(4) and is_even(7). Expected: true, false.
  3. Write fn count_evens(max: i32) -> i32 that counts even numbers in 1..=max. Loop, call is_even.
  4. Print count_evens(10)5; count_evens(100)50.
  5. Stretch: write fn quotient_and_remainder(a: i32, b: i32) -> (i32, i32). From main, destructure: let (q, r) = quotient_and_remainder(17, 5); and print both.

What you might wonder

"Why do I have to think about expressions vs statements?" Rust uses "everything is an expression" pervasively - if, match, blocks. The semicolon-or-no-semicolon distinction is small but consistent. After a week you stop thinking about it.

"Can I omit the return type?" For functions with no return value, yes (fn foo() { ... }). For functions that return something, no - you must declare it explicitly.

"Can a function call itself?" Yes (recursion). Common for tree traversal etc. The compiler doesn't currently optimize tail calls; very deep recursion can stack-overflow.

"Can I have two functions with the same name?" Not in the same scope. Use different names or methods on different types (page 05).

Done

  • Define functions with parameters and return types.
  • Distinguish expressions from statements.
  • Return implicitly (no semicolon) or with return.
  • Return multiple values via tuples; destructure with let (a, b) = ....

Next page: structs and enums - Rust's way to make your own types.

Next: Structs and enums →

Comments