Functions

Learn how to define and call functions in Rust, work with parameters and return values, and understand the difference between statements and expressions.

Functions organize logic into named, reusable blocks. In Rust, every program begins with a special function called main, but you can define as many others as you need. Rust’s approach to functions is shaped by its expression‑oriented nature — almost everything can produce a value, which changes how you write return logic, handle conditions, and structure code.

Defining a Function

A function starts with the fn keyword, followed by the name and a pair of parentheses. If the function accepts input, you list each parameter with its name and type inside the parentheses. The body goes inside curly braces.

fn greet() {
    println!("Hello from a function!");
}

Calling greet() from main prints the message. The function takes no parameters and returns nothing — which means it returns the unit type (). Rust does not require you to write -> () explicitly when there is no return value; the compiler fills that in.

Rust uses snake_case for function and variable names. That means all lowercase letters with underscores between words. The compiler will warn you if you use a different style.

Naming Conventions:

Stick to snake_case for function names — for example calculate_total, not calculateTotal. This keeps your code consistent with the standard library and most Rust projects.

Function Parameters

Parameters are the input values a function works with. In Rust, every parameter must have a declared type. The compiler uses these annotations to verify correctness at the call site, without any guesswork.

fn add_numbers(x: i32, y: i32) {
    let sum = x + y;
    println!("{x} + {y} = {sum}");
}
fn main() {
    add_numbers(10, 15);
}

When you call add_numbers(10, 15), the integers are bound to x and y inside the function. Parameters are immutable by default — you cannot reassign x inside the body unless you mark it as mut. That prevents accidental changes and makes the function’s intent clearer.

Ownership and Parameter Passing:

Passing a value to a function can move ownership, depending on the type. For types that do not implement the Copy trait (like String), the original variable becomes invalid after the call, unless you pass a reference with &. Ownership rules are covered in depth in the next chapter, but keep this in mind as you write your first functions.

Rust does not support default parameter values. You can simulate the behavior by accepting an Option and handling both cases inside the function body, but a separate function or a builder pattern is often clearer.

Statements and Expressions

Understanding the difference between statements and expressions unlocks a lot of Rust’s design. The rule is simple:

  • Statements perform an action and do not return a value.
  • Expressions evaluate to a value.

A let binding is a statement. It creates a variable and binds a value to it, but the let statement itself does not produce a value you can use elsewhere.

fn main() {
    let y = 6;               // statement
    // let x = (let y = 6);  // this would be an error
}

The commented line fails because let y = 6 is a statement — it has no value to assign to x. In contrast, a block {} is an expression if its last line does not end with a semicolon.

fn main() {
    let x = {
        let a = 3;
        a + 1           // no semicolon -> expression
    };
    println!("x is {x}"); // prints "x is 4"
}

Inside the block, a + 1 evaluates to 4, and that value becomes the result of the whole block. The let x = ... statement captures that result.

This distinction flows directly into how functions return values. A function body is itself a block. Whatever the body evaluates to becomes the return value — as long as the last line is an expression without a trailing semicolon.

Semicolon on the Final Expression:

If a function signature promises a return type like i32, but the final line of the body ends with a semicolon, that line becomes a statement returning (). The compiler will reject the code with a type mismatch error. This is one of the most common early surprises. Watch for semicolons at the end of functions that are supposed to return a value.

Functions with Return Values

To tell the compiler that a function produces a value, you add -> followed by the return type after the parameter list. The simplest way to return that value is to make the last expression of the function body the thing you want to hand back — with no semicolon.

fn square(n: i32) -> i32 {
    n * n
}
fn main() {
    let result = square(4);
    println!("Square is {result}"); // Square is 16
}

Here, n * n is an expression. It computes the value, and because it is the final line of square, that value becomes the function’s return value. There is no return keyword.

Explicit return is still available, and it is the right tool when you need to leave the function early.

fn validate_age(age: i32) -> Result<i32, String> {
    if age < 0 {
        return Err(String::from("Age cannot be negative"));
    }
    if age > 150 {
        return Err(String::from("Age seems unrealistic"));
    }
    Ok(age)
}

The early return statements exit immediately with the Err variant. The final line Ok(age) is still an expression — no semicolon, so it becomes the normal return value when none of the guard clauses triggered.

Early Return Requires `return`:

You cannot rely on an expression in the middle of a function body to exit the function early. Only the final expression (or an explicit return) hands a value back. If you need to bail out of a function before the end, use return.

Functions that don’t need to send back a value can omit the return type entirely. They implicitly return (), pronounced “unit.” The println! macro is a common example — it prints text but returns ().

fn log_event(name: &str) {
    println!("Event: {name}");
    // implicitly returns ()
}

Putting Functions to Work

A function often combines parameters, conditional logic, and a return value. Because if is also an expression, you can build clean, single‑assignment patterns.

fn categorize_temperature(celsius: f64) -> &'static str {
    if celsius < 0.0 {
        "Freezing"
    } else if celsius < 20.0 {
        "Cold"
    } else if celsius < 30.0 {
        "Comfortable"
    } else {
        "Hot"
    }
}
fn main() {
    let temps = [25.5, -5.0, 35.0];
    for t in temps {
        let label = categorize_temperature(t);
        println!("{t}°C is {label}");
    }
}

Each branch of the if expression evaluates to a string literal. Because all branches produce the same type (&str), the whole if expression produces a value that becomes the return value of the function. No intermediate variable is required, though you could introduce one for readability.

Your Function Is Working Correctly:

When you run the code above, you should see output like: 25.5°C is Comfortable, -5°C is Freezing, 35°C is Hot. If you see those lines, you’ve successfully written a function with parameters, a return type, and an expression‑based body.