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.
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:
- GitHubs intro to Git https://docs.github.com/en/get-started/using-git/about-git
- Git intro with vscode, which you can find here: https://www.youtube.com/watch?v=i_23KUAEtUM
- More info: https://www.youtube.com/playlist?list=PLg7s6cbtAD15G8lNyoaYDuKZSKyJrgwB-
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
.
After confirming with Return
, you can enter the repository URL https://gitlab.com/etrovub/smartnets/rustiec-101
.
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
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) TheDebug
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
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
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
- When is such a data structure more efficient than a standard
Vec
? - What are the downsides, compared to just using a
Vec
? - 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
- 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 theLocalStorageVec
push
appends an item to the end of theLocalStorageVec
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 theLocalStorageVec
, optionally returns it and decrements its length. If the length is 0,pop
returnsNone
insert
inserts an item at the given index and increments the length of theLocalStorageVec
remove
removes an item at the given index and returns it.clear
resets the length of theLocalStorageVec
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 callingvec[1]
;Index<RangeTo<usize>>
, allowing us to get the firstn
items (excluding itemn
) by callingvec[..n]
;Index<RangeFrom<usize>>
, allowing us to get the lastn
items by callingvec[n..]
;Index<Range<usize>>
, allowing us to get the items betweenn
andm
items (excluding itemm
) by callingvec[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 LocalStorageVec
s 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 LocalStorageVec
s containing items of type T
that implement both Copy
and Default
, but iter
is available for all LocalStorageVec
s.
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
withself.as_ref()
results in a stack overflow when running an unoptimized version. Why? (Hint: deref coercion)
Cheat sheet
Necessary tools
- rustup: https://rustup.rs
- cargo: comes with rustup
- rust-analyzer: comes with rustup, but might need an extension for your editor of choice
- git: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git
- CodeLLDB: when using VSCode, this is a handy extension to run your code in the editor
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
- In Rust there is always a single owner for each stack value
- Once the owner goes out of scope any associated values should be cleaned up (drop)
- 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
- One mutable reference at the same time
- Any number of immutable references at the same time as long as there is no mutable reference
- References cannot live longer than their owners
- 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:
Pattern | Meaning |
---|---|
0..10 | Range from 0 to 10 (exclusive) |
0..=10 | Range from 0 to 10 (inclusive) |
0 | 1 | 0 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 |