Part 1: Hello, Ferris!
Installing Rust (1.1)
We have a few bits we'll need
rustup
for managing versions of Rust and the other tools belowcargo
Rust's build tool and package manager. 99% of Rust projects use cargo.rustc
the Rust compiler itselfrust-analyzer
the Rust language server
This is made easy with rustup
, which can be installed by running the command below. If you're on a system other than DCS, go to https://rustup.rs for installation instructions.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Follow the on-screen instructions, and this will install Rust's default stable toolchain to $HOME/.cargo
. cargo
can use a large amount of space, so if you are concerned about filling up your home directory (e.g. filling your disk quota), you can set RUSTUP_HOME
and CARGO_HOME
to somewhere else, e.g. /var/tmp/rustup
(this will not persist, but it won't fill your quota).
You'll need to install rust-analyzer
separately. If you're using VS Code, the command
code --install-extension rust-lang.rust-analyzer
will install rust-analyzer
for you. See the rust-analyzer
user manual for instructions for other editors.
Hello World (1.2 & 1.3)
Before we write any code, we need a new Cargo project, or "crate".
cargo new hello_world
Open your new Cargo project, and in the hello_world
folder, you should see:
Cargo.toml
, Cargo's config file, in TOML (Tom’s Obvious, Minimal Language) format.src/main.rs
- The
src
directory is where all source code should live main.rs
is the top-level source file in the crate. It's where themain()
function should live.
- The
- A
.git
folder - Cargo automaticallyinit
s a git repo for you.- It also adds a default
.gitignore
- It also adds a default
Cargo has multiple commands that facilitate the building and running of Rust projects
cargo run
will build and run your code, usingmain::main
as the entry pointcargo build
will just compile the cratecargo check
will check your crate for errorscargo fmt
will format your code using rustfmtcargo clippy
will lint your code using clippy
See The Book, and also The Cargo Book, for more info about Cargo and the Cargo.toml
file.
Open main.rs
in VS Code and, oh look, you don't even need to write hello world: Cargo did it for you. You can delete it and write it out again, if you really want.
fn main() { println!("hello world"); }
- The
fn
keyword is used to declare functions main
is the name of the function- Parentheses
()
is where the parameter list goes, and braces{}
are used to declare blocks - The
println!()
macro is used for command line output (more on macros/functions later)
Variables (3.1)
Variables in Rust are declared, or bound, using the let
keyword:
#![allow(unused)] fn main() { let x = 6; println!("The value of x is: {}", x); }
Variables are immutable by default, meaning their value cannot be changed once they have been bound. This is A Good Thing™ because immutability means less potential for errors. Prefer to leave variables as immutable, unless you absolutely have to, in which case mutable variables can be declared mut
:
fn main() { let mut x = 5; println!("The value of x is: {}", x); x = 6; println!("The value of x is: {}", x); }
We'll talk about type annotations and inference later.
Types
Basic Types (3.2)
Rust has the following numeric types:
Bit Width | Signed | Unsigned |
---|---|---|
8 | i8 | u8 |
16 | i16 | u16 |
32 | i32 | u32 |
64 | i64 | u64 |
128 | i128 | u128 |
architecture-defined | isize | usize |
(usize/isize
are the pointer width of the architecture you're compiling for. You should use them for anything representing size/length/indices of data structures, such as arrays.)
We also have floats, f32
and f64
which are IEEE754 floating point types (equivalent to float
and double
in many languages).
Booleans are of type bool
and are either true
or false
.
Characters are of type char
and are written using single quotes:
#![allow(unused)] fn main() { let c = 'c'; let z = 'Z'; let heart_eyed_cat = '😻'; }
char
s in Rust are four bytes in size, as a char is meant to represent a single Unicode scalar value, meaning it can do much more than just ASCII.
Primitive Compound Types (3.2)
Rust has tuples, which are used extensively. Tuples can be non-homogenous and of any length.
#![allow(unused)] fn main() { let tup = (1, 2); let trip: (char, i32, f64) = ('A', -2, 100.01); }
Tuples are accessed using dot syntax, or destructured by pattern matching (more on this later):
#![allow(unused)] fn main() { let tup = (1, 2); let trip: (char, i32, f64) = ('A', -2, 100.01); let one = tup.0 + tup.1 + trip.1; let (x, y) = tup; let (c, i, _) = trip; }
Note the _
binding, which is used in pattern matching to discard a value:
fn main() { let _ = 1; print!("Can you use _? {}", _); }
Arrays in Rust have a fixed length and are allocated on the stack. They are indexed using brackets []
, and have a type signature [type; length]
. The length of an array is part of it's type in Rust.
fn main() { let a = [1, 2 ,3]; let first = a[0]; let second = a[1]; let b: [f32; 2] = [-2.0, 4.1]; // you can also use shorthand for creating arrays of the same element! let zeroes = [0; 10]; // ten zeroes! }
Attempting to index out of bounds will cause a panic. Panics are Rust's way of throwing unrecoverable errors at runtime, similar to exceptions in other languages. More on those later.
Type Annotations
let
bindings can be annotated with their types using the following syntax:
fn main() { let a: u16 = 4; let b: i32 = -1; let c: char = 'c'; let d: &str = "hello!"; // More on string types shortly let e: (i32, f32) = (12, 12.0); let f: [u32; 3] = [1, 2, 3]; }
This is rarely necessary, as Rust can infer types most of the time. You can also annotate numeric literals with their types:
#![allow(unused)] fn main() { let a = 4_u16; let b = -1_i32; let c = 3.14_f64; }
Composite Data Types
We can use these basic types to build more complex types. Rust has two ways of doing this.
Structures (5)
Those of you familiar with C will be familiar with struct
s. Structs are types made up of several fields, where each field has a name and type.
- Structs can be created by giving values to the types, using the syntax shown.
- Fields can be accessed or updated using dot notation.
- If a struct is bound as immutable, its fields are also immutable.
struct Student { name: String, id: String, year: u32, active: bool, } fn main() { let mut you = Student { name: String::from("Your Name"), id: String::from("1234567"), year: 2, active: false, }; println!("My student ID is: {}", you.id); you.year += 1; println!("My year of study is: {}", you.year); }
Enums (6.1)
Enums restrict variables to a predefined finite set of named discrete values. It's short for enumeration, because it is enumerates of all the allowed options. Many languages have support for enums (e.g. C, Java, Haskell), but Rust's enums are most similar to Java's enums or sum types in functional languages such has Haskell and F#.
Say we want to represent some colours. We have a Thing, and we need it to be either red, green, or blue:
enum Colour { Red, Green, Blue, } fn main() { let thing_colour: Colour = Colour::Red; }
We now have a new type, Colour
, and three new values: Colour::Red
, Colour::Blue
, and Colour::Green
.
What makes enums really special is that variants can contain values:
#![allow(unused)] fn main() { enum MyEnum { NoValue, ANumber(u32), TwoNumbers(u32, i64), AString(String), } }
This is a really powerful feature, especially when used in combination with pattern matching, and part of what makes Rust's type system so good.
An example, using an enum to represent some shapes:
enum Shape { Circle(u64), Rectangle(u64, u64), Triangle(u64, u64, u64), } fn main() { let circle = Shape::Circle(6); let triangle = Shape::Triangle(1, 2, 3); }
Or a recursive definition of a binary tree1:
#![allow(unused)] fn main() { enum Tree { Leaf(i32), Branch(Box<Tree>, Box<Tree>), } }
Option<T>
A commonly used enum is Option<T>
. Option is used to represent values that may or may not exist. Rust has no notion of null
(no more NullPointerException
s!), instead preferring to wrap other types in an Option
when some similar notion of null is needed. This has advantages, as it forces you to explicitly deal with error cases, among other things.
T
is a generic type parameter, which we'll cover in more detail later.
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Think of it as a type-safe container which may or may not be empty. This enum is frequently used as a return type in methods that may fail:
fn div(x: i64, y: i64) -> Option<i64> { if y == 0 { None } else { Some(x/y) } } fn main() { println!("{:?}", div(15, 3)); }
This particular enum is very important as it's used widely throughout the language, and we'll get to it in more detail later.
The actual use of the tree here is more complex, Box
es are smart pointers with heap allocation (for unknown size data). More in the Raytracer Project, so don't worry about these for now.
Functions (3.3)
Functions in Rust are declared using the fn
keyword.
- The list of parameters is declared between the parentheses:
name: type
- The return type is given using an arrow
-> type
- Functions with no return type implicitly return
()
, the unit type.
- Functions with no return type implicitly return
fn main() { println!("this function has no parameters and no return type"); } fn inc(x: i32) -> i32 { println!("this function returns its parameter plus one"); return x+1; } fn mul(a: f64, b: f64) -> f64 { println!("this function returns the product of its parameters"); a*b } fn zip(fst: i32, snd: i32) -> (i32, i32) { (fst, snd) }
Note how in the last examples the return
keyword was not used and there is no semicolon. This is because in Rust, (almost) everything is an expression that returns a value, even blocks. The value of the last expression in a block is the return value of the block. Ending an expression with a semicolon discards the return value. This can be a tricky concept to grasp, see Rust by Example for more detail.
Control Flow (3.5)
if
Expressions
You all hopefully know how these work. The syntax is shown below. In Rust, you don't need parentheses around the condition.
fn main() { let number = 6; if number % 4 == 0 { println!("number is divisible by 4"); } else if number % 3 == 0 { println!("number is divisible by 3"); } else if number % 2 == 0 { println!("number is divisible by 2"); } else { println!("number is not divisible by 4, 3, or 2"); } }
Boolean expressions are combined with
&&
and||
or!
not&&
and||
are lazily evaluated
if
expressions return a value too, so it can be used as a ternary conditional operator:
#![allow(unused)] fn main() { let condition = true; let x = if condition { 5 } else { 6 }; }
loop
Loops
loop
expressions repeat ad infinitum.
loop {
println!("again!");
}
You can break
out of one, if needs be. This is preferred to while true
. You can also use one to construct a do-while:
loop {
do_thing();
if !condition {
break;
}
}
while
Loops
Pretty standard stuff too:
#![allow(unused)] fn main() { let mut number = 3; while number != 0 { println!("{}", number); number -= 1; } }
for
Loops
for
loops are more like Python's, or Java's range-based for loop, than C/C++. They are used to iterate over a collection, using an iterator (more on those later).
#![allow(unused)] fn main() { let a = [10, 20, 30, 40, 50]; for element in a { println!("the value is: {}", element); } }
If you want to loop over a numerical range, you can create a range iterator. Like Python's range, this is exclusive of the last number:
#![allow(unused)] fn main() { for number in 1..10 { println!("{}", number); } for number in (1..10).rev() { // this loop goes from 10 -> 1 println!("{}", number); } }
Strings
Strings in Rust are not so simple. For now, we will consider only String
. String
represents a mutable, variable length buffer which can hold your char
s. Create an empty one with String::new()
, or create one from a literal using String::from()
, .to_owned()
or the trait into
.
fn main() { let empty_string = String::new(); let hello_world = String::from("Hello, World!"); let hello_ferris: &str = "Hello, Ferris"; let owned_ferris = hello_ferris.to_owned(); }
The ::
symbol is used to access associated functions which belong to a type. The from()
and new()
functions both belong to String
, so we call them as shown. Think of them as static methods, for those of you into your Java.
Pattern Matching (6.2)
Those of you familiar with functional languages will be pleased to hear that Rust can do this. Those of you not lucky enough to be familiar with functional languages will likely be a bit confused. Think of it as a fancy switch statement for now. Here's an example, using our Colour
enum from earlier:
enum Colour { Red, Blue, Green, White, Black } fn colour_to_rgb(colour: Colour) -> (u8, u8, u8) { match colour { Colour::Red => (255, 0, 0), Colour::Green => (0, 255, 0), Colour::Blue => (0, 0, 255), Colour::White => (255, 255, 255), Colour::Black => (0, 0, 0), } } fn main() { colour_to_rgb(Colour::Blue); }
The cases come before the =>
, and the return value for that match arm comes after it. The match returns one of those tuples, and then the function returns the return value of the match
. Remember that we don't need a return
However, matching is much more powerful than this. We can include arbitrary expressions after the match arm:
enum Colour { Red, Blue, Green, White, Black } fn colour_to_rgb(colour: Colour) -> (u8, u8, u8) { match colour { Colour::Red => (255, 0, 0), Colour::Green => (0, 255, 0), Colour::Blue => { println!("I'm blue, da ba dee da ba di"); (0, 0, 255) }, Colour::White => if 10 % 2 == 0 { (255, 255, 255) } else { unreachable!("Something is very wrong"); }, Colour::Black => (0, 0, 0), } } fn main() { let rgb = colour_to_rgb(Colour::Blue); println!("{:?}", rgb); }
We can also use it to destructure (unpack) and bind values, for use in the match
body. For example, with Option<T>
:
fn maybe_increment(x: Option<i64>) -> Option<i64> { match x { None => None, Some(i) => Some(i + 1), } } fn main() { let five = Some(5); let six = maybe_increment(five); let none = maybe_increment(None); }
Note that matches must be exhaustive. For example, if you're matching on a u8
, you must cover all cases from 0 to 255. This is impractical, so the placeholder _
can be used, as a default case:
fn main() { let val: u8 = 40; match val { 12 => println!("val is 12"), 21 => println!("val is 21"), _ => println!("val is some other number that we don't care about"), } }
Pattern matching can also make use of further conditions (called guards). These require a fallback case to act as an else, even if it is only a _
. There are other places pattern matching can be used, such as in if let
and while let
expressions.
fn maybe_collatz(x: Option<u64>) -> Option<u64> { match x { None => None, Some(1) => None, Some(i) if i % 2 == 0 => Some(i / 2), Some(i) => Some(3 * i + 1) } } fn main() { let mut number = Some(7_u64); while let Some(i) = number { println!("{i}"); number = maybe_collatz(number); } }