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.