Installing the prerequisites

In this file you'll find instructions on how to install the tools we'll use during the workshop.

All of these tools are available for Linux, macOS and Windows users. We'll need these tools to write and compile our Rust code.

Important: these instructions are to be followed at home, before the start of the workshop. If you have any problems with installation, contact the team! Ruben and Thibaut will be happy to help you out.

Rust and Cargo: rustup

First we'll need rustc, the standard Rust compiler. rustc is generally not invoked directly, but through cargo, the Rust package manager. rustup takes care of installing rustc and cargo.

This part is easy: go to https://rustup.rs and follow the instructions. Please make sure you're installing the latest default toolchain.

On Microsoft Windows, rustup might prompt you to install a linker.

rustup prompts to install Visual C++ redist

Once done, you can run

rustc -V && cargo -V

The output should be something like this:

rustc 1.68.2 (9eb3afe9e 2023-03-27)
cargo 1.68.2 (6feb7c9cf 2023-03-26)

Visual Studio Code

During the workshop, we will use Visual Studio Code (vscode) to write code in. Of course, you're free to use your favorite editor, but if you encounter problems, you can't rely on support from us.

You can find the installation instructions here: https://code.visualstudio.com/.

We will install some plugins as well:

  • The Rust-Analyzer

Installation instructions can be found here https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer. Rust-Analyzer provides a lot of help during development and in indispensable when getting started with Rust.

  • CodeLLDB

This plugin enables debugging Rust code from within vscode. You can find instructions here: https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb.

More info:

Git

We will use Git to fetch the exercises. If you haven't installed Git already, you can find instructions here: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git.

If you're new to Git, you'll also appreciate the following:

Workshop code

Now that everything is installed, you can clone the source code repository. The repository can be found here: https://gitlab.com/etrovub/smartnets/rustiec-101. If you're used to Git, you can clone the repository any way you like. Alternatively, you can use Visual Studio Code to clone the repository.

Cloning from within Visual Studio Code

You can clone the repository in Visual Studio Code itself. First, open the command panel (Control+Shift+P), and type gitcl.

Navigate to Git: Clone in VS Code

After confirming with Return, you can enter the repository URL https://gitlab.com/etrovub/smartnets/rustiec-101.

Enter the repository URL

After confirming "Clone from URL" with Return, Visual Studio Code will ask a directory in which to clone the repository. When asked whether to open the newly cloned workspace, you confirm.

Cloning the classical way

For those familiar to Git, you may opt to clone any way you like. Typically, this would involve the Git command line interface, like so:

git clone https://gitlab.com/etrovub/smartnets/rustiec-101

Github provides documentation about cloning repositories here: https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories#cloning-with-https-urls

Trying it out

Now that you've got the code on your machine, navigate to it using your favorite terminal and run:

cd exercises/0-intro
cargo run

This command may take a while to run the first time, as Cargo will first fetch the crate index from the registry. It will compile and run the intro package, which you can find in exercises/0-intro. If everything goes well, you should see some output:

   Compiling intro v0.1.0 (/home/henkdieter/tg/edu/101-rs/exercises/0-intro)
    Finished dev [unoptimized + debuginfo] target(s) in 0.11s
     Running `target/debug/intro`
🦀 Hello, world! 🦀
You've successfully compiled and run your first Rust project!

If Rust-Analyzer is set up correctly, you can also click the '▶️ Run'-button that is shown in exercises/0-intro/src/main.rs. With CodeLLDB installed correctly, you can also start a debug session by clicking 'Debug', right next to the '▶️ Run'-button. Play a little with setting breakpoints by clicking on a line number, making a red circle appear and stepping over/into/out of functions using the controls. You can view variable values by hovering over them while execution is paused, or by expanding the 'Local' view under 'Variables' in the left panel during a debug session.

Module A1 - Language basics

Slides (or pdf)

A1.1 Basic syntax

Open exercises/A1/1-basic-syntax in your editor. This folder contains a number of exercises with which you can practise basic Rust syntax.

While inside the exercises/A1/1-basic-syntax folder, to get started, run:

cargo run --bin 01

This will try to compile exercise 1. Try and get the example to run, and continue on with the next exercise by replacing the number of the exercise in the cargo run command.

Note: you can also press the ▶️ Run|Debug button to run the main function or tests. These buttons will appear above the main function and tests (it may take some time before they appear) The Debug button might give an error pop-up. This occurs when there are still compiler errors.

A1.1 Basic syntax 01

For this exercise, you need to remember the syntax for functions:

  • The function boundary must always be explicitly annotated with types
  • Within the function body type inference may be used
  • A function that returns nothing has the return type unit (())
  • The function body contains a series of statements optionally ending with an expression
#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn returns_nothing() -> () {
    println!("Nothing to report");
}

fn also_returns_nothing() {
    println!("Nothing to report");
}
}

Some exercises, such as this one, contain unit tests. To run the test in src/bin/01.rs, run:

cargo test --bin 01

A1.1 Basic syntax 02

This exercise contains unit tests as well. To run the test in src/bin/02.rs, run:

cargo test --bin 02

Make sure all tests pass!

A1.1 Basic syntax 03

For this exercise, you will need to remember loop syntax, and control flow:

#![allow(unused)]
fn main() {
let mut x = 0;
loop {
    if x < 5 {
        println!("x: {x}");
        x += 1;
    } else {
        break;
    }
}

let mut y = 5;
while y > 0 {
    y -= 1;
    println!("y: {y}");
}

for i in [1, 2, 3, 4, 5] {
    println!("i: {i}");
}
}

A1.1 Basic syntax 04

For this exercise, you will need to remember loop syntax, and control flow:

  • Control flow expressions as a statement do not need to end with a semicolon if they return unit (())
  • Remember: A block/function can end with an expression, but it needs to have the correct type
#![allow(unused)]
fn main() {
let y = 11;
// if as an expression
let x = if y < 10 {
    42
} else {
    24
};

// if as a statement
if x == 42 {
    println!("Foo");
} else {
    println!("Bar");
}
}

A1.1 Basic syntax 05

For this exercise, you will need to remember how to access an individual value of an array.

A1.2 Move semantics

This exercise is adapted from the move semantics exercise from Rustlings

This exercise enables you to practise with move semantics. It works similarly to exercise A1.1. To get started, open exercises/A1/2-move-semantics in your editor and run:

cargo run --bin 01

For the exercises in this chapter, you will need to remember the following:

  • To create a variable that can be changed later on, we use the keyword mut
  • There is always ever only one owner of a stack value
  • Once the owner goes out of scope (and is removed from the stack), any associated values on the heap will be cleaned up as well
  • Rust transfers ownership for non-copy types: move semantics

Make sure that all the exercises compile. For some exercises, instructions are included as doc comments at the top of the file. Make sure to adhere to them.

Module A2 - Advanced Syntax, Ownership, references

Slides (or pdf)

A2.0 Borrowing

Fix the two examples in the exercises/A2/0-borrowing crate. Don't forget you can run individual binaries by using cargo run --bin 01 in that directory. Make sure to follow the instructions that are in the comments.

When it comes to borrowing in Rust:

  • If a value is borrowed, it is not moved and the ownership stays with the original owner
  • To borrow in Rust, we create a reference (&):
fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    println!("{s}");
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}
  • A mutable reference can even fully replace the original value. To do this, you can use the dereference operator * to modify the value:
#![allow(unused)]
fn main() {
*some_string = String::from("Goodbye");
}

A2.1 Error Propagation

Follow the instructions in the comments of excercises/A2/1-error-propagating/src/main.rs.

  • A really powerful enum is the Result, very useful when we think about error handling and propagation:
#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}
  • Results are so common that there is a special operator associated with them, the ? operator:
#![allow(unused)]
fn main() {
fn can_fail() -> Result<i64, Error> {
  let intermediate_result = divide(10, 0)?;
  Ok(divide(intermediate_result, 0)? * 2)
}
}
  • The ? operator does an implicit match, if there is an error, that error is then immediately returned and the function returns early
  • If the result is Ok(_) then the value is extracted and we can continue right away

A2.2 Slices

Follow the instructions in the comments of excercises/A2/2-slices/src/main.rs. You will have to implement the well-known merge sort algorithm and fix the existing code so it compiles. Don't take too much time on the extra assignment, instead come back later once you've done the rest of the excercises.

The merge sort algorithm follows a divide-and-conquer approach. It breaks down an unsorted list into smaller sub-lists, sorts each of these sub-lists recursively, and eventually merges them back together in the correct order.

A slice is a dynamically sized view into a contiguous sequence:

  • Contiguous: elements are layed out in memory such that they are evenly spaced
  • Dynamically sized: the size of the slice is not stored in the type, but is determined at runtime
  • View: a slice is never an owned data structure

You can use ranges to create a slice from parts of a vector or array:

fn sum(data: &[i32]) -> i32 { /* ... */ }

fn main() {
    let v = vec![0, 1, 2, 3, 4, 5, 6];
    let all = sum(&v[..]);
    let except_first = sum(&v[1..]);
    let except_last = sum(&v[..5]);
    let except_ends = sum(&v[1..5]);
}

The range start..end contains all values x with start <= x < end.

A2.3 Error Handling

Follow the instructions in the comments of excercises/A2/3-error-handling/src/main.rs.

A panic in Rust is the most basic way to handle errors:

  • A panic is an all or nothing kind of error
  • A panic will immediately stop running the current thread/program

What would you do when there is an error? Use the enum Result like mentioned in another exercise:

#![allow(unused)]
fn main() {
enum Result<T, E> {
  Ok(T),
  Err(E),
}

enum DivideError {
  DivisionByZero,
  CannotDivideOne,
}

fn divide(x: i64, y: i64) -> Result<i64, DivideError> {
  if x == 1 {
    Err(DivideError::CannotDivideOne)
  } else if y == 0 {
    Err(DivideError::DivisionByZero)
  } else {
    Ok(x / y)
  }
}
}

The caller may decide how to handle a possible error:

#![allow(unused)]
fn main() {
fn div_zero_fails() {
  match divide(10, 0) {
    Ok(div) => println!("{div}"),
    Err(e) => panic!("Could not divide by zero"),
  }
}
}
  • The divide function’s signature is explicit in how it can fail
  • The function’s caller can decide what to do, even if it is panicking

A2.4 Boxed Data

Follow the instructions in the comments of excercises/A2/4-boxed-data/src/main.rs.

Boxing something is the way to store values on the heap. A Box uniquely owns that value, there is no one else that also owns the same value. Even if the type inside the box is Copy, the box itself is not, move semantics apply to a box.

fn main() {
    // put an integer on the heap
    let boxed_int = Box::new(10);
}

There are several reasons to box a variable on the heap:

  • When something is too large to move around
  • You need something that is sized dynamically
  • For writing recursive data structures

A2.5 Bonus - Ring Buffer

This is a bonus exercise! Follow the instructions in the comments of excercises/A2/5-bonus-ring-buffer/src/main.rs.

Module A3 - Traits and generics

Slides (or pdf)

A3 Local Storage Vec

In this exercise, we'll create a type called LocalStorageVec, which is generic list of items that resides either on the stack or the heap, depending on its size. If its size is small enough for items to be put on the stack, the LocalStorageVec buffer is backed by an array. LocalStorageVec is not only generic over the type (T) of items in the list, but also by the size (N) of this stack-located array using a relatively new feature called "const generics". Once the LocalStorageVec contains more items than fit in the array, a heap based Vec is allocated as space for the items to reside in.

The syntax for const generics is like normal generics, but instead of being generic over a type, it is generic over a constant. For now mostly numeric types are allowed with const generics, but as the language further matures, more options will be added.

#![allow(unused)]
fn main() {
// const generics start with `const`, followed by a name and specified with a type
fn my_const_generic_function<const N: usize>(array_of_size_n: [u32; N]) -> u32 { // you can use N in other types
    if N > 0 { // also usable as any other constant
        array_of_size_n[N-1]
    } else {
        0
    }
}
}

Within this exercise, the objectives are annotated with a number of stars (⭐), indicating the difficulty. You are likely not to be able to finish all exercises during the workshop

Questions

  1. When is such a data structure more efficient than a standard Vec?
  2. What are the downsides, compared to just using a Vec?
  3. Can you come up with a situation where a Vec would not be possible to be used?

Open the exercises/A3/2-local-storage-vec crate. It contains a src/lib.rs file, meaning this crate is a library. lib.rs contains a number of tests, which can be run by calling cargo test. Don't worry if they don't pass or even compile right now: it's your job to fix that in this exercise. Most of the tests are commented out right now, to enable a step-by-step approach. Before you begin, have a look at the code and the comments in there, they contain various helpful clues.

Make sure that all the commands you execute are in exercises/A3/2-local-storage-vec (same where Cargo.toml-file is).

A3.A Defining the type ⭐

Currently, the LocalStorageVec enum is incomplete. Give it two variants: Stack and Heap. Stack contains two named fields, buf and len. buf will be the array with a capacity to hold N items of type T; len is a field of type usize that will denote the amount of items actually stored. The Heap variant has an unnamed field containing a Vec<T>. If you've defined the LocalStorageVec variants correctly, running cargo test should output something like

running 1 test
test test::it_compiles ... ignored, This test is just to validate the definition of `LocalStorageVec`. If it compiles, all is OK

test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

This test does (and should) not run, but is just there for checking your variant definition.

Hint 1 You may be able to reverse-engineer the `LocalStorageVec` definition using the code of the `it_compiles` test case.

Hint 2 (If you got stuck, but try to resist me for a while)

Below definition works. Read the code comments and make sure you understand what's going on.

#![allow(unused)]
fn main() {
// Define an enum `LocalStorageVec` that is generic over
// type `T` and a constant `N` of type `usize`
pub enum LocalStorageVec<T, const N: usize> {
    // Define a struct-like variant called `Stack` containing two named fields:
    // - `buf` is an array with elements of `T` of size `N`
    // - `len` is a field of type `usize`
    Stack { buf: [T; N], len: usize },
    // Define a tuple-like variant called `Heap`, containing a single field
    // of type `Vec<T>`, which is a heap-based growable, contiguous list of `T`
    Heap(Vec<T>),
}
}

A3.B impl-ing From<Vec<T>>

The definition of the From<T>-trait (with some extra explanation):

#![allow(unused)]
fn main() {
// `Sized` means here that at compile time Self must have a known size.
// All types automatically are `Sized` unless 1 of their fields is not 
// `Sized`. For now, you may assume that all types you encounter in these
// exercises will be `Sized`.
pub trait From<T>: Sized {
    /// Convert value T to the type of Self
    /// This then allows, ie. `Self::from(value)` and 
    /// `let value: T = ...; let a: Self = value.into()`
    fn from(value: T) -> Self;
}
}

Uncomment the test it_from_vecs, and add an implementation for From<Vec<T>> to LocalStorageVec<T>. To do so, copy the following code in your lib.rs file and replace the todo! macro invocation with your code that creates a heap-based LocalStorageVec containing the passed Vec<T>.

#![allow(unused)]
fn main() {
impl<T, const N: usize> From<Vec<T>> for LocalStorageVec<T, N> {
    fn from(v: Vec<T>) -> Self {
        todo!("Implement me");
    }
}
}

Question

  1. How would you pronounce the first line of the code you just copied in English?*

Run cargo test to validate your implementation.

A3.C impl LocalStorageVec ⭐⭐

To make the LocalStorageVec more useful, we'll add more methods to it. Create an impl-block for LocalStorageVec. Don't forget to declare and provide the generic parameters. For now, to make implementations easier, we will add a bound T, requiring that it implements Copy and Default. First off, uncomment the test called it_constructs. Make it compile and pass by creating a associated function called new on LocalStorageVec that creates a new, empty LocalStorageVec instance without heap allocation.

The next methods we'll implement are len, push, pop, insert, remove and clear:

  • len returns the length of the LocalStorageVec
  • push appends an item to the end of the LocalStorageVec and increments its length. Possibly moves the contents to the heap if they no longer fit on the stack.
  • pop removes an item from the end of the LocalStorageVec, optionally returns it and decrements its length. If the length is 0, pop returns None
  • insert inserts an item at the given index and increments the length of the LocalStorageVec
  • remove removes an item at the given index and returns it.
  • clear resets the length of the LocalStorageVec to 0.

Here is simple skeleton to get you started:

#![allow(unused)]
fn main() {
impl<T, const N: usize> LocalStorageVec<T, N>
where
    T: Default + Copy
{
    pub fn new() -> Self {
        // Default types provide a static method that creates a default 
        // value for a specific type
        let array = [T::default(); ...]; // FIXME
        todo!("Create here the stack representation of Self")
    }

    // ... more methods coming here
}
}

Uncomment the corresponding test cases and make them compile and pass. Be sure to have a look at the methods provided for slices [T] and Vec<T> Specifically, [T]::copy_within and Vec::extend_from_slice can be of use.

A3.D Iterator and IntoIterator ⭐⭐

Our LocalStorageVec can be used in the real world now, but we still shouldn't be satisfied. There are various traits in the standard library that we can implement for our LocalStorageVec that would make users of our crate happy.

First off, we will implement the IntoIterator and Iterator traits. Go ahead and uncomment the it_iters test case. Let's define a new type:

#![allow(unused)]
fn main() {
pub struct LocalStorageVecIter<T, const N: usize> {
    vec: LocalStorageVec<T, N>,
    counter: usize,
}
}

This is the type we'll implement the Iterator trait on. You'll need to specify the item this Iterator implementation yields, as well as an implementation for Iterator::next, which yields the next item. You'll be able to make this easier by bounding T to Default when implementing the Iterator trait, as then you can use the std::mem::take function to take an item from the LocalStorageVec and replace it with the default value for T.

Take a look at the list of methods under the 'provided methods' section. In there, lots of useful methods that come free with the implementation of the Iterator trait are defined, and implemented in terms of the next method. Knowing in the back of your head what methods there are, greatly helps in improving your efficiency in programming with Rust. Which of the provided methods can you override in order to make the implementation of LocalStorageVecIter more efficient, given that we can access the fields and methods of LocalStorageVec?

Now to instantiate a LocalStorageVecIter, implement the [IntoIter] trait for it, in such a way that calling into_iter yields a LocalStorageVecIter.

A3.E AsRef and AsMut ⭐⭐

AsRef and AsMut are used to implement cheap reference-to-reference coercion. For instance, our LocalStorageVec<T, N> is somewhat similar to a slice &[T], as both represent a contiguous series of T values. This is true whether the LocalStorageVec buffer resides on the stack or on the heap.

Uncomment the it_as_refs test case and implement AsRef<[T]> and AsMut<[T]>.

Hint Make sure to take into account the value of `len` for the `Stack` variant of `LocalStorageVec` when creating a slice.

A3.F Index ⭐⭐

To allow users of the LocalStorageVec to read items or slices from its buffer, we can implement the Index trait. This trait is generic over the type of the item used for indexing. In order to make our LocalStorageVec versatile, we should implement:

  • Index<usize>, allowing us to get a single item by calling vec[1];
  • Index<RangeTo<usize>>, allowing us to get the first n items (excluding item n) by calling vec[..n];
  • Index<RangeFrom<usize>>, allowing us to get the last n items by calling vec[n..];
  • Index<Range<usize>>, allowing us to get the items between n and m items (excluding item m) by calling vec[n..m];

Each of these implementations can be implemented in terms of the as_ref implementation, as slices [T] all support indexing by the previous types. That is, [T] also implements Index for those types. Uncomment the it_indexes test case and run cargo test in order to validate your implementation.

A3.G Removing bounds ⭐⭐

When we implemented the borrowing Iterator, we saw that it's possible to define methods in separate impl blocks with different type bounds. Some of the functionality you wrote used the assumption that T is both Copy and Default. However, this means that each of those methods are only defined for LocalStorageVecs containing items of type T that in fact do implement Copy and Default, which is not ideal. How many methods can you rewrite having one or both of these bounds removed?

A3.H Borrowing Iterator ⭐⭐⭐

We've already got an iterator for LocalStorageVec, though it has the limitation that in order to construct it, the LocalStorageVec needs to be consumed. What if we only want to iterate over the items, and not consume them? We will need another iterator type, one that contains an immutable reference to the LocalStorageVec and that will thus need a lifetime annotation. Add a method called iter to LocalStorageVec that takes a shared &self reference, and instantiates the borrowing iterator. Implement the Iterator trait with the appropriate Item reference type for your borrowing iterator. To validate your code, uncomment and run the it_borrowing_iters test case.

Note that this time, the test won't compile if you require the items of LocalStorageVec be Copy! That means you'll have to define LocalStorageVec::iter in a new impl block that does not put this bound on T:

#![allow(unused)]
fn main() {
impl<T: Default + Copy, const N: usize> LocalStorageVec<T, N> {
    // Methods you've implemented so far
}

impl<T: const N: usize> LocalStorageVec<T, N> {
    pub fn iter(&self) -> /* TODO */
}
}

Defining methods in separate impl blocks means some methods are not available for certain instances of the generic type. In our case, the new method is only available for LocalStorageVecs containing items of type T that implement both Copy and Default, but iter is available for all LocalStorageVecs.

A3.I Generic Index ⭐⭐⭐⭐

You've probably duplicated a lot of code in the last exercise. We can reduce the boilerplate by defining an empty trait:

#![allow(unused)]
fn main() {
trait LocalStorageVecIndex {}
}

First, implement this trait for usize, RangeTo<usize>, RangeFrom<usize>, and Range<usize>.

Next, replace the implementations from the previous exercise with a blanket implementation of Index. In English:

"For each type T, I and constant N of type usize, implement Index<I> for LocalStorageVec<T, N>, where I implements LocalStorageVecIndex and [T] implements Index<I>"

If you've done this correctly, it_indexes should again compile and pass.

If this exercise went well, you should take a look at SliceIndex<[T]> and try to see how your implementation can be simplified.

A3.J Deref and DerefMut ⭐⭐⭐⭐

The next trait that makes our LocalStorageVec more flexible in use are Deref and DerefMut that utilize the 'deref coercion' feature of Rust to allow types to be treated as if they were some type they look like. That would allow us to use any method that is defined on [T] by calling them on a LocalStorageVec. Before continuing, read the section 'Treating a Type Like a Reference by Implementing the Deref Trait' from The Rust Programming Language (TRPL). Don't confuse deref coercion with any kind of inheritance! Using Deref and DerefMut for inheritance is frowned upon in Rust.

Below, an implementation of Deref and DerefMut is provided in terms of the AsRef and AsMut implementations. Notice the specific way in which as_ref and as_mut are called.

#![allow(unused)]
fn main() {
impl<T, const N: usize> Deref for LocalStorageVec<T, N> {
    type Target = [T];

    fn deref(&self) -> &Self::Target {
        <Self as AsRef<[T]>>::as_ref(self)
    }
}

impl<T, const N: usize> DerefMut for LocalStorageVec<T, N> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        <Self as AsMut<[T]>>::as_mut(self)
    }
}
}

Question

  • Replacing the implementation of deref with self.as_ref() results in a stack overflow when running an unoptimized version. Why? (Hint: deref coercion)

Cheat sheet

Necessary tools

Basic syntax

Assign variables:

#![allow(unused)]
fn main() {
let a: u32 = 42; // immutable
let mut b: u32 = 42; // mutable
}

If-Else:

#![allow(unused)]
fn main() {
let a = if 1 < 2 { // optionally use as expression
    true // last statement without ; is the result of this branch
} else if 3 < 2 { // else if does also exist
    false
} else {
    true
}; // as this is used as an expression, ; is mandatory
}

If-let:

#![allow(unused)]
fn main() {
let a = Some(42); // package the number in a Some
if let Some(number) = a { // pattern match with any pattern
    assert_eq!(number, 42); // use the captured value(s)
}
assert_eq!(a, Some(42));
}

Match-clauses:

// like a switch in C with super powers
let b = match my_lucky_number {
    42 => ..., // have a direct expression (ends with ,)
    69 => {...} // can also have a block (does not end with ,)
    1..=10 => ..., // can use ranges
    n if n % == 0 => ..., // can use arbitrary conditions
    100 | 101 => ..., // can use | to combine patterns
    _ => ..., // match always needs to be exhaustive, this is similar to a default branch
}; // same as above, match used as expr -> needs to end in ;

For-loop:

for i in 0..10 { ... } // equivalent to in C `for (int i = 0; i < 10; i++) {}`
for c in "hello world".chars().take(5) { ... } // use any iterator

While-loop:

#![allow(unused)]
fn main() {
let mut a = 0;
while a < 10 { a += 1; } // notice no ()
// iterator is Some while there are is more to come
let mut iterator = "hello world".chars(); 
while let Some(c) = iterator.next() { println!("{c}"); } // repeat while the pattern matches
}

Infinite-loop:

loop { ... }

Functions:

fn add(a: i32, b: i32) -> i32 { return a + b; }
fn add2(a: i32, b: i32) -> i32 { a + b } // last expression is returned if not terminated with ;
pub fn main() { ... } // public main, entrypoint to the program
pub fn main() -> () { ... } // () is no return, equivalent to previous
pub fn main() -> Result<(), MyError> { ... } // fallible main

Compound types

Tuples:

#![allow(unused)]
fn main() {
let a: (u32, String) = (999, "Santa".to_string());
println!("first: {}, second: {}", a.0, a.1);
}

Structs:

#![allow(unused)]
fn main() {
struct MyEmptyStruct; // unit struct (zero size)
struct MyTupleStruct(u32, String); // contains only a list of items
struct MyStruct1 { age: u32, name: String } // give a name to the items
pub struct MyStruct2 { age: u32, name: String } // it may be public
pub struct MyStruct3 { pub age: u32, pub name: String } // fields may be public

let a = MyStruct1 {
    age: 999,
    name: "Santa".to_string(), // trailing comma optional
};
}

Enums: combination of class enums (C++/Java) and tagged unions (C/C++)

#![allow(unused)]
fn main() {
enum MyEnum {
    Empty,
    ThisIsATuple(u32, String),
    ThisIsAStruct {
        age: u32,
        name: String,
    }
}
let a = MyEnum::ThisIsAStruct {
    age: 999,
    name: "Santa".to_string(),
};
}

Arrays:

#![allow(unused)]
fn main() {
let arr: [i32; 3] = [1, 2, 3];
println!("{:?}", arr); // Print debug representation of the array
println!("[{}, {}, {}]", arr[0], arr[1], arr[2]);
let [a, b, c] = arr; // Array destructuring
println!("[{a}, {b}, {c}]");
}

The 3 rules of ownership

  1. In Rust there is always a single owner for each stack value
  2. Once the owner goes out of scope any associated values should be cleaned up (drop)
  3. Copy types creates copies (implicitly), all other types are moved
fn main() {
    let first_owner = String::from("Hello world");
    let new_owner = first_owner; // first_owner goes here out of scope
    // println!("{}", first_owner); // <-- ERROR, first_owner no longer exists
    let copy_of_owner = new_owner.clone();
    println!("new_owner: {}", new_owner);
    println!("copy_of_owner: {}", copy_of_owner);
}

The 4 rules of borrowing

  1. One mutable reference at the same time
  2. Any number of immutable references at the same time as long as there is no mutable reference
  3. References cannot live longer than their owners
  4. A reference will always point to a valid value
#![allow(unused)]
fn main() {
let mut s = String::from("Hello ");
{ // -> start a new scope
    let ref_to_s = &mut s;
    *ref_to_s += "world";
    // println!("{}", &s); // ERROR: RULE 2
    println!("{}", ref_to_s);
} // <- ref_to_s goes of scope
println!("{}", &s);
drop(s); // <- s goes out of scope
// println!("{}", &s); // ERROR: RULE 3
}

Generics

#![allow(unused)]
fn main() {
use std::ops::Add; // import trait, so it can be used

// use the derive-macro to auto implement traits
#[derive(Clone, Copy, Debug, PartialEq)]
// add generic parameter with the constraint that it needs to implement Add<Rhs=T>
pub struct Point<T: Add<T>> { pub x: T, pub y: T }
// Rust requires us to constrain T for a impl block
impl<T: Add<T>> Point<T> {
    pub fn new(x1: T, y: T) -> Self {
        Self {
            x: x1,
            y, // => shorthand notation 
        } // no ; as we are returning
    }
}

impl<T> Add<Self> for Point<T>
where
    T: Add<T, Output=T> // alternative way to constrain T
{ 
    type Output = Self; // associated type, like in Scala

    fn add(self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

let a = Point::new(1, 2);
let b = Point::new(3, 4);
let c = a + b;
println!("x: {}, y: {}", c.x, c.y);
}

Traits

#![allow(unused)]
fn main() {
use std::ops::Div;
// define new trait
pub trait Numeric {
    // define static function
    fn zero() -> Self;
}

// define trait that requires the implementation of another type
// This trait is used as a marker for floats
pub trait Floaty: Numeric { }

impl Numeric for u8 { fn zero() -> Self { 0 } }
impl Numeric for u16 { fn zero() -> Self { 0 } }
impl Numeric for u32 { fn zero() -> Self { 0 } }
impl Numeric for u64 { fn zero() -> Self { 0 } }
impl Numeric for u128 { fn zero() -> Self { 0 } }
impl Numeric for usize { fn zero() -> Self { 0 } }

impl Numeric for i8 { fn zero() -> Self { 0 } }
impl Numeric for i16 { fn zero() -> Self { 0 } }
impl Numeric for i32 { fn zero() -> Self { 0 } }
impl Numeric for i64 { fn zero() -> Self { 0 } }
impl Numeric for i128 { fn zero() -> Self { 0 } }
impl Numeric for isize { fn zero() -> Self { 0 } }

impl Numeric for f32 { fn zero() -> Self { 0.0 } }
impl Floaty for f32 { }
impl Numeric for f64 { fn zero() -> Self { 0.0 } }
impl Floaty for f64 { }

// use the above traits to constrain T, so we can do all the checks
fn divide<T>(a: T, b: T) -> Option<T>
where
    T: Numeric + PartialEq + Div<Output=T>
{
    if b == T::zero() {
        None
    } else {
        Some(a / b)
    }
}
// uses the Floaty marker trait to force only the use of floating point numbers
fn accept_only_floats<T: Floaty>(f: T) -> T { f }

println!("{:?}", divide(5, 2));
// println!("{:?}", divide(5.0, 2)); // ERROR: f64 != i32
accept_only_floats(2.0f32);
// accept_only_floats(2u32); // ERROR
}

Orphan rule

Traits can be implemented for a type iff:

  • Either your crate defines the trait
  • Or your crate defines the trait

Common traits in std

  • std::ops::{Add, Mul, Div, Sub}
  • std::marker::{Sized, Sync, Send}
  • std::default::Default
  • std::{clone::Clone, marker::Copy}
  • Into/From
  • AsRef/AsMut
  • std::ops::Drop

Lifetime annotations

#![allow(unused)]
fn main() {
struct Foo {}
impl Foo {
fn return_first<'a, 'b>(a: &'a str, b: &'b str) -> &'a str { a }
fn return_static() -> &'static str { "hello world" }
fn return_self(&self) -> &Self { self }
// fn return_self<'a>(&'a self) -> &'a Self { self } // previous expanded
// add constraints on lifetimes
fn a_longer_than_b<'a: 'b, 'b>(a: &'a str, b: &'b str) -> &'a str { a }
}

// higher kinded lifetimes
trait TakesRef<'a, T> {
    // return something with a lifetime on the trait level
    fn foo() -> &'a T;
}
// make T generic for all lifetimes 'a
fn use_lifetime<T>()
where
    T: for<'a> TakesRef<'a, u32>,
{
    println!("{}", T::foo());
}
}

Pattern matching

Patterns that can be commonly used in a match:

PatternMeaning
0..10Range from 0 to 10 (exclusive)
0..=10Range from 0 to 10 (inclusive)
0 | 10 or 1
Some(value)Destructuring of Some and capturing value
Some(Some(value))Destructuring can be nested
[a, b, c, ..]Destructuring first 3 elements and allow more
(a, b, c, ..)Destructuring tuple
MyStruct { age, name, .. }Destructuring struct and allow for more fields