Today we will cover Functions in Rust in-depth, we will look at their declaration, function parameters, function scoping, associated functions and methods, functions visibility and some best practices when working with functions.
If you missed yesterday's writeup we covered let statement pattern for pattern matching and destructing. If you feel you need to brush on the topic, check out the link for day 5 below.
What are Functions?
Functions are the building blocks of any programming language, they let us group together code that is intended to perform some actions.
Such grouped code can be intended to do a specific thing or can do multiple things at a time but it it generally a better practice to have functions do one thing at a time. It also makes it easier to refactor and read, and can prevent writing bugs too.
To declare functions in Rust, we need to use the keyword fn followed by the function's name, parameters and the return type if we need to but not mandatory.
Functions in Rust take the syntax fn function_name(parameter: type) -> return_type { body }.
fn add_values(a: i32, b: i32) -> i32 {
a + b
}The function above take parameters a and b of type integers, adds the two integers and return the sum. Whenever we have functions parameters, we must explicitly declare their types i.e (in the case above the type is i32).
Additionally we can pass owned values of references (both mutable and immutable) to function parameters depending on how they are declared on the function parameters.
Another nice feature of Rust which is in some other programming languages are function parameters, where we can pass functions as parameters to other functions, sounds fun right? Check snippet below.
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
f(x)
}In the code above we have a parameter f which accepts a function that takes an integer, performs some operation and returns an integer.
It is important to note that Rust functions return the last expression implicitly (no `return` needed unless early return). In the code above, we can rewrite it as shown below to implicitly return.
fn add_values(a: i32, b: i32) -> i32 {
return a + b ; // Same as a + b
}Also functions that don't return are called as Unit Type (`()`), which is essentially an empty tuple (remember tuples on day 5?). Functions that return unit type are common with functions with side effects such as printing values. In such cases we not to declare the return type on them.
Diverging Functions
These are special type of functions that never return, but use `-> !`, especially common when writing infinite loops or panic.
fn diverge() -> ! {
panic!("This function never returns");
}Function Scoping and Visibility
There are situations where we might need some function to perform a recurring operation, in such cases, we can write a single function and expose it publicly so we can reuse it.
By default Rust functions are private unless we declare them as public. To declare a function as public we use the keyword pub before the function declaration. Check code snippet below.
pub fn public_function() {
fn private_helper() {
println!("Helper function");
}
private_helper();
}Now the public_function visibility will be public and we can use it anywhere in the codebase.
Their are also some special kind of functions know as Closures. Closures are basically anonymous functions, defined with `|param| expression`.
Closures are powerful in that we can use them to capture variables from their environment either by value, reference, or mutable reference. Check example below.
let square = |x: i32| x * x;
println!("{}", square(5)); // Outputs 25Higher-Order Functions
High order functions are special function that can take other functions or closures as arguments. The idea of high order functions is common in functional programming patterns (e.g., `map`, `filter`).
In simple terms they let us chain functions inside other functions. Check example below.
fn map_vec(v: Vec<i32>, f: fn(i32) -> i32) -> Vec<i32> {
v.into_iter().map(|x| f(x)).collect()
}In the code above, map_vec accepts two parameters v and f (function parameter) and inside we loop through map and then inside the map we pass in the f function.
HOF are very powerful and very useful.
Now that we have seen what functions are, let us see associated function and methods.
Associated Functions and Methods
When we are working with other types especially Structs and traits (more on them later), we can have functions that are associated with such kind of types and they are what we call associated functions and methods though they are different and we will differentiate them.
Functions defined in an `impl` block are associated functions, while methods on the other hand are functions with a `self` parameter (e.g., `&self`, `&mut self`, or `self`).
Lets see their difference in code below.
struct Point {
x: i32,
y: i32,
}
impl Point {
// Associated function (no self, called on the type)
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// Method (takes &self, called on an instance)
fn distance_from_origin(&self) -> f64 {
((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
}
}Associated functions are often used for constructors (e.g., new, default) or static-like utilities while methods are tied to instance state anduse Rust's ownership model (e.g., borrowing with &self or taking ownership with self).
Both can be public (pub) or private, depending on your intent.
pub struct Point {
x: i32,
y: i32,
}
impl Point {
// Public associated function
pub fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// Public method
pub fn distance_from_origin(&self) -> f64 {
((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
}
}So far we have only seen function that accept a specific type as a parameter, there are special functions that can accept multiple type provided they implement a certain trait, such kind of function are what we call Generic functions. Check example below.
fn largest<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}In the code above, the largest function accept two parameters, a and b, which accept any type provided it implements the PartialOrd trait. See how the trait bounds have been declared but we will cover them in-depth in coming days.
Functions are everywhere in Rust and I hope we have tried to cover most of it in this article. Here are some best practices while working with them.
Functions Best Practices
- Keep functions small and focused and let them do only one single thing at a time.
- Use descriptive names (e.g., `snake_case` for functions).
Thanks for reading this until the end.
See you in the next one.