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 astr
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 thegreet
function acts as a locally declared variable with the namesome_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 thesome_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 themain
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.