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 below
  • cargo Rust's build tool and package manager. 99% of Rust projects use cargo.
  • rustc the Rust compiler itself
  • rust-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 the main() function should live.
  • A .git folder - Cargo automatically inits a git repo for you.
    • It also adds a default .gitignore

Cargo has multiple commands that facilitate the building and running of Rust projects

  • cargo run will build and run your code, using main::main as the entry point
  • cargo build will just compile the crate
  • cargo check will check your crate for errors
  • cargo fmt will format your code using rustfmt
  • cargo 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 WidthSignedUnsigned
8i8u8
16i16u16
32i32u32
64i64u64
128i128u128
architecture-definedisizeusize

(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 = '😻';
}

chars 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 structs. 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 NullPointerExceptions!), 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.

1

The actual use of the tree here is more complex, Boxes 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.
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

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 chars. 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);
    }
}