Back to the basics - functions

In the previous chapter, we've explored variables a bit. You've written a small program that printed several greetings with different first names. If you review the code that you've written, you will see that it is quite repetitive. It will probably resemble something like this:

fn main() {
    let mut first_name = "Marcel";
    println!("Hello, {first_name}!");

    first_name = "Tom";
    println!("Hello, {first_name}!");

    first_name = "Dick";
    println!("Hello, {first_name}!");

    first_name = "Harry";
    println!("Hello, {first_name}!");
}

Functions are used to get rid of these repetitions and to make the software development more efficient.

The signature of a function is: fn [name] ([parameters]) -> [result type] {} Where:

  • [name] is a name of your choosing that describes what the function does
  • [parameters] define the input to the function (optional)
  • [result type] defines the kind of output you are returning from your function (optional)

Here are some examples of function signatures:

fn do_nothing() {}
fn say_hello(first_name: &str) {}
fn read_temperature() -> i32 {}
fn sum_numbers(x: i32, y: i32) -> i32 {}

Functions without an arrow in the signature do not return any data. Functions without any input parameters do not need any input to work.

In these examples you see that we've introduced these data types: i32 and str. i32 represents a signed 32-bit number, and str represents a string of text.

Data types

Going back to the cabinet analogy for the use of memory, these data types define the size of the drawer in the cabinet. Sometimes these drawers can be subdivided into smaller compartments; to hold small items like screws, nails, or bolts.

The smallest data type in a computer is called a bit. It can have one of two values: 1 or 0. If you were to divide a drawer into the smallest possible compartments, you could store a single bit in each compartment. This is the smallest unit of data that a computer can work with.

The smallest number that Rust can work with is an i8. This is a number that is made up of 8 bits. In terms of drawers, it is a drawer that holds 8 bit compartments. The drawer can hold a single number from -128 to 127. There is also an unsigned variant: u8. This drawer is equal in size, but by convention we can only store positive numbers in such a drawer. The smallest value is 0, the highest is 255.

If you need to store bigger numbers, you can double the size of the drawer into an i16 or u16. If that is still not big enough, you can double it again to a i32 or u32. And again: i64 and u64, and again: i128 and u128. At this time the i128 is the biggest drawer available for numeric data types. If you need even bigger numbers, you need to do some clever math by storing parts of the number in different drawers, but we'll leave that for what it is right now.

Rust also has a data type for numbers with decimal points: f32 and f64

The other data type is the str. This is a drawer that is dynamically sized when you assign a text value to it. As long as there is enough room in the cabinet, you can make this drawer very, very big.

The final data type that we'll cover now is the bool. A bool is similar to a bit in the way that it can have two states: true or false.

Rust will attempt to infer a data type whenever it can. This is also why we did not need to specify a data type for first_name in our earlier examples. Rust figured out that this must be a str type, because we assigned it a string value.

Taking vs borrowing

In the above example you see another character: &. It is used in combination with a datatype, like: &str. If you pass a value to a function, by default, that function will take the variable. It will open the drawer, take the contents of the drawer, and close it. After calling the function, the drawer is empty.

More often than not, you want to use whatever you've stored in the drawer at a later point in your program. You can do this by adding the ampersand (&) in front of your type signature. By doing this you tell the function that they can use the contents of the drawer, but that they must put it back in the drawer when done. Just like in real-life, Rust will make sure that no function can use a drawer that is already opened by another function.

Back to the functions

With that little side step out of the way, let's create a small function to help us greet several people.

fn main() {
    let mut first_name = "Marcel";
    greet(first_name);

    first_name = "Tom";
    greet(first_name);

    first_name = "Dick";
    greet(first_name);

    first_name = "Harry";
    greet(first_name);
}

fn greet(some_name: &str) {
    println!("Hello, {some_name}!");
}

We've introduced the greet function that has a single parameter: some_name of type &str, and that does not return any data.

The some_name parameter of the greet function acts as a locally declared variable with the name some_name.

I've intentionally given the input parameter of greet a different name to the first_name variable used in main to demonstrate that these names are not related. You can give the variable and the input parameter any name you want.

Exercise

Rename the first_name variable and the some_name parameter to a name of your choosing. Run the program to verify that these names have no effect on the logic of the software. You can use the Shift+F6 keyboard combination to quickly rename variables and field in RustRover.

Although we now have two functions, our program still starts at the top of the main function and finishes after the first }, i.e., the end of the main function.

In this example, we've not dramatically decreased the size of our program. We've actually added a few lines. However, the greeting logic is now all in a single place. If we want to change our greeting, we can do this by modifying a single line. For example:

fn main() {
    let mut first_name = "Marcel";
    greet(first_name);

    first_name = "Tom";
    greet(first_name);

    first_name = "Dick";
    greet(first_name);

    first_name = "Harry";
    greet(first_name);
}

fn greet(first_name: &str) {
    println!("{first_name}! I greet you.");
}

Because of the & in front of str we can re-use the first_name variable in subsequent calls to greet.

Benefits of functions

The real benefit of functions becomes clear when their complexity grows. Imagine we want to extend our greeting:

fn main() {
    let mut first_name = "Marcel";
    greet(first_name);

    first_name = "Tom";
    greet(first_name);

    first_name = "Dick";
    greet(first_name);

    first_name = "Harry";
    greet(first_name);
}

fn greet(first_name: &str) {
    println!("{first_name}! I greet you.");
    println!("Welcome to the world of Rust!");
}

Adding this single instruction to the greet function would have taken four additional lines if we hadn't used the greet function. In this way, functions help to reduce complexity.

Exercise

Add a new function that says goodbye after greeting a person.

Exercise

The name you pick for a variable or a function is completely at your discretion. This book and the examples within, are written in English. If you are a non-native English speaker, attempt to rewrite the previous example using variable and function names in your local language. Of course, you should also translate the output to your local language.