Introduction

25 November 2021

Updated: 03 September 2023

These notes are based on working through The Rust Programming Language Book. It can also be accessed using rustup docs --book

Getting Started

Hello World

To create a hello-world program simply create a new folder with a file called main.rs which defines a main function with the following:

main.rs

1
fn main() {
2
println!("hello world!");
3
}

You can then compile your code with:

Terminal window
1
rustc main.rs

Thereafter, run it with:

Terminal window
1
./main

A few things to note on the main.rs file above:

  1. The main function is the entrypoint to the application
  2. println! is a macro (which apparently we will learn about in chapter 19)
  3. semicolons ; are required at the end of each statement

Cargo

Cargo is Rust’s built-in package manager, to create a new project with Cargo use:

Terminal window
1
cargo new rust_intro

This should create a rust project in a rust_intro directory with the following structure:

1
│ .gitignore
2
│ Cargo.toml // cargo config file
3
4
└───src
5
main.rs // program entrypoint

The Cargo.toml file looks something like this:

Cargo.toml

1
[package]
2
name = "rust_intro"
3
version = "0.1.0"
4
edition = "2021"
5
6
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7
8
[dependencies]

You can now use cargo to build and run the application:

  1. cargo build builds the application
  2. cargo run builds and runs the application
  3. cargo check checks that code compiles but does not create an output
  4. cargo fmt formats source files
  5. cargo build --release builds a release version of the application
  6. cargo doc --open opens cargo docs for all crates in the current project

A Basic Program

Basic Program

Let’s define a basic program below which takes an input from stdin, stores it in a variable, and prints it out to the stdout

src/main.rs

1
use std::io;
2
3
fn main() {
4
println!("guess the number!");
5
6
println!("please enter a number");
7
8
let mut guess = String::new();
9
10
io::stdin()
11
.read_line(&mut guess)
12
.expect("failed to read string");
13
14
println!("your guess was: {}", guess);
15
}

In the above, we can see the import statement that imports std::io:

1
use std::io

Next, the String::new() creates a new String value that’s assigned to a mutable variable named guess. We can either define mutable or immutable variables like so:

1
let a = 5; // immutable
2
let mut b = 10; // mutable

stdin().read_line is a function that takes the input from stdin and appends it to the reference given to it. Like variables, references can also be mutable or immutable, when using a reference we can do either of the following:

1
&guess // immutable
2
&mut guess // mutable

The read_line function returns an io::Result, in Rust, Result types are enumerations which are either Ok or Err. The Result type has an expect method defined which takes in the error message that will be thrown if there is an Err, otherwise it will return the Ok value

Leaving out the expect will result in a compiler warning but the program will still be able to run and compile

Adding a Dependency

To add a dependency we need to add a new line to the Cargo.toml file with the dependency name and version. We’ll need to add the rand dependency to generate a random number

Cargo.toml

1
[dependencies]
2
rand = "0.8.3"

Then, run cargo build to update dependencies and build the application. Next, you can use cargo update

Generating a Random Number

To generate a random number we’ll import rand::Rng and use it as follows:

1
let secret_number = rand::thread_rng().gen_range(1..101);

Even though we’re not using the Rng import directly, it’s required in scope for the other functions we are using. Rng is called a trait and this defines what methods/functions we can call from a crate

Parsing Input to a Number

To parse the input to a number we can use the parse on strings like so:

1
let guess: u32 = guess.trim().parse().expect("please enter a number!");

Note that we’re able to use the same guess variable as before, this is because rust allows us to shadow a previous variable declaration, this is useful when doing things like converting data types. So the code now looks like this:

1
let mut guess = String::new();
2
3
io::stdin()
4
.read_line(&mut guess)
5
.expect("failed to read string");
6
7
let guess: u32 = guess.trim().parse().expect("please enter a number!");

Matching

The match expression is used for flow-control/pattern matching and allows us to do something or return a specific value based on the branch that the code matches.

We can use the Ordering enum for comparing the value of two numbers:

1
let result = guess.cmp(&secret_number);
2
3
match result {
4
Ordering::Less => println!("guess is less than secret"),
5
Ordering::Greater => println!("guess is greater than secret"),
6
Ordering::Equal => println!("correct!"),
7
}

Looping

To loop, we can use the loop flow-control structure:

1
loop {
2
// do stuff
3
}

Lastly, we can break out of the loop using break or move to the next iteration with continue

Using the above two statements, we can update the guess definition using match and continue

1
let guess: u32 = match guess.trim().parse() {
2
Ok(num) => num,
3
Err(_) => continue,
4
};

And when we compare the guess:

1
match result {
2
Ordering::Less => println!("guess is less than secret"),
3
Ordering::Greater => println!("guess is greater than secret"),
4
Ordering::Equal => {
5
println!("correct!");
6
break;
7
}
8
}

Final Product

Combining all the stuff mentioned above, the final code should look something like this:

main.rs

1
use rand::Rng;
2
use std::cmp::Ordering;
3
use std::io;
4
5
fn main() {
6
println!("guess the number!");
7
8
let secret_number = rand::thread_rng().gen_range(1..101);
9
10
println!("the secret number is: {}", secret_number);
11
12
loop {
13
println!("please enter a number");
14
15
let mut guess = String::new();
16
17
io::stdin()
18
.read_line(&mut guess)
19
.expect("failed to read string");
20
21
println!("your guess was: {}", guess);
22
23
let guess: u32 = match guess.trim().parse() {
24
Ok(num) => num,
25
Err(_) => continue,
26
};
27
28
let result = guess.cmp(&secret_number);
29
30
match result {
31
Ordering::Less => println!("guess is less than secret"),
32
Ordering::Greater => println!("guess is greater than secret"),
33
Ordering::Equal => {
34
println!("correct!");
35
break;
36
}
37
}
38
}
39
40
println!("CONGRATULATIONS!!");
41
}

Core Concepts

Variables and Mutability

Variables

By default variables are immutable, but the mut keyword lets us create a mutable variable as discussed above:

1
let a = 5; // immutable
2
3
let mut b = 6; // mutable
4
b = 7;

Constants

Rust also makes use of the concept of a constant which will always be the same for the lifetime of a program within their defined scope, and can be defined at any scope (including the global scope). By convention these are also capitalized:

1
const MAX_TIME_SECONDS = 5 * 60;

Constants can make use of a few simple operations when defining them but can’t be anything that would need to be evaluated at runtime for example. This is a key distinction between a constant and an immutable variable

Shadowing

Shadowing allows us to redeclare a variable with a specific scope and will shadow the previous declaration after it’s new definition

1
let x = 5; // x is 6
2
3
let x = x * 2; // x is 10
4
5
{
6
let x = x + 1; // x is 11
7
}
8
9
// x is 10

Shadowing is different to a mutable variable and is pretty much just a way for us to re-declare a variable and reuse an existing name within a scope. This is useful for cases where we’re transforming a value in some way. Shadowing also allows us to change the type of a variable which is something that we can’t do with a mutable variable

For example, the below will work with shadowing but not mutability:

1
let spaces = " ";
2
let spaces = spaces.len();

Data Types

Rust is a statically typed language. In most cases we don’t need to explicitly define the type of a variable, however in cases where the compiler can’t infer the type we do need to specify it, e.g. when parsing a string to a number:

1
let num: u32 = "52".parse().expect("Not a valid unsigned int");

We need to specify that the result of the parsing should be a u32, or if we want to use i32:

1
let num: i32 = "-23".parse().expect("Not a valid int");

Scalar Types

Rust has 4 scalar types, namely;

  • Integers
  • Floating-point numbers
  • Booleans
  • Characters

Integers

An integer is a number without a fraction component. The available integer types are:

lengthsignedunsigned
8 biti8u8
16 biti16u16
32 biti32u32
64 biti64u64
128 biti128u128
archisizeusize

The arch size uses 32 or 64 bits depending on if the system is a 32 or 64 bit one respectively

Additionally, number literals can use a type suffix as well as an _ for separation, for example the below are all equal:

1
let a: u16 = 5000;
2
let a = 5000u16;
3
4
// using _ separator
5
let a: u16 = 5_000;
6
let a = 5_000u16;

Floats

Rust has two types of floats, f32 for single-precision and f64 for double-precision values. The default is f64 which is about the same speed as f32 on modern CPUs but with added precision

Operations

The following operations are supported for integer and float operations:

1
// addition
2
let sum = 5 + 10;
3
4
// subtraction
5
let difference = 95.5 - 4.3;
6
7
// multiplication
8
let product = 4 * 30;
9
10
// division
11
let quotient = 56.7 / 32.2;
12
let floored = 2 / 3; // Results in 0
13
14
// remainder
15
let remainder = 43 % 5;

Booleans

Booleans are defined in rust as either true or false using the bool type:

1
let t = true;
2
let f: bool = false;

Characters

Character types are defined using char values specified with single quotes ' and store a Unicode scalar value which supports a bit more than ASCII

1
let c: char = 'z';
2
let z = 'Z';
3
let u = '😄';

Compound Types

Compound types are used to group multiple values into a single type, Rust has two compound types:

  • Tuple
  • Array

Tuples

Tuples are groups of values. They have a fixed length once declared. Tuples can be defined as with or without an explicit type, for example:

1
let t1 = (500, 'H', 1.3);
2
let t2: (i32, char, f64) = (500, 'H', 1.3);

Tuples can also be destructured as you’d expect:

1
let tup = (1, 2, 'A');
2
3
let (o, t, a) = tup;

We can also access a specific index of a tuple using a . followed by the index:

1
let tup = (1, 2, 'A');
2
3
let o = tup.0;
4
let t = tup.1;
5
let a = tup.2;

Arrays

Arrays can hold a collection of the same type of value.

Arrays are also fixed-length once defined but other than that they’re pretty much the same as in most other languages. When defining the type of an array we us the syntax of [type; size] though this can also usually be inferred

An example of defining an array can be seen below:

1
let a: [i32; 3] = [1,2,3];
2
let b = [1,2,3];

You can also create an array with the same value repeated using the following notation

1
let a = [3; 5];
2
let b = [3, 3, 3, 3, 3];

Accessing elements of an array can be done using [] notation:

1
let o = a[0];
2
let t = a[1];
3
let r = a[2];

Note that when accessing an array’s elements outside of the valid range you will also get an index our of bounds error during runtime

Functions

Defining a Function

Functions are defined using the fn keyword along with () and {} for it’s arguments and body respectively

1
fn my_function() {
2
println!("do stuff");
3
}

And can be called as you would in other languages:

1
my_function();

Parameters

Function parameters are defined using a name and type, like so:

1
fn print_sum(a: i32, b: i32) {
2
let result = a + b;
3
println!("sum: {}", result);
4
}

Statements and Expressions

Functions may have multiple statements and can have an optional ending expression. Statements do not return values whereas expressions do

An example of an expression which can have a value is this scoped block:

1
let x = {
2
let y = 5;
3
y + 5 // <- note the lack of a ; here
4
};

In the above case, x will have the value of the last expression in the block, in this case 10. As we’ve also seen above, expressions don’t contain a ;. Adding a ; to an expression turns it into a statement

Function Return Values

As we’ve discussed in the case of a block above, a block can evaluate to the ending expression of that block. So in the case of a function, we can return a value from a function by having it be the ending expression, however we must also state the return value of the function or it will not compile

A simple function that adds two numbers can be seen below:

1
fn add_nums(a: i32, b: i32) -> i32 {
2
a + b
3
}

Comments

Comments make use of // and is required at the start of each line a comment is on:

1
let a = 5; // comment after statement
2
3
// this is a
4
// multiline
5
// comment

Control Flow

If / Else

if statements will branch based on a bool expression. They can optionally contain multiple else if branches, and an else branch:

We can have only an if statement:

1
let number = 3;
2
3
if number > 5 {
4
println!("greater than 5");
5
}

Or an if and else:

1
let number = 3;
2
3
if number > 5 {
4
println!("greater than 5");
5
} else {
6
println!("something else");
7
}

Or even multiple else if statements with an optional else branch

1
let number = 3;
2
3
if number > 5 {
4
println!("greater than 5");
5
} else if number < 0 {
6
println!("negative");
7
} else if number < 10 {
8
println!("less than 10");
9
} else {
10
println!("something else");
11
}

Loops

loop

We can use the loop keyword to define a loop that we can escape by using the break keyword:

1
let mut count = 0;
2
3
loop {
4
count = count + 1;
5
6
println!("{}", count);
7
8
if count > 5 {
9
break;
10
}
11
}

We can use the continue keyword as well to early-end an iteration like so:

1
let mut count = 0;
2
3
loop {
4
count = count + 1;
5
6
if count == 2 {
7
continue;
8
}
9
10
println!("{}", count);
11
12
if count > 5 {
13
break;
14
}
15
}

It’s also possible to have loops inside of other looks. We can label a look as well and we can break out of any level of a look by using it’s label:

1
let mut count_out = 0;
2
3
'outer: loop {
4
count_out = count_out + 1;
5
let mut count_in = 0;
6
7
loop {
8
count_in = count_in + 1;
9
10
println!("{} {}", count_out, count_in);
11
12
if count_in > 2 {
13
break 'outer;
14
}
15
}
16
}

The above will log out:

1
1 1
2
1 2
3
1 3

Since we’re breaking the 'outer loop from inside of the inner loop

Loops can also break and return a value like so:

1
let mut count = 0;
2
3
let last = loop {
4
count = count + 1;
5
6
println!("{}", count);
7
8
if count > 2 {
9
break count;
10
}
11
};

In the above we are able to set last to the value of the count when the loop breaks

while

We also have a while loop which will continue for as long as a specific condition is true

1
let mut count = 0;
2
3
while count < 3 {
4
count = count + 1;
5
6
println!("{}", count);
7
}

for

A for loop allows us to iterate through the elements in a collection, we can do this as:

1
let a = [1, 2, 3, 4, 5];
2
3
for element in a {
4
println!("{}", element)
5
}

Additionally, it’s also possible to use a for loop with a range instead of an array:

1
for element in 1..6 {
2
println!("{}", element)
3
}

Ownership

Ownership is a concept of Rust that enables it to be memory safe without the need for a garbage collector

Ownership is implemented as a set of rules that are checked during compile time and does not slow down the program while it runs

Stack and Heap

Data stored on the stack must have a known, fixed size, data that has an unknown or changing size must be stored on the heap. The heap is less organized. Adding values to the heap is called allocating. When storing data on the heap we also store a reference to its address on the stack. We call this a pointer. When storing data to a stack we make use of pushing

Pushing to the stack is faster than allocating to the heap, and likewise for accessing data from the stack compared to the heap

When code calls a function, the values passed to the function as well as its variables are stored on the stack or heap. When the function is completed the values are popped off the stack

Ownership keeps track of what data is on the heap, reduces duplication, and cleans up unused data

Ownership Rules

  • Each value in Rust has a variable that’s called its owner
  • There can only be one owner at a time
  • When an owner goes out of scope the value is dropped

Variable Scope

We can see the scope of a variable s in a given block below:

1
// ❌ not in scope
2
{
3
// ❌ not in scope
4
let s = "hello"; // ✔️ in scope from this point onwards
5
// ✔️ in scope
6
}
7
// ❌ not in scope

The String Type

The types covered earlier are all fixed-size, but strings may have different, possibly unknown sizes

A general string is different to a string literal in that string literals are immutable. ASide from the string literal type Rust also has a String type

We can use the String type to construct a string from a literal like so:

1
let s = String::from("hello");

The type of s is now a String compared to a literal which is &str

As mentioned, Strings can be mutable, which means we can add to it like so:

1
let mut s = String::from("hello");
2
3
s.push_str(" world");

Memory and Allocation

In the case of a string literal the text contents ae known at compile time and is hardcoded into the executable. The String type can be mutable, which means that:

  • Memory must be requested from the allocator during runtime
  • Memory must be returned to the allocator when done being used

Rust does this by automatically returning memory once the variable that owns it is no longer in scope by calling a drop function

Moving a Variable

When assigning and passing values around, we have two cases in Rust, for values which live on the stack:

1
let x = 5;
2
let y = x;

An assignment like the above creates 2 values of 5 and binds them to the variables x and y. This is done by creating a copy of x on the stack

However, when doing this with a value on the heap, both variables will reference the same value in memory:

1
let x = String::from("hello");
2
let y = x;

As far as memory returning goes, there is a potential problem in the above code, which is that if x goes out of scope and is dropped, the value for y would also be dropped - Rust gets around this by invalidating a the previous version of the value can’t be used anymore. Which means doing the below will give us an error:

1
let x = String::from("hello");
2
let y = x;
3
println!("{}", x);

And this is the error:

1
borrow of moved value: `x`
2
3
value borrowed here after moverustc(E0382)
4
main.rs(2, 9): move occurs because `x` has type `std::string::String`, which does not implement the `Copy` trait

Cloning a Variable

If we do want to create an actual deep copy of data from the heap, we can use the clone method. So on the String type this would look like:

1
let x = String::from("hello");
2
let y = x.clone();

Using this, both x and y will be dropped independent of one another

Ownership and Functions

When passing a variable to a function the new function will take ownership of a variable, and unless we return ownership back to the caller the value will be dropped:

1
fn main() {
2
let x = String::from("hello");
3
take_ownership(x);
4
5
println!("{}", x); // error
6
}
7
8
fn take_ownership(data: String) {
9
println!("{}", data);
10
}

If we want to return ownership back to the caller’s scope, we need to return it from the function like so:

1
fn main() {
2
let x = String::from("hello");
3
let x = return_ownership(x);
4
5
println!("{}", x);
6
}
7
8
fn return_ownership(data: String) -> String {
9
println!("{}", data);
10
11
data
12
}

References and Borrowing

The issue with ownership handling as discussed above is that it’s often the case that we would want to use the variable that was passed to a function and that the caller may want to retain ownership of it

Because of this, Rust has a concept of borrowing which is when we pass a variable by reference while allowing the original owner of a variable to remain the owner

We can pass a value by reference using & when defining and calling the function like so:

1
fn main() {
2
let x = String::from("hello");
3
borrows(&x);
4
5
println!("{}", x);
6
}
7
8
fn borrows(data: &String) {
9
println!("{}", data);
10
}

Mutable References

By default, references are immutable and the callee can’t modify it’s value. If we want to make a reference mutable we make use of &mut

1
fn main() {
2
let mut x = String::from("hello");
3
modifies(&mut x);
4
5
println!("{}", x); // hello world
6
}
7
8
fn modifies(data: &mut String) {
9
println!("{}", data); // hello
10
11
data.push_str(" world")
12
}

An important limitation to note is that the only one mutable reference to a variable can exist at a time, which means that the following will not compile:

1
let mut x = String::from("hello");
2
let x1 = &mut x;
3
let x2 = &mut x;
4
5
println!("{} {}", x1, x2);

The error we see is:

1
cannot borrow `x` as mutable more than once at a time
2
3
second mutable borrow occurs hererustc(E0499)

The restriction above ensures that mutation of a variable is controlled and helps prevent bugs like race conditions

Race conditions occur when the following behaviors occur:

  • Two or more pointers access the same data at the same time
  • At least one of the pointers is being used to write to the data
  • There’s no mechanism to synchronize access to the data

Dangling References

In other languages with pointers it’s possible to create a pointer that references a location in memory that may have been cleared. With Rust the compiler ensures that there are no dangling references. For example, the below function will not compile:

1
fn main() {
2
let reference_to_nothing = dangle();
3
}
4
5
fn dangle() -> &String {
6
let s = String::from("hello");
7
8
&s
9
}

With the following error:

1
missing lifetime specifier
2
3
expected named lifetime parameter
4
5
...
6
7
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from

What’s happpening in the above code can be seen below:

1
fn main() {
2
let reference_to_nothing = dangle();
3
}
4
5
fn dangle() -> &String { // dangle returns a reference to a String
6
let s = String::from("hello"); // s is a new String
7
8
&s // we return a reference to the String, s
9
} // Here, s goes out of scope, and is dropped. Its memory goes away.
10
// Danger!

Since in the above code, s is created inside of the function it will go out of scope when the function is completed. This leads to the value for s being dropped which means that the result will be a reference to a value that is no longer valid

In order to avoid this, we need to return s so that we can pass ownership back to the caller

1
fn main() {
2
let result = dangle();
3
}
4
5
fn dangle() -> String {
6
let s = String::from("hello");
7
8
s
9
}

Though the code is now valid, we stil get a warning because result is not used, however this will not prevent compilation and the above code will still run

Rules of References

The following are the basic rules for working with references

  • You can either have onme mutatble reference or any number of immuatable references to a variable simultaneously
  • References must always be valid

The Slice Type

The slice type is a reference to a sequence of elements in a collection instead of the collection itself. Since a slice is a kind of reference in itself it doesn’t have ownership of the original collection

To understand the usecase for a slice type, take the following example of a function that needs to get the first word in a string

Since we don’t want to create a copy of the string, we can maybe try something that lets us get the index of the space in the string

1
fn first_word(s: &String) -> usize {
2
let bytes = s.as_bytes();
3
4
for (i, &item) in bytes.iter().enumerate() {
5
if item == b' ' {
6
return i;
7
}
8
}
9
10
return s.len()
11
}

Next, say we want to use the above function in our code:

1
fn main() {
2
let mut s = String::from("Hello World");
3
let result = first_word(&s);
4
5
s.clear(); // clear the string
6
7
// result is still defined but the value can't be used to access the string
8
println!("the first word is: {}", result)
9
}

However, we may run into a problem where the position no longer can be used to index the input string

In order to mitigate this, we can make use of a Slice that references a part of this string

The syntax for slicing is to use a range within the brackets of a collection. So for a string:

1
let s = String::from("Hello World");
2
3
let hello = &s[0..5];
4
let world = &s[6..11];

In the above, since the 0 is the start and 11 is the end of the string, we can also leave these out of the range to automatically get the start and end parts of the collection

1
let hello = &s[..5];
2
let world = &s[6..];

The hello and world variables not contain a reference to the specific parts of the String without creating a new value

We can also use the following to refer to the full string:

1
let ref_s = &[..]

The type of hello can also be seen to be &str which is an immutable reference to a string

Using this, we can redefine the first_word function like this:

1
fn first_word(s: &String) -> &str {
2
let bytes = s.as_bytes();
3
4
for (i, &item) in bytes.iter().enumerate() {
5
if item == b' ' {
6
return &s[0..i];
7
}
8
}
9
10
&s[..]
11
}

Using the function now will give us an error:

1
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
2
--> src\main.rs:5:5
3
|
4
3 | let result = first_word(&s);
5
| -- immutable borrow occurs here
6
4 |
7
5 | s.clear(); // clear the string
8
| ^^^^^^^^^ mutable borrow occurs here
9
...
10
8 | println!("the first word is: {}", result)
11
| ------ immutable borrow later used here
12
13
For more information about this error, try `rustc --explain E0502`.

Something else to note is that string literals are stored as slices. If we try to use a string literal with our function like so:

1
fn main() {
2
let s = "Hello World";
3
let result = first_word(&s);
4
5
println!("the first word is: {}", result)
6
}

We will get a mismatched types error:

1
error[E0308]: mismatched types
2
--> src\main.rs:3:29
3
|
4
3 | let result = first_word(&s);
5
| ^^ expected struct `String`, found `&str`
6
|
7
= note: expected reference `&String`
8
found reference `&&str`

This is because our function requires &String, we can change our function instead to use &str which will work on string references as well as slices:

1
fn first_word(s: &str) -> &str { // take a slice

The above will work with refernecs to String and str

Other Slice Types

Slices apply to general collections, so we can use it with an array like so:

1
let a = [1, 2, 3, 4, 5];
2
3
let slice = &a[1..3];
4
// slice is type &[i32]

Slices can also be used by giving them a start element and a length, like so:

1
let slice = &a[2, 3];

Summary

Ownership, borrowing, and slices help us ensure memory safety and control as we have seen above

Structs

A struct is a data type used for packaging and naming related values.

Structs are similar to tuples in that they can hold multiple pieces of data

Defining a Struct

We can define structs using the struct keyword:

1
struct User {
2
active: bool,
3
username: String,
4
email: String,
5
sign_in_count: u64,
6
}

We can create a struct be defining a concrete instance like so:

1
fn main() {
2
let user = User {
3
active: true,
4
username: String::from("bob"),
5
email: String::from("bob@email.com"),
6
sign_in_count: 1,
7
};
8
}

Struct instances can also be mutable which will allow us to modify properties:

1
fn main() {
2
let mut user = User {
3
active: true,
4
username: String::from("bob"),
5
email: String::from("bob@email.com"),
6
sign_in_count: 1,
7
};
8
9
user.email = String::from("bobnew@email.com");
10
}

We can also return structs from functions, as well as using the shorthand struct syntax:

1
fn create_user(email: String, username: String) -> User {
2
User {
3
username,
4
email,
5
active: true,
6
sign_in_count: 1,
7
}
8
}

We can also use the struct update syntax to create a new struct based on an existing one:

1
let user = create_user(email, username);
2
3
let inactive_user = User {
4
active: false,
5
sign_in_count: 2,
6
..user
7
}; // we can't refer to user anymore

It should also be noted that when creating a struct like this, we can no longer refer to values in the original user as this will give us an error:

1
let a = user.email; // moved error

This is because the value has been moved to the new struct

Tuple Structs

Tuple structs can be defined using the following syntax:

1
struct Point(i32, i32, i32);

And can then be used like:

1
let origin = Point(0, 0, 0);

Unit Type Structs

You can also define a struct that has no data (is unit) by using this syntax:

1
struct NothingReally;
2
3
fn main() {
4
let not_much = NothingReally;
5
}

Unit structs are useful when we want to define a type that implements a trait or some other type but doesn’t have any data on the type itself

Printing Structs

In order to make a struct printable we can use a Debug attribute with a debugging print specifier of {:?} like this:

1
#[derive(Debug)]
2
struct Rectangle {
3
length: u32,
4
height: u32,
5
}
6
7
fn main() {
8
let rect = Rectangle {
9
height: 20,
10
length: 10,
11
};
12
13
println!("This is a rectangle: {:?}", rect);
14
}

Also, instead of the println! macro, we can use dbg! like so:

1
dbg!(&rect);

The dbg! macro will also return ownership of the input, which means that we can use it while instantiating a Rectangle as well as when trying to view the entire data:`

1
fn main() {
2
let rect = Rectangle {
3
height: dbg!(2 * 10),
4
length: 10,
5
};
6
7
dbg!(&rect);
8
}

And the output:

1
[src\main.rs:9] 2 * 10 = 20
2
[src\main.rs:13] &rect = Rectangle {
3
length: 10,
4
height: 20,
5
}

We can see that the value assigned to height is logged as well as assigned to the rect struct

Method Syntax

Methods aan be added to structs using the impl block. Everything in this block is a part of the Rectangle struct

The first value passed to a struct method is always a reference to the struct itself. We can add an area method to the Rectangle struct like so:

1
impl Rectangle {
2
fn area(&self) -> u32 {
3
self.length * self.width
4
}
5
}

And we can then use this method on our rect like so:

1
let a = rect.area();

Note that in the area function, &self is shorthand for self: &Self which is a reference to the current instance

We can also define methods that have the same name as a struct field, these methods can then return something different if called vs when accessed

1
impl Rectangle {
2
fn width(&self) -> bool {
3
self.width > 0
4
}
5
}
6
7
fn main() {
8
let rect = Rectangle {
9
width: 20,
10
length: 10,
11
};
12
13
if rect.width() { // call the width function
14
println!("Rect has a width of {}", rect.width) // access width value
15
}
16
}

Multiple Parameters

We can give methods multiple parameters by defining them after self

1
impl Rectangle {
2
fn area(&self) -> u32 {
3
self.width * self.length
4
}
5
6
fn add(&self, other: &Rectangle) -> u32 {
7
self.area() + other.area()
8
}
9
}
10
11
fn main() {
12
let rect1 = Rectangle {
13
width: 20,
14
length: 10,
15
};
16
17
let rect2 = Rectangle {
18
width: 20,
19
length: 20,
20
};
21
22
let total_area = rect1.add(&rect2);
23
}

Associated Functions

Functions defined in an impl block are called associated functions because they’re associated with the type named in the impl block

We can also define associated functions that don’t have self as their first param (and are therefore not methods) like so:

1
impl Rectangle {
2
fn create(size: u32) -> Rectangle {
3
Rectangle {
4
length: size,
5
width: size,
6
}
7
}
8
}

We can use these functions with the :: syntax:

1
fn main() {
2
let rect = Rectangle::create(20);
3
}

The above syntax tells us that the function is namespaced by the struct. This syntax is used for associated functions as well as namespaces created by modules

Multiple Impl Blocks

Note that it is also allowed for us to have multiple impl blocks for a struct, though not necessary in the cases we’ve done here

Enums and Pattern Matching

Enums in Rust are most like algebraic data types in functional languages like F#

Defining an Enum

Enums can be defined using the enum keyword:

1
enum Person {
2
Employee,
3
Customer,
4
}

We can also associate a value with an enum like so:

1
enum Person {
2
Employee(String),
3
Customer(String),
4
}
5
6
fn main() {
7
let bob = Person::Customer(String::from("bob"));
8
let charlie = Person::Employee(String::from("charlie"));
9
}

We can further make it such that each of the enum values are of a different type

1
struct CustomerData {
2
name: String,
3
}
4
5
struct EmployeeData {
6
name: String,
7
employee_number: u32,
8
}
9
10
enum Person {
11
Employee(EmployeeData),
12
Customer(CustomerData),
13
}
14
15
fn main() {
16
let bob = Person::Customer(CustomerData {
17
name: String::from("Bob"),
18
});
19
20
let charlie = Person::Employee(EmployeeData {
21
employee_number: 1,
22
name: String::from("Charlie"),
23
});
24
}

Enums can also be defined with their data inline:

1
enum Message {
2
Quit,
3
Move { x: i32, y: i32 },
4
Write(String),
5
ChangeColor(i32, i32, i32),
6
}

The impl keyword can also be used to define methods on enums like with structs:

1
impl Message {
2
fn call(&self) {
3
// do stuff
4
}
5
}

The Option Enum

The Option Enum defined in the standard library and is defined like so:

1
enum Option<T> {
2
None,
3
Some(T),
4
}

The Option enum is also included by default and can be used without specifying the namespace

Option types help us avoid null values and ensure that we correctly handle for when we do or don’t have data

In general, in order to handle an Option value we need to ensure that we handle the None and Some values

The Match Control Flow Construct

The match construct allows us to compare a value against a set of patterns and then execute based on that, it’s used like this:

1
enum Answer {
2
Yes,
3
No,
4
}
5
6
fn is_yes(answer: Answer) -> bool {
7
match answer {
8
Answer::Yes => true,
9
Answer::No => false,
10
}
11
}

We can also have a more complex body for the match body:

1
fn is_yes(answer: Answer) -> bool {
2
match answer {
3
Answer::Yes => true,
4
Answer::No => {
5
println!("Oh No");
6
false
7
}
8
}
9
}

We can also use patterns to handle the data from a match, for example:

1
enum Answer {
2
Yes,
3
No,
4
Maybe(String),
5
}
6
7
fn is_yes(answer: Answer) -> bool {
8
match answer {
9
Answer::Yes => true,
10
Answer::No => {
11
println!("Oh No");
12
false
13
}
14
Answer::Maybe(comment) => {
15
println!("Comment: {}", comment);
16
false
17
}
18
}
19
}

The match can also use an _ to handle all other cases:

1
fn is_yes(answer: Answer) -> bool {
2
match answer {
3
Answer::Yes => true,
4
_ => {
5
println!("Oh No");
6
false
7
}
8
}
9
}

Or, can also capture the value like this:

1
match answer {
2
Answer::Yes => true,
3
answer => {
4
println!("Oh No");
5
false
6
}
7
}

If we would like to do nothing, we can also return unit:`

1
fn nothing(answer: Answer) -> () {
2
match answer {
3
Answer::Yes => {
4
println!("Yes!");
5
}
6
_ => (),
7
}
8
}

If-Let Control Flow

In Rust, something that’s commonly done is to do something based on a single pattern match, the previous example can be done like this using if let which is a little cleaner

1
fn nothing(answer: Answer) -> () {
2
if let Answer::Yes = answer {
3
println!("Yes!")
4
}
5
}

We can also use if let with an else, for example in the below snippet we return a boolean value:

1
fn is_yes(answer: Answer) -> bool {
2
if let Answer::Yes = answer {
3
true
4
} else {
5
false
6
}
7
}

Packages, Crates, and Modules

As programs grow, there becomes a need to organize and separate code to make it easier to manage

  • Packages: A cargo feature for building, testing, and sharing crates
  • Crates: A tree of modules that produces a library or executable
  • Modules and Use: Let you control organization, scope, and privacy
  • Paths: A way of naming an item such as a struct, function, or module

Packages and Crates

A package is one or more crate that provide a set of functionality. A package contains a Cargo.toml file that describes ho to build a crate

Crates can either be a binary or library. Binary crates must have a main which will run the binary

Libraries don’t have a main and can’t be executed

Cargo follows a convention that src/main.rs is a binary crate with the name of the package, and src/lib.rs is the library crate root

A package can have additional binary crates by placing them in the src/bin directory. Each file in here will be a separate binary crate

Defining Modules to Control Scope and Privacy

  • Starts from the crate root, either src/main.rs or src/lib.rs
  • Modules are declared in the root file using the mod keyword. The compiler will look for the module in the following locations. For example, a module called garden
    • If using mod garden followed by curly brackets: inline directly following the declaration
    • If using mod garden; then in the file src/garden.rs or src/garden/mod.rs
  • Submodules can be declared in other files and the compiler will follow the similar pattern as above, for example a module vegetables in the garden module:
    • mod vegetables {...} as inline
    • mod vegetables; as src/garden/vegetables.rs or src/garden/vegetables/mod.rs
  • Paths can refer to code from a module. For example, a type Carrot in the vegetables submodule would be used with: crate::garden::vegetables::Carrot (as long as privacy rules allow it to be used)
  • Modules are private by default, and can be made by adding pub when declaring the module, for example pub mod vegetables
  • The use keyword allows us to create a shorthand for an item, for example doing use crate::garden::vegetables::Carrot we can just use Carrot in a file without the full path

We can create modules using cargo new --lib <LIB NAME>

The src/lib.rs and src/main.rs are called crate roots and things accessed relative to here are accessed by the crate module

Referencing Items by Paths

Paths can ee referenced in one of two ways:

  • An absolute path using crate
  • A relative path using self, super, or an identifier in the current module

For example, referencing add_to_waitlist in the below file may look like this:

1
mod front_of_house {
2
pub mod hosting {
3
pub fn add_to_waitlist() {}
4
}
5
}
6
7
pub fn eat_at_restaurant() {
8
// Absolute path
9
crate::front_of_house::hosting::add_to_waitlist();
10
11
// Relative path
12
front_of_house::hosting::add_to_waitlist();
13
}

Also note the pub keyword which allows us to access the relevant submodules and function

Best Practices for Packages with a Binary and Library

When a package has a binary src/main.rs crate root and a src/lib.rs crate root, both crates will have the package name by default, in this case you should have the minimum necessary code in the main.rs to start the binary, while keeping the public API in the lib.rs good so that it can be used by other consumers easily. Like this, the binary crate uses the library crate and allows it to be a client of the library

Relative Paths with Super

Modules can use the super path to access items from a higher level module:

1
fn deliver_order() {}
2
3
mod back_of_house {
4
pub fn fix_incorrect_order() {
5
cook_order();
6
super::deliver_order();
7
}
8
9
fn cook_order() {}
10
}

Bring Paths into Scope

We can bring paths into scope using the use keyword, this makes it easier to access an item from a higher level scope

For example, say we wanted to add a method undo_order_fix at the top level of our module file, and we need to us a method from the back_of_house module, we can do this like so:

1
fn deliver_order() {}
2
3
mod back_of_house {
4
pub fn fix_incorrect_order() {
5
cook_order();
6
super::deliver_order();
7
}
8
9
fn cook_order() {}
10
}
11
12
use back_of_house::fix_incorrect_order;
13
14
pub fn undo_order_fix() {
15
fix_incorrect_order();
16
}

Note that if we were to try to use the top-level use in another submodule this would not work and we would need to import it within the scope of that module

For example, the following will fail:

1
pub mod private_module {
2
pub fn do_stuff() {
3
fix_incorrect_order();
4
}
5
}

But this will work:

1
pub mod private_module {
2
use super::back_of_house::fix_incorrect_order;
3
4
pub fn do_stuff() {
5
fix_incorrect_order();
6
}
7
}

Idiomatic Paths

In the above examples, we’re importing paths to functions and using them directly, however, this isn’t the preferred way to do this in rust, it’s instead preferred to keep the last-level of the path in order to identify that the path is part of another module. So for example, instead of the above use this instead:

1
pub mod private_module {
2
use super::back_of_house;
3
4
pub fn do_stuff() {
5
back_of_house::fix_incorrect_order();
6
}
7
}

The exception to this rule is when importing structs or enums into scope, we prefer to use the full name, for example:

1
use std::collections::HashMap;
2
3
fn main() {
4
let mut map = HashMap::new();
5
map.insert(1, 2);
6
}

The exception to this is when bringing two items with the same name into scope:

1
use std::fmt;
2
use std::io;
3
4
fn function1() -> fmt::Result {
5
// --snip--
6
}
7
8
fn function2() -> io::Result<()> {
9
// --snip--
10
}

This is to ensure that we refer to the correct value

Renaming Imports

In order to get around naming issues, we can also use as to rename a specific import - so the above example can be written like:

1
use std::fmt::Result;
2
use std::io::Result as IoResult;
3
4
fn function1() -> Result {
5
// --snip--
6
}
7
8
fn function2() -> IoResult<()> {
9
// --snip--
10
}

Re-exporting Names

We can re-export an item with pub use, like so:

1
mod front_of_house {
2
pub mod hosting {
3
pub fn add_to_waitlist() {}
4
}
5
}
6
7
pub use crate::front_of_house::hosting;
8
9
pub fn eat_at_restaurant() {
10
hosting::add_to_waitlist();
11
}

This is often useful to restructure the imports from a client perspective when our internal module organization is different than what we want to make the public API

Nested Paths

When importing a lo of stuff from a similar path, you can join the imports together in a few ways

For modules under the same parent:

1
use std::cmp::Ordering;
2
use std::io;

Can become:

1
use std::{cmp::Ordering, io};

For using the top-level as well as some items under a path:

1
use std::io;
2
use std::io::Write;

Can become:

1
use std::io::{self, Write};

And for importing all items under a specific path using a glob:

1
use std::collections::*;

Note that using globbing can make it difficult to identify where a specific item has been imported from

The Glob operator is often used when writing tests to bring a specific module into scope

Common Collections

Collections allow us to store data that can grow and shrink in size

  • Vectors store a variable number of values next to each other
  • Strings are collections of characters
  • Hash Maps allow us to store a value with a particular key association

Vectors

Vectors allow us to store a list of a single data type.

Creating a Vector

When creating a vector without initial items you need to provide a type annotation:

1
let a: Vec<i32> = Vec::new();

Or using Vec::from if we have an initial list of items

1
let c = Vec::from([1, 2, 3]);

The above can also be using the vec! macro

1
let b = vec![1, 2, 3];

Updating Values

In order to update the values of a vector we must first define it as mutable, thereafter we can add items to it using the push method

1
let mut c: Vec<i32> = Vec::new();
2
c.push(1);
3
c.push(2);
4
c.push(3);

Dropping a Vector Drops its Elements

When a vector gets dropped, all of it’s contents are dropped - which means that if there are references to an element we will have a compiler error

1
{
2
let v = vec![1, 2, 3, 4];
3
4
// do stuff with v
5
} // <- v goes out of scope and is freed here

Reading Elements

We can read elements using either [] notation or the .get method:

1
let mut d = vec![1, 2, 3, 4, 5];
2
3
let d1 = &d[1];
4
let d2 = d.get(2);

The difference int he two access methods above is that d1 is an &i32 whereas d2 is an Option<$i32>

This is an important distinction since when running the code, the first reference will panic with index out of bounds

1
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 100', src\main.rs:12:15
2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceerror: process didn't exit successfully: `target\debug\rust_intro.exe` (exit code: 101)

Note that as long as we have a borrowed, immutable reference to an item we can’t also use the mutable reference to do stuff, for example we can’t push an item into d while d1 is still in scope:

1
let mut d = vec![1, 2, 3, 4, 5];
2
3
let d1 = &d[1];
4
let d2 = d.get(2);
5
6
d.push(6);
7
println!("The first element is: {}", d1);

The above will not compile with the following error:

1
error[E0502]: cannot borrow `d` as mutable because it is also borrowed as immutable
2
--> src\main.rs:15:5
3
|
4
12 | let d1 = &d[1];
5
| - immutable borrow occurs here
6
...
7
15 | d.push(6);
8
| ^^^^^^^^^ mutable borrow occurs here
9
16 | println!("The first element is: {}", d1);
10
| -- immutable borrow later used here
11
12
For more information about this error, try `rustc --explain E0502`.

The reason for the above error is that vectors allocate items in memory next to one another, which means that adding a new element to a vector may result in the entire vector being reallocated, which would impact any existing references

Looping over Elements

We can use a for in loop to iterate over elements in a vector, like so:

1
let e = vec![41, 62, 92];
2
for i in &e {
3
println!("{}", i);
4
}

We can also use the * dereference operator to modify the elements in the vector:

1
let mut e = vec![41, 62, 92];
2
for i in &mut e {
3
*i = *i + 10;
4
}

Which is equivalent to:

1
let mut e = vec![41, 62, 92];
2
for i in &mut e {
3
*i += 10;
4
}

And will add 10 to each item in the list

Using Enums to Store Multiple Types

Vectors can only store values that are the same type. In order to store multiple types of data we can store them as enums with specific values. For example, if we want to store a list of users

1
struct UserData {
2
name: String,
3
age: i32,
4
}
5
6
enum User {
7
Data(UserData),
8
Name(String),
9
}
10
11
fn main() {
12
let mut users: Vec<User> = Vec::new();
13
14
users.push(User::Data(UserData {
15
name: String::from("Bob"),
16
age: 32,
17
}));
18
19
users.push(User::Name(String::from("Jeff")));
20
}

Remove the Last Element

We can use the pop method to remove the last element from the vector

1
let popped = users.pop();

UTF-8 Encoded Text as Strings

The String type differs from str in that it is growable, mutable, and owned

Creating a String

Strings are created using the new function:

1
let mut s = String::new();

When we have some initial data for the string, we can use the .to_string method:

1
let s = "hello".to_string();

An alternative to the above is:

1
let s = String::from("hello");

Updating a String

We can use functions like push_str to add to a string:

1
let mut s = String::from("hello");
2
3
s.push_str(" world");

Strings can be updated by using concatenation (+) or the format! marco:

1
let s1 = String::from("hello");
2
let s2 = String::from(" world");
3
// concatenation requires an owned string first, other strings should be borrowed
4
let s3 = s1 + &s2;
5
// can no longer use `s1` from this point since it's ownership has moved to s3
6
7
println!("{}", s3);

If we want to create a new string from a concatenation of multiple other strings then we can use the format! marco:

1
let s1 = String::from("hello");
2
let s2 = String::from(" world");
3
let s3 = format!("{}-{}", s1, s2);
4
5
// s1, s2, s3 can all still be used since ownership is not given
6
7
println!("{}, {}, {}", s1, s2, s3);

Indexing into Strings

Rust doesn’t support string indexing. This is due to how String is implemented internally and that the resulting string value may not always be the expected value in the string

This is especially relevant with non-numeric characters - since rust supports all UTF-8 Characters, something that seems like a simple string may be encoded as a non-trivial sequence of characters

Slicing Strings

Since indexing isn’t a good idea in rust since the return value can be unexpected - it’s usually more appropriate to create a slice:

1
let s1 = String::from("💻");
2
println!("{}", s1.len()); // len is 4

Hash Maps

Hash maps store key-value pairs as HashMap<K, V>. This is bsically a map/object/dictionary and works like so:

Importing

To use a hash map, we need to import it:

1
use std::collections::HashMap;

Adding Items

1
let mut users = HashMap::new();
2
3
users.insert("bob@email.com", "Bob Smith");
4
users.insert("john@email.com", "John Smith");

We can also create items using a list of keys and a list of values along with the iterators and the collect method

1
let emails = vec!["bob@email.com", "john@email.com"];
2
let names = vec!["Bob Smith", "John Smith"];
3
4
let mut users: HashMap<_, _> = emails.into_iter().zip(names.into_iter()).collect();

The zip method gathers data into an iterator of tuples, and the collect method gathers data into different collection types. In our case, a HashMap

We specify HashMap<_,_> to tell the collect method that we want a hash map back and not someother kind of collection. We use the _ because rust can still infer the type of the key and value

Get a Value by Key

We can get a value by key using the .entry method

1
let mut users: HashMap<_, _> = ...
2
let bob = users.entry("bob@email.com");
3
4
println!("{:?}", bob);

Note that users needs to be defined as mutable in order for the us to use the .entry method

Update Item

We can update an item by just setting the key and value like when we added them initially:

1
users.insert("bob@email.com", "Bob's New Name");
2
3
let new_bob = users.entry("bob@email.com");

Update Item if Exists

Another usecase is updating a value in a map only if the value does not exist, this can be done using the .or_insert method. We can see that here the entry for bob is not updated:

1
users.entry("bob@email.com").or_insert("Bob's New Name");
2
3
let new_bob = users.entry("bob@email.com");
4
5
println!("{:?}", new_bob);

Error Handling

Rust uses the Result<T,E> type for recoverable errors, and panic! for errors that should stop execution

Unrecoverable Errors with panic!

We can panic like so:

1
fn main() {
2
panic!("at the disco");
3
}

We can also create a panic by doing something that yields an undefined behaviour:

1
let v = vec![1, 2, 3];
2
v[10];

Which will result in:

1
Compiling my-project v0.1.0 (/home/runner/Rust-Playground)
2
Finished dev [unoptimized + debuginfo] target(s) in 1.03s
3
Running `target/debug/my-project`
4
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10', src/main.rs:3:1
5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
6
exit status 101

Recoverable Errors with Result

Usually if we encounter an error that we can handle, we should return a Result

The Result type is an enum defined in the standard library as follows:

1
enum Result<T, E> {
2
Ok(T),
3
Err(E),
4
}

The result type is always in scope, so it doesn’t need to be imported

The Result type is usually used with a match expression, for example:

1
let file = File::open("./sample.txt");
2
3
let result = match file {
4
Ok(f) => f,
5
Err(e) => panic!("Error reading file: {:?}", e),
6
};

We can also do further specific checks on the type of the error based on theResult value

The shortcut to panic on error is the .unwrap method which can be used like so:

1
let file = File::open("./sample.txt");
2
3
let result = file.unwrap();

Or we can unwrap with a specific error message:

1
let file = File::open("./sample.txt");
2
3
let result = file.expect("Error reading file");

We can also choose to handle only Ok values by using the ? operator, and then have the error automatically propagated like so:

1
use std::fs::File;
2
use std::io::Error;
3
4
fn read_file() -> Result<File, Error> {
5
let file = File::open("./sample.txt")?;
6
print!("{:?}", file);
7
8
return Ok(file);
9
}
10
11
fn main() {
12
let result = read_file().expect("Could not read file");
13
}

The ? operator will do an early return in the result of an Err in the above case. The ? operator can only be used for types that implement FromResidual like Result or Option

When to Panic

Generally, we use unwrap or expect when prototyping or writing tests, but other than this it can be okay to use .unwrap in a case where the compiler thinks that we may have a Result but due to the specific circumstance we know that we won’t have an error

An example of when we know that the data will definitely not be an error can be seen below:

1
fn main() {
2
use std::net::IpAddr;
3
4
let home: IpAddr = "127.0.0.1".parse().unwrap();
5
}

Otherwise it is advisable to panic when it’s possible that code could end up in a bad state, for example if a user enters data in an incorrect format

Generics, Traits, and Lifetimes

Generic Functions

We can define generic functons using the following structure:

1
fn my_func<T>(input: T) -> T

For example, a function that finds the first vue in a slice would be defined like so:

1
fn first<T>(list: &[T]) -> T

Generic Structs

We can use a generic in a struct like so:

1
struct Datum<X,Y> {
2
x: X,
3
    y: Y,
4
}

Generic Enums

Enums work the same as structs, for example the Option enum:

1
enum Option<T> {
2
Some(T),
3
    None,
4
}

Or for the Result enum:

1
enum Result<T, E> {
2
Ok(T),
3
Err(E),
4
}

Method Definitions

methods can also be implemented on generic structs and enums, for example

1
struct Datum<X,Y> {
2
x: X,
3
y: Y,
4
}
5
6
impl<X,Y> Datum<X,Y> {
7
fn get_x(self) -> X {
8
self.x
9
}
10
}

Traits

A trait defines funcionality that a specific type can have and share with other types. This helps us define shared behaviour in an abstract way

We can use traits to enforce constriants on generics so that we can specify that it meets a specific requirement

Defining a Trait

A trait can be defined usnig the trait keyword, for example, we can define a trait called Identifier with a get_id method:

1
pub trait Identifier {
2
pub fn get_id(&self) -> String;
3
}

Implement a Trait

We then create a type that implements this like so:

1
struct User {
2
id: i32,
3
name: String,
4
}
5
6
impl Identifier for User {
7
fn get_id(&self) -> String {
8
String::from(&self.id.to_string())
9
}
10
}

Default Implementations

Traits can also have a default implementation, for example we can have a Validity trait which looks like this:

1
pub trait Validity {
2
pub fn is_valid(&self) -> bool {
3
false
4
}
5
}
6
7
impl Validity for User {
8
fn is_valid(&self) -> bool {
9
&self.id > &0
10
}
11
}

Traits as Parameters

We can specify a trait as a function parameter like so:

1
pub fn id(value: &impl Identifier) -> String {
2
value.get_id()
3
}

The above is syntax sugar for a something known as a trait bound

1
pub fn id<T: Identifier>(value: &T) -> String {
2
value.get_id()
3
}

We can also specify that a value needs to implement multiple traits, as well as nultiple different generics:

1
fn valid_id<T: Identifier + Validity, U: Validity>(value: &T, other: U) -> Option<String> {
2
if value.is_valid() {
3
Some(value.get_id())
4
} else {
5
None
6
}
7
}

Trait bounds can also be speficied using the where clause when we have multiple bounds:

1
fn valid_id<T, U>(value: &T, other: U) -> Option<String>
2
where
3
T: Identifier + Validity,
4
U: Validity,
5
{
6
if value.is_valid() {
7
Some(value.get_id())
8
} else {
9
None
10
}
11
}

Conditional Trait Implementation

Traits can also be generically implemented for a generic, like so:

1
impl<T: Display> ToString for T {
2
// --snip--
3
}

Validate References with Lifetimes

Lifetimes are a kind of generic that ensures that a reference’s data is valid for as long as we need them to be

Prevent Dangling References

Lifetimes aim to prevent dangling references

If we try to run the below code we will have a compiler error:

1
fn main() {
2
{ // 'a
3
let r;
4
5
{ // 'b
6
let x = 5;
7
r = &x;
8
} // end 'b
9
10
println!("r: {}", r);
11
} // end 'a
12
}

This is because the reference to x doesn’t live as long as r which is in the outer scope

In the above example, we would say that the lifetime of r is 'a and the lifetime of x is 'b. We can also see that the 'a block extends beyond the 'b block, so 'a outlives 'b and so r will not be able to reference x in the outer scope

Given the following function longest:

1
fn main() {
2
let string1 = String::from("abcd");
3
let string2 = "xyz";
4
5
let result = longest(string1.as_str(), string2);
6
println!("The longest string is {}", result);
7
}
8
9
fn longest(x: &str, y: &str) -> &str {
10
if x.len() > y.len() {
11
x
12
} else {
13
y
14
}
15
}

Rust will not be able to compile since it can’t tell whether the returned value refers to x or y, and therefore can’t tell which value the result refers to. In this case, when compiling we will get the following error:

1
Compiling my-project v0.1.0 (/home/runner/Rust-Playground)
2
Building [ ] 0/1: my-project(bin)
3
error[E0106]: missing lifetime specifier
4
--> src/main.rs:9:33
5
|
6
9 | fn longest(x: &str, y: &str) -> &str {
7
| ---- ---- ^ expected named lifetime parameter
8
|
9
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
10
help: consider introducing a named lifetime parameter
11
|
12
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
13
| ++++ ++ ++ ++
14
15
Building [ ] 0/1: my-project(bin)
16
For more information about this error, try `rustc --explain E0106`.
17
Building [ ] 0/1: my-project(bin)
18
error: could not compile `my-project` due to previous error
19
exit status 101

In this context, the compuler is telling us that we need to specify the lifetime value and it shows us how we need to do that:

1
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
2
if x.len() > y.len() {
3
x
4
} else {
5
y
6
}
7
}

Using lifetime values helps the compiler identify issues more easily

The above also prevents us from using result outside of the 'b scope since it’s not valid due to the lifetime value:

1
fn main() {
2
let string1 = String::from("long string is long");
3
let result;
4
5
{
6
let string2 = String::from("xyz");
7
result = longest(string1.as_str(), string2.as_str());
8
}
9
println!("The longest string is {}", result);
10
}

We’ll get the following error:

1
Compiling my-project v0.1.0 (/home/runner/Rust-Playground)
2
Building [ ] 0/1: my-project(bin)
3
error[E0597]: `string2` does not live long enough
4
--> src/main.rs:7:44
5
|
6
7 | result = longest(string1.as_str(), string2.as_str());
7
| ^^^^^^^ borrowed value does not live long enough
8
8 | }
9
| - `string2` dropped here while still borrowed
10
9 | println!("The longest string is {}", result);
11
| ------ borrow later used here
12
13
Building [ ] 0/1: my-project(bin)
14
For more information about this error, try `rustc --explain E0597`.
15
Building [ ] 0/1: my-project(bin)
16
error: could not compile `my-project` due to previous error
17
exit status 101

This is because of the lifetime of the result value being longer than 'b, and due to the lifetime constraing the value of result is only valid for the shortest lifetime parameter

Using the concept of lifetimes we can also specify a lifetime parameter that shows that the return type of a function is not dependant on a specific value, for example in the following function, we only care about the lifetime of x:

1
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
2
x
3
}

Lifetimes in Structs

We can define structs to hold refernces, however if this is done then it becomes necessary to have a lifetime annotation to each reference in the struct’s definition. Struct lifetime definitions look like so:

1
struct ImportantExcerpt<'a> {
2
part: &'a str,
3
}

Lifetime Elision

Often in rust we have cases where the compiler can identify a lifetime rule based on patterns in a function’s body, in this case we don’t necessarily need to specify the lifetime. The rules that the compiler uses to identify these are called lifetime elision rules

Lifetimes on Method Definitions

Lifetimes on methods are specified like so:

1
impl<'a> ImportantExcerpt<'a> {
2
fn level(&self) -> i32 {
3
3
4
}
5
}

By default, the lifetime of the result is always the same as the lifetime ofthe struct, which means that we don’t need to specify it in the input or output values

The Static Lifetime

The 'static lifetime is a lifetime of a value that’s stored in the program’s inary and is always available. All string literals are 'static

Automated Tests

How to Write Tests

File Structure

A test file should contain tests in their own isolated module using the #[cfg(test)] attribute, and each test having the #[test] attribute on it. We usually also want our test to make use of code from a module we want to test, we can do this by using the super:** import which would be the module exported from the file we’re in

Writing Tests

In the src/lib.rs file add the following content implementing what was discussed above:

1
#[derive(Debug)]
2
struct Rectangle {
3
width: u32,
4
height: u32,
5
}
6
7
impl Rectangle {
8
fn can_hold(&self, other: &Rectangle) -> bool {
9
self.width > other.width && self.height > other.height
10
}
11
}
12
13
#[cfg(test)]
14
mod tests {
15
use super::*;
16
17
#[test]
18
fn larger_can_hold_smaller() {
19
let larger = Rectangle {
20
width: 8,
21
height: 7,
22
};
23
let smaller = Rectangle {
24
width: 5,
25
height: 1,
26
};
27
28
assert!(larger.can_hold(&smaller));
29
}
30
}

In the above test, we can also see the assert! macro which will cause a panic if the test fails

The following are some of the other test macros we can use:

MacroUse
assert!Check that a boolean is true
assert_eq!Check that two values are equal
assert_neqCheck that two values are not equal

Running Tests

To run tests, use cargo test which will automatically run all tests within the project using:

Terminal window
1
cargo run

Testing for Panics

We can also state that a test should panic by using the #[should_panic] attribute on a specific test:

1
mod tests {
2
use super::*;
3
4
#[test]
5
#[should_panic]
6
fn greater_than_100() {
7
Guess::new(200);
8
}
9
}

In the above example it’s also possible for us to specify the exact panic message for the test:

1
#[cfg(test)]
2
mod tests {
3
use super::*;
4
5
#[test]
6
#[should_panic(expected = "Guess value must be less than or equal to 100")]
7
fn greater_than_100() {
8
Guess::new(200);
9
}
10
}

Return Result from a Test

Instead of panicking we can also get a test to return a Result, for example:

1
#[cfg(test)]
2
mod tests {
3
#[test]
4
fn it_works() -> Result<(), String> {
5
if 2 + 2 == 4 {
6
Ok(())
7
} else {
8
Err(String::from("two plus two does not equal four"))
9
}
10
}
11
}

Controlling Test Runs

By default, the test runner will compile and run all tests in parallel, but we can control this more specifically

Consecutive Test Runs

We can limit the number of threads to get tests to run sequentially

Terminal window
1
cargo test -- --test-threads=1

Show Test Output

By default, any prints from within a test will not be shown, if we want to see these we can instead use:

Terminal window
1
cargo test -- --show-output

Running Tests by Name

We can specify a set of tests to run like so:

Terminal window
1
cargo test <SEARCH>

So if we want to run all tests with the word hello:

Terminal window
1
cargo test hello

Ignoring a Test Unless Specified

We can make the default test runner ignore a specific test with the #[ignore] attribute. The test runnner will then only run it if we specify that it should:

Terminal window
1
cargo test -- --ignored

Conventions

Unit Tests

Unit tests are usually contained in the same file as the code being tested in a module called tests

Integration Tests

Integration tests are stored in a tests directory, cargo knows to find integration tests here

Additionally, for code we want to share between integration tests we can create a module in the tests directory and import it from there

Functional Language Features

Closures

Closures are anonymous functions that can capture their outer scope

Defining Closures

Closures can be defined in the following ways:

1
// with type annotations
2
let add_one_v2 = |x: u32| -> u32 { x + 1 };
3
4
// with a multi line body
5
let add_one_v3 = |x| { x + 1 };
6
7
// with a single line body
8
let add_one_v4 = |x| x + 1 ;

Capturing the Environment

A simple closure which immutably borrows a which is defined externally can be created and used like so:

1
fn main() {
2
let a = "Hello";
3
4
let c = || println!("{}", a);
5
6
c();
7
}

Borrowing Mutably

Closures can also make use of mutable borrows. For example, the below closure will add an item to a Vec:

1
fn main() {
2
let mut a = Vec::new();
3
let mut c = || a.push("Hello");
4
5
c();
6
7
println!("{:?}", a);
8
}

Types of Closures

There are three traits that closures can implement, depending on which ones they have the compiler will enforce their usage in certain places:

  • FnOnce - Can only be called once - Applies if it moves captured values out of its body
  • FnMut - Does not move captured values out of its body but may mutate captured values. Can be called more than once
  • Fn - Pure closures, don’t move captured values or mutate them, can be called more than once

Iterators

Iterators allow us to perform a sequence of operations over a sequence.

Iterators in Rust are lazy and are not computed until a method that consumes the iterator is called

The Iterator Trait

All iterators implement the Iterator trait along with a next method and Item type:

1
pub trait Iterator {
2
type Item;
3
4
fn next(&mut self) -> Option<Self::Item>;
5
6
// methods with default implementations elided
7
}

Consuming Adaptors

Methods that call the next method are called consuming adaptors because calling them uses up the iterator

These methods take ownership of the iterator which means that after they are called we can’t use the iterator

Examples of this are the sum or collect methods

Iterator Adaptors

Methods that transform the iterator are called iterator adaptors and they turn the iterator from one type of iterator into another. These can be chainged in order to handle more complex iterator processes

Since iterator adaptors are lazy we need to call a consuming adaptor in order to evaluate a final values

Examples of this are the map or filter methods

Example of Closures and Iterator

An example using iterators with closures can be seen below:

1
pub fn search<'a>(contents: &'a str, query: &str) -> Vec<&'a str> {
2
let lines: Vec<&str> = contents
3
.lines()
4
.filter(|line| line.to_lowercase().contains(&query.to_lowercase()))
5
.collect();
6
7
return lines
8
}

Smart Pointers

A pointer is a general concept for a varaible that contains an addres in memory

Smart pointers act like pointers but have some additional functionality. Smart pointers also differ from references in that they usually own the data

Some smart pointers in the standard library include String and Vec

Smart pointers are implemented as structs that implement Deref and Drop. Deref allows smart pointer to behave like a reference, and Drop allows customization of code that gets run when a smart pointer goes out of scope

Some other smart pointers in the standard library include:

  • Box<T> - allocating values on the heap
  • Rc<T> - counting references to allow for multiple ownership
  • Ref<T>, RefMut<T> - enforce borrowing rules ar runtime

Box<T>

A box is the most straightforward smart pointer, it allows us to store data on the heap along with a pointer to the data on the heap

Boxes don’t have a performance overhead and don’t do lot - often used in the following situations:

  • Types that’s size can’t be known at compile time but we want to use them in places that require an exact size
  • Large amount of data that we want to transfer ownership of but don’t want the data to be copied
  • When you want to own a data but don’t care about the specific type but rather a trait implementation

Using Box<T> to Store Data on the Heap

Using the Box::new we can create a value that will be stored on the heap

1
fn main() {
2
let b = Box::new(5);
3
println!("b = {}", b);
4
}

Often it doesn’t make sense to store simple values like i32 on the heap, this is usually used for more complex data

Recursive Types using Boxes

Recursive types are problematic since rust can’t tell the size of the type at compile time. In order to help, we can Box a value in the recursive type

For example, take the type below which can be used to represent a person’s reporting structure in a company

A Person can either be a Boss or an Employee with a manager who is either another Employee or a Boss

1
enum Person {
2
Boss(String),
3
Employee(String, Person),
4
}

Trying to compile the above will result in the following error:

1
error[E0072]: recursive type `Person` has infinite size
2
--> .\main.rs:1:1
3
|
4
1 | enum Person {
5
| ^^^^^^^^^^^ recursive type has infinite size
6
2 | Boss(String),
7
3 | Employee(String, Person),
8
| ------ recursive without indirection
9
|
10
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `Person` representable
11
|
12
3 | Employee(String, Box<Person>),
13
| ++++ +
14
15
error: aborting due to previous error
16
17
For more information about this error, try `rustc --explain E0072`.

As per the compiler message, we can use a Box which will fix the unknown size issue since it’s size is known at compile time:

1
enum Person {
2
Boss(String),
3
Employee(String, Box<Person>),
4
}

The code can then be used to represent an employee’s reporting hierarchy:

1
#[derive(Debug)]
2
enum Person {
3
Boss(String),
4
Employee(String, Box<Person>),
5
}
6
7
fn main() {
8
let reporting = Person::Employee(
9
String::from("John"),
10
Box::new(Person::Employee(
11
String::from("Jack"),
12
Box::new(Person::Boss(String::from("Jane"))),
13
)),
14
);
15
16
println!("{:?}", reporting);
17
}

Which prints:

1
Employee("John", Employee("Jack", Boss("Jane")))

Boxes only provide indirection and heap allocation and don’t do any other special functions