How to Properly Learn Rust Programming

2023-08-06

The goal, in this blog post, will be to help beginner Rust programmers overcome the notion that Rust is a difficult language.

First and foremost, I will advocate for the Rust book from Brown University over the regular one. Here: https://rust-book.cs.brown.edu/ (it requires you to scroll all the way down and accept to participate).

It provides a more thorough explanation of Rust and includes simple quizzes to test your new gained knowledge. The original book does not contain quizzes and so many people believe they understand Rust but are completely mistaken.

Alongside with the book, you'll want to test the examples using either a local programming environment or the online environment https://play.rust-lang.org/

How to set up a local workspace (skip if you prefer the online Rust Playground)

I'm assuming Rust is already installed on your system.

Create a new directory

mkdir project && cd project

Manually create a Cargo.toml for the workspace

vim Cargo.toml
[workspace]

members = [
	"app",
	"applib",
]

Initialize the binary directory and the library directory

cargo init --bin app && cargo init --lib applib

Make applib available inside of app by editing app/Cargo.toml

[package]
name = "app"
version = "0.1.0"
edition = "2021"

[dependencies]
#add this
applib = {path = "../applib"}

Now applib's functions can be imported into the app binary. Edit app/src/main.rs

use applib;

fn main() {
   // applib::add() is located in applib/src/lib.rs
   println!("100 + 100 = {}", applib::add(100, 100)); 
}

and run it:

cargo run --bin app

# 200 

Memory safety

I believe Rust's memory safe idiosyncrasies are what intimidate most people from this language. However, this is what makes it a safe language that doesn't require a garbage collector. It's essential to master this part of the language to write memory safe code.

The good news is that even if you write unsafe code it won't compile.

Ownership

Since there exists no garbage collector, owned variables are destructed once they go out of scope. Essentially, once a function or block expression returns. Unless the variable is returned or the variable was passed by borrowing (also known as pass by reference).

The following variable will be immutable for the entire duration of the program.

fn main() {
    let num = 14;
    println!("{}", num); // prints 14
}

note: notice how num's type is implicitly assigned; a const variable would require explicitly assigning the type as so:

const NUM: i32 = 10;

In order to make it mutable we must add the keyword mut:

fn main() {
    let mut num = 14;
    num = 100;
    println!("{}", num); // prints 100
}

Suppose we pass this variable into a function

fn plusOne(num: i32) {
    println!("{}", num + 1); // prints 101
} 

fn main() {
   let num = 14; 
   plusOne(num);
   println!("{}", num); // prints 100
}

Notice how the code was able to call println! on num after calling the function plusOne.

Normally Rust would not compile this program because any variable passed into a function (without an ampersand &) would destroy the variable.

However, Rust primitives such as u64 implement the Copy trait. The function plusOne implicitly received a Copy of the variable num and thus we did not transfer ownership.

Let's see how Rust transfers ownership of a struct that doesn't implement Copy

struct Person{
    name: String
}

fn getName(pers: Person) {
    println!("name is {}", pers.name); // drops the variable
}

fn main() {
    let Carl = Person{name: String::from("Carl")};
    getName(Carl); // prints name is Carl
    // but we can no longer user Carl as it was dropped 
    // println!("{}", Carl.name); would not work here as it did with num above
}

In order to use Carl, after calling getName, we'd be required to pass it as a reference using &

... 

fn getName(pers: &Person) {
    println!("name is {}", pers.name); // does not drop the variable
}

fn main() {
    let Carl = Person{name: String::from("Carl")};
    // placing an & before the variable passes it as borrowed
    getName(&Carl); // prints name is Carl
    println!("{}", Carl.name); // prints Carl 
}

We could also transfer ownership of Carl to another variable just as we could into a function.

...

fn main() {
    let Carl = Person{name: String::from("Carl")};            
    // move ownership
    let Carl2 = Carl;

    // Carl is no longer available 
    // Carl2 is available
    println!("{}", Carl2.name); // prints Carl
}

A variable can also be converted to mutable when moving it

...

fn main() {
    let Carl = Person{name: String::from("Carl")};            
    // move to mutable ownership
    let mut Carl2 = Carl;
    Carl2.name = "Carl2".to_string();

    println!("{}", Carl2.name); // prints Carl2
}

Alternatively, a function can take a mutable borrow and change the value without deleting the variable.

...

// changes the borrowed variable without dropping it 
fn changeName(pers: &mut Person) {
    pers.name = "Carl2".to_string();
}

fn main() {
    let Carl = Person{name: String::from("Carl")};            
    let mut Carl2 = Carl;

    changeName(&mut Carl2);
    println!("{}", Carl2.name); // prints Carl2
}

However, if the variable is a primitive then ownership is not transferred.

fn main() {
    let a = 10;
    // a is cloned and thus is not dropped
    let b = a; 

    println!("{a} and {b} are clones"); // prints 10 and 10
}

While a variable is borrowed mutably it cannot also be borrowed immutably. Only when the variable is no longer referenced by the borrower can it be again borrowed.

struct Person {
    name: String
}

fn main() {
    let mut Carl = Person{name: "Carl".to_string()}; 
    // borrow Carl mutably
    let borrowCarlMutably = &mut Carl;
    // Carl's name is indirectly changed to Carl2
    borrowCarlMutably.name = "Carl2".to_string();
    
    // Carl cannot be assigned to an immutable variable as so:
    // let c = &Carl;
    println!("{}", borrowCarlMutably.name); // prints Carl2

    // Carl can now again be borrowed immutably because 
    // borrowCarlMutably is no longer referenced

    let borrowCarlImmutably = &Carl;
    println!("{}", borrowCarlImmutably.name); // prints Carl2

    // Carl is still the owner as we only borrowed it above 
    // Now that the mutable borrow is dropped we can use it again
    println!("{}", Carl.name); // prints Carl2
}

Variables stored as an Option type will be dropped in a match statement unless the unpacked variable is prefixed with ref.

fn main(){
    let name = Some(String::from("Carl"));
    match name {
        // notice the ref keyword
        // Using Some(n) .. will not compile 
        Some(ref n) => println!("Hello {}", n),
        _ => println!("no value"),
    }
    // if ref is not added, this would cause the program to not compile 
    // since name would have been dropped in the match statement
    println!("Hello again {}", name.unwrap());
}

Lifetimes

Rust also requires that a borrowed variable's data have a lifetime. Simply because you wouldn't want your borrowed variable to be dropped before you're done using it.

In fact the mutable variable we declared in the code right above was implementing lifetimes.

This code will NOT compile because the lifetime of borrowCarlMutably ends when we use Carl.

...

fn main() {
    let mut Carl = Person{name: "Carl".to_string()}; 
    // borrow Carl mutably
    let borrowCarlMutably = &mut Carl;

    // using Carl means borrowCarlMutably can no longer be used
    // because its lifetime has gone out of scope 
    println!("{}", Carl.name); 
    
    // WRONG! 
    // this should be moved above println before using Carl 
    borrowCarlMutably.name = "john".to_string();
}

So far we haven't seen the syntax of lifetimes, even though I have passed borrowed variables into functions above.

The reason is that Rust elides (omits) them for for simple functions that don't cause the compiler to decide which to return.

struct Person {
    name: String
}

// Elided 
fn changeName(pers: &mut Person) {
    pers.name = "joe".to_string();
}

// Expanded
fn changeName<'a>(pers: &'a mut Person) {
    pers.name = "joe".to_string();
}

fn main() {
    let mut Carl = Person{name: "Carl".to_string()}; 
    changeName(&mut Carl);
    println!("{}", Carl.name);
}

However more complicated functions that have borrowed variables with different lifetimes will require explicitly telling the compiler.

Note that an apostraphe is required for a lifetime's syntax however the name can be anything. It's common convention to use different letters for different lifetimes (e.g., 'a, 'b)

struct Person {
    name: String
}

fn changeName<'a, 'b>(pers: &'a mut Person, newName: &'b str) {
    pers.name = newName.to_string();
}

fn main() {
    let mut Carl = Person{name: "Carl".to_string()}; 
    
    {
        // variables in this scope have different lifetimes 
        // than those outside of {}
        let newName = "Mario";
        changeName(&mut Carl, newName);
    }
    println!("{}", Carl.name); // prints Mario
}

Essentially, Rust is making sure that any borrowed value returned from the function will live at least as long as the lifetime of one of the inputs. The compiler can then make a decision as to whether your code is valid.

struct Person {
    name: String
}

// now we return a borrowed variable with a lifetime 
// of newName
fn changeName<'a, 'b>(pers: &'a mut Person, newName: &'b str) -> &'b str {
    pers.name = newName.to_string();
    &newName
}

fn main() {
    let mut Carl = Person{name: "Carl".to_string()}; 
    {
       let newName = "Mario";
       let _ =  changeName(&mut Carl, newName);
    }
    println!("{}", Carl.name);
}

There exists a reserved lifetime called 'static that signifies to the compiler that the variable will live for the entire lifetime of the program. The variable will be embedded into the binary.

static GLOBAL: &'static str = "global static variable";

fn main() {
    println!("{}", GLOBAL);
}

In my opinion, much of the struggle that people have wrestling with the borrow checker stems from gaps in knowledge related to ownership and lifetimes.

The quizzes in the brown.edu git book definitely help filling those gaps.

If Rust still seems confusing then I recommend A half-hour to learn Rust.