Rust Programming: The Ultimate Guide (2023)

I’ll programmatically introduce you to Rust Programming Language by creating a real-world project to enable you to learn Rust in a practical approach.

Rust Programming: The Ultimate Guide (2023)

Chapter 1: Complete Rust Overview

Rust programming language brings to the table, new and opinionated ways of doing things.

Concepts such as the Borrow Checker sound new to most developers coming from Garbage-Collected languages such as Go, Javascript, and Python.

In place of Garbage-Collection, Rust programming language allows the developer to make the choices of memory management with concepts such as Ownership and Borrowing.

The Borrow Checker, which most Rust developers would fight every now and then, ensures memory safety, a foreign concept to core C and C++.

Chapter 2: Advanced Rust Concepts

Next, we’d want to call the function that handles user input in accordance with the previously initialized variables.

We’ll momentarily leave the main function and head to the handle_input function:

Before we do that, let’s take a look at the concept of Ownership and Borrowing in Rust

Ownership

Take a look at this short snippet:

fn main() { //for brevity sake, we won’t specify this main function in subsequent snippets
	let hello = “hello”;
} // at this point, hello no longer exists.

By default, variables are block-scoped. Hello is an example of what we call a string literal: hardcoded string values.

let hello = String::from(“hello”);
let bye = hello;
println!(“{}”, hello); //the remaining arguments to the println macro are variables that would replace ‘{}’ in the string.

Running this code would throw an error(called panicking in Rust):

error[E0382]: borrow of moved value: `hello`
 --> main.rs:4:20
  |
2 |     let hello = String::from("hello");
  |         ----- move occurs because `hello` has type `std::string::String`, which does not implement the `Copy` trait
3 |     let bye = hello;
  |               ----- value moved here
4 |     println!("{}", hello);
  |                    ^^^^^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try rustc --explain E0382.

This signifies a concept called Moving.

This means in Rust, an attempt to free unused memory, since both hello and bye are pointing to the same memory address, will invalidate the variable hello.

Any attempt to call the variable would result in an error. As a systems programming language with a focus on memory safety, this is a useful feature that prevents access to a memory address after it is no longer needed.

Rust performs a form of copying, known as shallow copying. This way, both variables reference the same thing.

In another example shown below:

let hello = String::from(“hello”);
let bye = “ and bye”;
let greeting = hello + bye;
println!(“{}. Again, {}? {}”, greeting, hello, bye);

Again, as expected, this code won’t compile. bye ceases to exist the moment it is used somewhere else.

The new variable takes ownership of bye and bye ceases to exist. hello on the other hand, has no problems with this. This is because, due to the use of String::new(), the size of hello is known at compile-time, therefore, there’s no deep or shallow copying here.

Using the code above, we can solve this problem by performing explicit deep copying like so:

let hello = String::from(“hello”);
let bye = hello.copy();
println!(“{}”, hello);

This time we’d have no errors since the underlying data is copied to bye and ownership of bye isn’t performed.

Borrowing

How about functions?

How do we pass arguments into functions and still reuse the variables? Rust has some sort of pointer-like magic called Borrowing.

With it, we can pass to functions, references to variables and access underlying data, and even mutate it (as we’ll see in the handle_input function).

Let’s go on to create the handle_input function and learn Borrowing from it:

fn handle_input(var: &mut String) → i32 {
	io::stdin()
        .read_line(var)
        .expect("Failed to read line");

	var.chars()
	    .count()
	    .try_into()
	    .expect("Length of name is too large!")
}

The function accepts a funny argument type, &mut String. You’ll probably recognize it as expecting a mutable String as an argument, but it goes farther than that:

The & sign indicates that the function is expecting a reference to that data type since we’d like to mutate its value rather than take ownership of it as Rust would normally expect.

This concept is called Borrowing.

At the end of the function’s execution, control of the memory address is returned back to the initial variable.

So, we call the stdin method of the io module for input-related functionalities, read_line does the magic of passing the user input into the variable specified.

The expect method is used for error propagation:

There are cases where we’d want our function to return an error type to the calling code to handle if an error occurs while performing an operation.

The expect function (similar to unwrap ) allows us to specify a custom message for the panic (as exceptions are called in Rust).

While unwrap would instead return the error to the calling code without a custom message.

Return value (data types)

The handle_input function has a return type of i32, which is a signed 32-bit integer.

Other integer data types include i8, i16, i64, i128 for signed integers of 8, 16, 64 and 128 respectively.

Unsigned integer types are of the u prefix with variants such as u8, u16, u32, u64, and u128 with their obvious precision levels.

For floating-point values, only two types are available: f32 and f64 for 32 and 64-bit precision respectively.

Taking a look at the return value snippet:

var.chars()
	    .count()
	    .try_into()
	    .expect("Length of name is too large!")

The chars method first breaks the text given by the user into an array of individual characters, count gives its count with a type of usize, an integer type for the size of data in Rust.

The try_into function on the other hand attempts to convert usize type into i32.

You would notice that try_into automatically knows the type we need due to the function’s return type – another Rust magic.

Finally, we wrap it all with an expect for possible errors that might occur.

Back to the main function

At this point, we can then attempt to call the function and initialized our variables like so:

println!(“Enter first person name: “);
let name1_count = get_input(&mut name1);

println!(“Enter second person name: “);
let name2_count = get_input(&mut name2);

Matches

Next, we’ll need a function to compute the compatibility:

fn calculate_compatibility(name1_count: i32, name2_count: i32) → f32 {
	match name1_count.cmp(&name2_count) {
		Ordering::Less => {
			((name1_count as f32 / name2_count as f32) as f32 * 100.0) as f32
		},
		Ordering::Greater => {
			((name2_count as f32 / name1_count as f32) as f32 * 100.0) as f32
		},
		Ordering::Equal => {
			50.0
		}
	}
}

Since we’ll be doing a bit of arithmetic, the function should be returning a float type.

Next, we compare both values to see which is higher. To do that, instead of the if statement with a syntax of:

if <condition> {
	block of code
} else if <condition> {
	block of code
} else {
	block of code
}

We’ll be making use of match, since the comparison of two number in Rust yields a data type rather than a boolean:

match name1_count.cmp(&name2_count) {
	Ordering::Less => {
	},
	Ordering::Greater => {
	},
	Ordering::Equal => {
	}
}

A match statement is made up of match followed by a type, or an operation that returns a type. In the block of code following, it is arms with pathways of code to run depending on what type is expected.

With this functionality of types returned for comparison, we have to bring into scope, the types expected to be returned with the statement:

use std::cmp::Ordering;

We’re done! We’ll compile and run our program with cargo run in the code directory.

Alas! We have an error:

error[E0599]: no method named `try_into` found for type `usize` in the current scope
  --> src/main.rs:10:25
   |
10 |     var.chars().count().try_into().expect("Length of name is too large!")
   |                         ^^^^^^^^ method not found in `usize`
   |
   = help: items from traits can only be used if the trait is in scope
help: the following trait is implemented but not in scope; perhaps add a `use` for it:
   |
1  | use std::convert::TryInto;
   |

error: aborting due to previous error

For more information about this error, try `rustc --explain E0599`.
error: could not compile `love_calculator`.

Something about traits.

A not very useful example

So, what are traits? Well, traits are a way Rust says: Inheritance.

As codebase increases, we notice that some structs seem to have similar characteristics, and it is important that future structs with such characteristics should be able to perform actions these structs can do.

An example, as culled from the Rust documentation will be used. Let’s say we’re implementing Tweets like so:

struct Tweet {
	username: String,
	content: String,
	location: String,
	retweet: bool,
}

We might want to implement a summarize method that displays a short form of the tweet:

impl Tweet {
   fn summarize(&self) → String {
	if self.retweet {
		format!(“{} retweeted: {} while in {}”, self.username, self.content, self.location)
	else {
		format!(“{} tweeted: {} while in {}”, self.username, self.content, self.location)
		}
	}
}

Consider another code where we implemented a news article like so:

struct Article {
	title: String,
	author: String,
	content: String,
}

and need to implement a summarize method. While we could as well implement it as a separate method:

impl Article {
	fn summarize(&self) → String {
		format!(“{} posted an article title: {}”, self.author, self.title)
	}
}

we could standardize our code by creating a trait both structs could implement. This way, we can ensure consistency when working with different structs with similar functionality.

So our trait is implemented like so:

trait Summary {
	fn summarize(&self) → String
}

Next, we attach the trait to our structs and implement the methods since it would be enforced anyway:

// for Tweet
impl Summary for Tweet {
	fn summarize(&self) → String {
		if self.retweet {
			format!(“{} retweeted: {} while in {}”, self.username, self.content, self.location)
		else {
			format!(“{} tweeted: {} while in {}”, self.username, self.content, self.location)
		}
	}
}

// for Article

impl Summary for Article {
	fn summarize(&self) → String {
		format!(“{} posted an article title: {}”, self.author, self.title)
	}
}

Then we can call our methods:

fn main() {
	let tweet = Tweet {
		String::from(“Lord_Sarcastic”),
		String::from(“Hello, world! Bye, world!”),
		String::from(“Lagos”),
		false
	}

	let article = Article {
		String::from(“Python and Rust?”),
		String::from(“Lord_Sarcastic”),
		String::from(“Python and Rust make a good match”)
	}

	println!(“Tweet summary: ‘{}’”, tweet.summarize());
	println!(“Article summary: ‘{}’”, article.summarize());
}

A very useful example

With traits, we could also have a default implementation for methods. As an example, if we wanted a display method that made use of the summarize method under the hood, we could have a trait like so:

trait Summary {
	fn summarize(&self) → String;

	fn display(&self) → String {
		format!(“Summary: {}”, self.summarize())
	}
}

At this point, both structs can call display without having implemented methods for it, but summarize must be implemented for it to work.

Lest we go offtrack…

Why this? The thing is for us to use functionalities that involve traits, the trait has to be in scope.

We can see that the Rust compiler insists that we bring a trait into scope before it would allow type conversion. That can be fixed with a simple line:

use std::convert:TryInto;

At this point, you’ll see that there are two ways we call methods:

  1. type.method(): This is for instance methods – methods bound to a specific instance of the type.

  2. type::method(): This is for static methods – methods that are bound to the type itself and do not need self(the instance reference) as a parameter.

As mentioned earlier, Rust uses Structs and Enums for data abstractions. A struct is represented syntactically like so:

struct User {
	username: String,
	first_name: String,
}

And then methods for this struct is done with the impl keyword:

impl User {
  fn new(username: String, first_name: String) → User {
   User {
	username,
	first_name
	}
}

 fn say_name(&self) → {
   println!(“{} name is {}”, self.username, self.first_name);
 }
}

As you can guess, new is a static method, while say_name is an instance method.

Enums on the other hand are used to group similar structs under an abstraction.

For example, if we wanted to extend the User struct to contain a gender field, we could implement a gender structs like so:

struct Male {
	symbol: u8,
}

struct Female {
	symbol: u8,
}

and then group them under a gender enum like so:

enum Gender {
	Female,
	Male,
}

Our User struct can then make use of this abstraction like so:

struct User {
	username: String,
	first_name: String,
	gender: Gender,
}

Accessing structs associated with an enum is done with the :: operator.

Going back to the Ordering Code above, we can see that Ordering is an enum with struct types: Less, Greater, and Equal.

When the comparison is done with the cmp method, the method yields one of these types, we can then take advantage of the graceful match syntax to perform our calculations based on these types:

Ordering::Less => {
	((name1_count as f32 / name2_count as f32) as f32 * 100.0) as f32
},
Ordering::Greater => {
	((name2_count as f32 / name1_count as f32) as f32 * 100.0) as f32
},
Ordering::Equal => {
	50.0
}

Since we’ll be working with fractions here, it’s important to switch data types to floating values in order to maintain precision.

This is done using the as keyword. We have to cast all values into the f32 type before our code works successfully.

For the Equal arm, we also have to make 50 as a floating-point value to match the return type. It’s easy to miss.

Return value(match)

Since we have one-liner operations as a resolved value, we do not need the return keyword. The values would be returned implicitly due to the lack of a semicolon as a delimiter.

Back to main(the end?)

We can head back to the main function and call the calculate_compatibility function and display the results:

let compat_value = calculate_compatibility(name1_count, name2_count).floor();
    
println!("Love compatibility between {} and {} is {}%", name1.trim(), name2.trim(), compat_value);

The trim method removes whitespace characters at the beginning and end of the string.

Chapter 3: Building with Rust

Installing Rust is quite easy. Head to the official website  https://www.rust-lang.org/tools/install and follow the steps.

From your CLI, confirm your installation with rustc –-version and cargo –-version

The former is the standard Rust tool for compiling and executing your code.

The latter is an abstraction that provides common tools needed by developers to organize their code and other external libraries.

You also get a linter: clippy, and a nice way to run tests for your code.

We all had our own shares of love plays while young. Perhaps you remember the popular “flames” method.

Here we’ll be using a basic formula to derive compatibility percentages:

(length_of_shorter_name/length_of_longer_name) × 100

Pseudocode:

Rust way of doing things diverges from the other languages, hence the need for pseudocode to outline steps in our code.

This program calculates the love compatibility of a male and a female with their names as input:

function handle_input(arg: memory_address_to_store_input) {
	* accepts `memory address` *
	* returns nothing *

	request input from user
	trim whitespace from user input
	store cleaned input into `arg`

	returns the length of the input
}

function calculate_compatibility(name1: String, name2: String) {
	* accepts Strings for both input *
	* returns number as percentage *
	if length of name1 > length of name2:
		percentage = (length of name2 ⁄ length of name1) x 100
	else if length of name2 > length of name1:
		percentage = (length of name1 ⁄ length of name2) x 100
	else: * lengths are equal *
		percentage = 50
}

function main() {
	initialize `name1` as string
	initialize `name2` as string

	name1_count = handle_input(memory address of name1)
	name2_count = handle_input(memory address of name2)

	compatibility =  calculate_compatibility(name1_count, name2_count)

	print compatibility
}

Code

First, we’ll use cargo to initialize our project. Cargo will help manage compilation and other nitty-gritty things involved in running out of code.

To initialize, head to the terminal and type:

cargo new love_calculator

The main function

use std::io;

fn main() {
	let mut name1 = String::new();
	let mut name2 = String::new();
}

The first line of our program would involve bringing functionality from the standard library into scope.

The io module as explained in its documentation “… contains a number of common things you’ll need when doing input and output”.

As with many C-like languages, the entry point of any Rust program is the main function. Declaring a function uses the keyword fn and the name of the function.

It has a syntax like so:

fn function_name(arg1: type, arg2: type, ...argn: type) → return_type {
	function body; //statements end in a semicolon
	return value	//return values do not require a semicolon or the ‘return’ keyword. If you need a semicolon, perhaps as part of a logic, you can then use the return keyword
}

In the absence of a return type, Rust implicitly returns what is called the Unit Type, written as Ok().

The concept of Null does not exist in Rust, instead, Rust represents it with a type called None as showing the absence of a value. Its counterpart is Some which could be used to wrap any data type.

In the main function, we declare two variables, name1 and name2 using the let keyword as:

  1. Being mutable, with the mut keyword;

  2. As a String: There are two types of strings you’ll encounter in Rust, the other is a string literal.

As with many other languages, the new method is used to initialize an instance of an object or Struct in this case.

Rust makes use of Structs and Enums as opposed to objects and classes for abstracting data.

We’ll be taking a closer look at these two concepts later on.

Right now, let’s get the our actual Love Calculator Code out of the way.

Below is the code that will produce our love calculator in Rust based on the name input of the user.

use std::cmp::Ordering;
use std::convert::TryInto;
use std::io;


fn get_input(var: &mut String) -> i32 {
    io::stdin()
        .read_line(var)
        .expect("Failed to read line");

    var.chars()
        .count()
        .try_into()
        .expect("Length of name is too large!")
}

fn calculate_compatibility(name1_count: i32, name2_count: i32) -> f32 {
    match name1_count.cmp(&name2_count) {
        Ordering::Less => {
            ((name1_count as f32 / name2_count as f32) as f32 * 100.0) as f32
        },
        Ordering::Greater => {
            ((name2_count as f32 / name1_count as f32) as f32 * 100.0) as f32
        },
        Ordering::Equal => {
            50.0
        }
    }
}


fn main() {
    let mut name1 = String::new();
    let mut name2 = String::new();

    println!("Enter first person name: ");
    let name1_count = get_input(&mut name1);

    println!("Enter second person name: ");
    let name2_count = get_input(&mut name2);

    let compat_value = calculate_compatibility(name1_count, name2_count).floor();
    
    println!("Love compatibility between {} and {} is {}%", name1.trim(), name2.trim(), compat_value);
}

Now, let’s move down to explaining each of the concepts used in the code below to get a clearer understanding of how Rust handles certain programming concepts.

The final code listing looks like this:

use std::cmp::Ordering;
use std::convert::TryInto;
use std::io;


fn get_input(var: &mut String) -> i32 {
    io::stdin()
        .read_line(var)
        .expect("Failed to read line");

    var.chars()
        .count()
        .try_into()
        .expect("Length of name is too large!")
}

fn calculate_compatibility(name1_count: i32, name2_count: i32) -> f32 {
    match name1_count.cmp(&name2_count) {
        Ordering::Less => {
            ((name1_count as f32 / name2_count as f32) as f32 * 100.0) as f32
        },
        Ordering::Greater => {
            ((name2_count as f32 / name1_count as f32) as f32 * 100.0) as f32
        },
        Ordering::Equal => {
            50.0
        }
    }
}


fn main() {
    let mut name1 = String::new();
    let mut name2 = String::new();

    println!("Enter first person name: ");
    let name1_count = get_input(&mut name1);

    println!("Enter second person name: ");
    let name2_count = get_input(&mut name2);

    let compat_value = calculate_compatibility(name1_count, name2_count).floor();
    
    println!("Love compatibility between {} and {} is {}%", name1.trim(), name2.trim(), compat_value);
}

The final code can be gotten here, a repo of mine dedicated to learning Rust and its paradigms.

More Resources

  1. Official Documentation

  2. Introduction to Rust

  3. RUST Beginners Tips & Tricks Guide 2020

  4. Learing Rust

  5. Ultimate Rust Crash Course is among the best courses for Rust programming language. The instructor explaining complex terms with ease and aid the understanding of the language.

  6. Learn Rust by Building Real Applications takes you to build real-life applications using the Rust programming language. If you’re looking at getting your hands duty with projects, this course is definitely for you.

Conclusion: Rust

Despite its fame as a systems language, we’ve succeeded in using Rust for trivial tasks such as building a Calculator.

Rust programming language is used in other domains besides love calculators, such as servers, compilers, web application back-end, WASM(web assembly), and anything you’d write with a programming language.

If you want to dive into Rust programming language, as we didn’t touch many important concepts, you’d want to check out the official documentation.

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board

Backend Tips, Every week

Backend Tips, Every week