6.1 Defining Functions: fn, Parameters, and Return Types
Right, let’s talk about functions. If variables are the nouns of your program, functions are the verbs. They’re the little machines you build to do things, and getting them right is 90% of what separates a messy script from a clean, maintainable application. Rust, being the opinionated friend that it is, has some strong—and frankly, brilliant—opinions on how you should build these machines.
The Basic Anatomy: fn, Parameters, and the Arrow
You declare a function with fn. It’s straightforward, no-nonsense, and it works exactly as you’d expect. Here’s the simplest useful function:
fn greet() {
println!("Hello, capable human.");
}
To call it? You already know: greet(). But a function that just prints the same thing is like a toaster that only toasts one specific brand of bread. Useful, but inflexible. So we add parameters.
fn greet(name: &str) {
println!("Hello, {}, you magnificent developer.", name);
}
Notice the type annotation. This isn’t optional. Rust will not just figure it out at runtime. This is a contract, and the compiler is the lawyer holding you to it. You must declare the type of every parameter. This feels pedantic until the third time you avoid a runtime error because of it. Now you can call greet("Alice") and feel personally welcomed.
But what if you want to get something back from your function? That’s where the return type comes in, declared with that nifty arrow ->.
fn create_greeting(name: &str) -> String {
let greeting = format!("Hello, {}, you magnificent developer.", name);
greeting
}
A crucial point here: notice I didn’t put a semicolon after greeting. In Rust, the last expression in a function is its return value. It’s an elegant, functional-style way of handling returns. Adding a semicolon would turn that expression into a statement (which returns ()), and you’d get a confusing type mismatch error. It’s the most common “gotcha” for newcomers, so always double-check your trailing expressions.
You can also use the return keyword explicitly, but it’s typically reserved for early returns. The idiomatic way is to let the expression flow.
Explicit Returns and the Unit Type ()
Sometimes you do need an early return, and for that, you use the return keyword.
fn get_username(id: u32) -> Option<String> {
if id == 0 {
// We can't look up the root user, bail early.
return None;
}
// ... some database lookup logic would go here ...
Some("Alice".to_string())
}
And what about functions that don’t return anything? You might think they’re “void”. Rust has a type for that, too: the unit type, (). It’s a tuple with zero elements, and it’s the return type of any function that doesn’t explicitly return something else. The compiler will happily infer it for you, but being explicit is better for documentation.
fn log_error(message: &str) -> () {
eprintln!("ERROR: {}", message);
// `()` is implicitly returned here
}
Parameter Passing: Borrowed, Owned, or Mutably Borrowed
This is where Rust’s functions get interesting. You don’t just pass data in; you define the relationship your function has with that data. This is all about ownership.
Take ownership: Pass by value (
String). The function takes ownership and can do whatever it wants, including destroying it. Use this when the function genuinely needs to consume the value.fn consume_string(s: String) { println!("Consumed: {}", s); } // `s` is dropped hereBorrow immutably: Pass by immutable reference (
&str,&String). The function can read the data but cannot change it. This is the default mode for most functions—it’s cheap and prevents side effects.fn calculate_length(s: &str) -> usize { s.len() } // `s` goes out of scope, but since it's a reference, nothing is dropped.Borrow mutably: Pass by mutable reference (
&mut String). The function can read and change the data. This is how you write functions with side effects without transferring ownership.fn add_excitement(s: &mut String) { s.push_str("!!!"); } fn main() { let mut message = String::from("Hello"); add_excitement(&mut message); println!("{}", message); // Prints "Hello!!!" }
Choosing the right one is key. Prefer immutable borrowing. Use mutable borrowing only when necessary. Take ownership only when the function truly needs to be the final owner. This isn’t just busywork; it’s the entire system of guarantees that prevents data races and memory errors.
Type Annotations Are Non-Negotiable
I said it before, but it bears repeating: you must write the types for function parameters and return values. The compiler is plenty smart enough to infer types inside function bodies, but at the boundaries, it demands explicit contracts. This is a feature. It makes your code self-documenting and catches a whole class of errors at compile time. If you change the type a function returns, the compiler will immediately show you every single place in your code that breaks because of it. You’ll come to love this.