Rust Functions

Alberto Basalo
5 min readMar 24, 2024

--

Functions are blocks of code that perform a specific task. In Rust, functions are defined with the fn keyword. So far, it seems easy and obvious. But if you’re coming from JavaScript, Java, C#, or TypeScript, Rust has some unfamiliar features that need a bit of explanation.

Prerequisites

To follow this tutorial, you need to have a basic knowledge of Rust. If not, I recommend that you read my previous tutorials.

Now, let’s dive into functions. I will use the Caesar cipher program, which reads the content of a file, encrypts it, and saves it to another file, as an example. Here’s the link to the complete code for the impatient dev in you.

Arguments

Simple Arguments

Before we begin, let’s start with the absence of arguments. Because, of course, a function can have no arguments. In this case, the argument list is empty.

fn print_instructions() {
println!("🔑 Encrypt and decrypt files using the Caesar cipher.");
println!("📘 The program reads the file content and encrypts it.");
println!("📘 The key is a string used to shift the characters.");
println!("📘 Requires two CLI arguments: the file name and the key.");
println!("🚀 Example: cargo run example.txt key");
}

However, it’s more common for a function to have one or more arguments. In Rust, arguments are defined between parentheses, consisting of a name and a type.

fn print_end(start_time: std::time::Instant) {
let duration = start_time.elapsed();
let duration_ms = get_milliseconds(duration);
println!("🦀 Program completed in: {:?} ms", duration_ms);
}

Ownership and Borrowing

Now, let’s look at a more interesting function. For now, don’t worry about its content; we’ll focus on the arguments. The next function, called read_file, receives an argument of type &String.

But what is that & symbol? To understand it, we need to talk about ownership and see this function in a broader context.

fn read_file(file_name: &String) -> String {
let content: Result<String, std::io::Error> = fs::read_file(file_name);
match content {
Ok(content) => return content,
Err(error) => {
eprintln!("💣 Error reading file: {}", error);
std::process::exit(1)
}
}
}

In Rust, variables have ownership. When you pass a variable to a function, you can transfer ownership of the variable to the function. This means that the function can modify or destroy the variable. But the craziest thing is that you lose access to it. Something we don’t want in this case.

We’ll dedicate an entire tutorial to ownership, but for now, it’s enough to know that if you don’t want the function to modify the variable, you must pass a reference to the variable. And that’s what the & symbol does.

Returning Values

Now, let’s move on to a seemingly more straightforward topic: returning values. Which also has its peculiarities in Rust. For starters, a function may not return anything, as in the case of the print_instructions function we saw earlier.

If you return a value, you must indicate the data type you’re going to return. For Rust, the data type is indicated with an arrow -> followed by the data type. For example, the get_milliseconds function returns a value of type u64.

fn get_milliseconds(duration: std::time::Duration) -> u64 {
return duration.as_secs() * 1000 + duration.subsec_millis() as u64;
}

Note! The last expression of a function is considered the return value. So it’s not necessary to use the return keyword or the final; finalizer. It's common to come across functions that don't use an explicit return.

fn get_milliseconds(duration: std::time::Duration) -> u64 {
duration.as_secs() * 1000 + duration.subsec_millis() as u64
}

Optional Return

Sometimes, a function may not return a value, for example, if it fails or finds nothing useful. In these cases, you can return a special value called Option, which is a very particular and intelligent use of enums that we'll see in a later tutorial. For now, it's enough to know that Option is an enumeration that can have two values: Some or None.

fn get_base_code_option(the_char: char) -> Option<u8> {
if the_char.is_ascii_alphabetic() == false {
return None;
}
let base_case_code: u8 = if the_char.is_ascii_lowercase() {
LOWER_CASE_BASE
} else {
UPPER_CASE_BASE
};
Some(base_case_code)
}

This function returns the ASCII code of the first lowercase or uppercase letter. But if the letter is not alphabetic, it returns None.

Receiving an Option can be a bit awkward until you get used to dealing with the match instruction (which is somehow similar to aswitch-case). Although there are other ways to do it, the one I'm showing you seems quite elegant and, above all, safe.

let base_case_code: u8 = match get_base_code_option(clean_char) {
None => return clean_char,
Some(base_case_code) => base_case_code,
};

Error Return

A special case of optional return is when one of the options is an error. In Rust, errors are handled using the Result type. You can think of it as an enumeration with two possible values: Ok or Err.

fn read_args() -> Result<CliArgs, std::io::Error> {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
print_instructions();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"⚠️ - Please provide the file name and key as arguments.",
));
}
let cli_args = CliArgs {
clean_file_name: args[1].clone(),
key_string: args[2].clone(),
};
return Ok(cli_args);
}

The read_args function checks whether two arguments have been passed. If not, an error is printed and returned. But, if everything goes well, it returns a value of type CliArgs instead.

Processing the response is similar to what we’ve seen with Option. In this case, we use match again to handle the two possible return values.

let base_case_code: u8 = match get_base_code_option(clean_char) {
None => return clean_char,
Some(base_case_code) => base_case_code,
};

Comments

Finally, I want to show you something I find curious: in Rust, comments are written with ///, and they support Markdown format. They're not very pleasant to look at in the editor, but they're very useful for documenting your code and generating documentation automatically.

/// Encrypts a character using the **Caesar cipher**.
///
/// This function takes a clean character and a shift value as input.
/// It applies the Caesar cipher to the character using the shift value.
/// If the character is not an ASCII alphabetic character, then it is left unchanged.
/// ### Arguments
/// * `clean_char` - A character that holds the clean text to be encrypted.
/// * `shift` - A u8 that holds the shift value.
/// ### Returns
/// * `char` - The encrypted character.
/// ### Example
/// ```
/// let encrypted = caesar_cipher_char('a', 3);
/// ```
fn caesar_cipher_char(clean_char: char, shift: u8) -> char {
let base_case_code: u8 = match get_base_code_option(clean_char) {
None => return clean_char,
Some(base_case_code) => base_case_code,
};
let clean_code: u8 = clean_char as u8;
let ciphered_code: u8 = ((clean_code - base_case_code + shift) % CASE_LENGTH) + base_case_code;
let ciphered_char: char = ciphered_code as char;
return ciphered_char;
}

Summary

In this tutorial, we’ve seen how to use functions in Rust with their arguments and how to handle return values. You also have examples of how to return optional values and errors.

If you want to see the complete Caesar cipher program, you can find it in my GitHub repository.

In future tutorials, we’ll delve into the concepts of ownership and borrowing and continue exploring more use cases of enums and other advanced Rust concepts. See you next time!

--

--

Alberto Basalo

Advisor and instructor for developers. Keeping on top of modern technologies while applying proven patterns learned over the last 25 years. Angular, Node, Tests