Functions, modules & error handling
Now let's have a look at functions, modules and how to handle errors in Rust.
Functions
Ahh! Functions! They're part of the fundamental building blocks of Rust programs, they define a reusable piece of code that can take some inputs, perform some operation and produce a result.
A function is defined using the fn
keyword (fn
stands for fun, of course 😎).
// main is a function too fn main() { say_hello(); } // say_hello is a function with no inputs fn say_hello(){ println!("I'm a function"); }
While functions with no parameters and no return value can be useful, functions can also take input values defined in the form of variable_name:variable_type
as well as define an expected return type.
fn main() { let result = add_numbers(1, 1); println!("The result of the addition is {}", result); } fn add_numbers(a: i32, b: i32) -> i32 { let result = a + b; return result; }
Adding these annotations has many benefits ranging from performance optimisations and error checking at the compiler level. It's easy to over do this, so one tip: keep the types clear and unambiguous. I'll detail later what this means and how it looks like.
So far we've seen functions return only one result, but it doesn't have to be this way. A function can return multiple values, for example:
fn main() { let (result, something) = divide_numbers(6, 4); println!("The result of the division is {}", result); } fn divide_numbers(a: i32, b: i32) -> (i32, bool) { if b == 0 { return (0, false); } let result = a / b; return (result, true); }
It is also possible to create functions with optional parameters, these can be very useful but their usage introduces new concepts:
fn main() { let result = multiply_numbers(2, Some(4)); println!("The result of the first multiplication is {}", result); let second_result = multiply_numbers(2, None); println!("The result of the second multiplication is {}", second_result); } fn multiply_numbers(a: i32, b: Option<i32>) -> i32 { let result = if let Some(b) = b { a * b } else { a }; return result; }
What are Some
and Option
?
Option
is like a box that can either have something in it or be empty. Option<i32>
means that we're expecting a box that either contains an i32
or nothing (None
).
Some
is used to put a value inside that box while None
is used when there is nothing to put inside the box. In this case, writing Some(4)
means "Put 4 in the box".
By using Option
and Some
, you can write code that handles both cases, making your program more robust and less prone to errors.
Let's review our division example above by using Some
and Option
as well as what we learned in the previous chapter:
fn main() { let x = 10; let y = 0; let result = divide(x, y); match result { Some(z) => println!("Result: {}", z), None => println!("Cannot divide by zero"), } } fn divide(x: i32, y: i32) -> Option<i32> { if y == 0 { return None; } else { return Some(x / y); } }
Modules
Modules are containers in which you can group related functions and data types together. They are used to organise the code under some sort of hierarchy (kinda like Java namespaces). They can be created in single files.
To create a module, you can use the mod
keyword:
#![allow(unused)] fn main() { mod some_module { // empty module } }
You can add functions, data types and other things within the module code block and refer to them from outside of the module block.
mod some_module { // public function pub fn some_public_function() { println!("Hello World! The answer is: {}", CONSTANT); } // private function fn some_private_function() { println!("The password the account is: 🥔"); } struct SomeStruct { potato: String, } const CONSTANT: u32 = 42; } fn main() { some_module::some_public_function(); }
Modules are useful if you want to split your code up into multiple files. For example:
main.rs
mod utils; fn main() { hello::print_hello(); }
utils.rs
#![allow(unused)] fn main() { pub fn function() { println!("called `my::function()`"); } }
Pretty neat, no?
Handling errors
Programming errors, bugs and other issues are a fact of life. It's important to know how to handle errors if we want to make sure we're building reliable software. Ideally, programs shouldn't have errors but there is only so much that can be tested or accounted for in advance. Even if errors happen, we want to make sure we account for unplanned behaviour.
The Rust compiler helps a lot with this by making sure the 80% of obvious errors happen before packaging and deploying your software.
In the following example, we'll make use of a Rust type: Result
.
// Divide either returns an i32 or a Sring fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { return Err("Cannot divide by zero".to_string()) } else { return Ok(x / y) } } fn main() { // Any call to divide, needs to account for all cases defined in Result match divide(10, 2) { Ok(result) => println!("Result: {}", result), Err(error) => println!("Error: {}", error), } }
Ok
and Err
are called variants (enums
) of the Result
type.
Ok
represents a successful operation and contains the value that the operation produced. For example, Ok(42)
would represent a successful operation that produced the value 42
.
Err
represents an error and contains an error value that provides more information about the error. For example, Err("Cannot divide by zero") would represent an error that occurred because a a division by zero was requested.
Result
is defined as follows:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
which is an example of enum with two types as seen in the previous chapter.
By default, Rust forces you to handle all the cases (Ok & Err) when handling the return of a specific function. This can make the call very verbose, which is why there are some shortcuts you can use to make things more readable (and where you accept that if there's an error, it's on you).
One way to do so is to use .unwrap()
:
#![allow(unused)] fn main() { let result: Result<i32, &str> = Ok(42); let value = result.unwrap(); // value will be 42 }
What .unwrap()
does is extract the value from Ok
. If called on an Err
, the method will panic and your program fails. Use this only if you know the result is an Ok
or if you want to leave the door open for an on-call rotation.
Another way is to use ?
. What ?
does, is propagate an error up the call stack. When the ?
operator is used with a Result
type, it will return the value inside the Ok
variant if it is present, or propagate the Err
variant up the call stack.
fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { return Err("Cannot divide by zero".to_string()) // 3. Error raised } else { return Ok(x / y) } } fn calculate(x: i32, y: i32) -> Result<i32, String> { let result = divide(x, y)?; // 2. calling divide(10, 0) // 4. error returned to caller Err("Cannot divide by zero") return Ok(result * 2) // thus shortcutting this line } fn main() { match calculate(10, 0) { // 1. calling calculate(10, 0) Ok(result) => println!("Result: {}", result), Err(error) => println!("Error: {}", error), // 5. Error displayed } }
I've made the code editable, try to play with it a bit, for example by replacing ?
with .unwrap()
and see what happens.
There's a lot of things that can be discussed about error handling, we'll explore that later on on some nice examples.
For now I'd say we have many of the essentials to get productive with Rust. Next, we'll investigate some of Rust specific features as well as how to use package provided by the community to build upon.