Introduction

What are we building?

We will build a multithreaded Read Eval Print Loop.

TODO: image of three threads - main, user input, worker - with arrows between them

Why?

The goal is learning how to build an app that can:

  • do multiple tasks concurrently
    • consume user input without blocking
    • parse commands that may have errors without crashing
  • share knowledge between tasks
  • is beginner friendly
    • no async
    • no borrows
    • no locks
    • no mutexes

➕ Sum Types

What are they?

Sum types are quite literally a "sum" of types. The idea is that you take one or more types and put them together in an either/or combination. A sum type is a type that is inhabited by the values of an enumeration of other types.


#![allow(unused)]
fn main() {
enum Bool {
    True,
    False,
}

let no = Bool::False;
}

#![allow(unused)]
fn main() {
enum Bit {
    On,
    Off,
}

let on = Bit::On;
}

But why stop there?! We can nest the values of sum types:


#![allow(unused)]
fn main() {
enum Bool {
    True,
    False,
}

enum Bit {
    On,
    Off,
}

enum BoolOrBit {
    Bool(Bool),
    Bit(Bit),
}

let off = BoolOrBit::Bit(Bit::Off);
}

Now again, but with generics:


#![allow(unused)]
fn main() {
enum Bool {
    True,
    False,
}

enum Bit {
    On,
    Off,
}

enum Either<A, B> {
    Left(A),
    Right(B),
}

let off: Either<Bool, Bit> = Either::Right(Bit::Off);
}

In the above example you can really start to see why they are called "sum types". The possible values in Either<A, B> are literally the sum of the values in A and the values in B.

âœ–ī¸ Product Types

The existance of sum types implies that there exists "product" types, where the possible values in a type are a product of others. Indeed, Rust's struct and tuple types are product types:


#![allow(unused)]
fn main() {
enum Bool {
    True,
    False,
}

enum Bit {
    On,
    Off,
}

struct BoolAndBit {
    boolean: Bool,
    bit: Bit,
}
}

#![allow(unused)]
fn main() {
enum Bool {
    True,
    False,
}

enum Bit {
    On,
    Off,
}

let false_off: (Bool, Bit) = (Bool::False, Bit::Off);
}

You can see here that a tuple of type (A, B) will have A * B value inhabitants. So for our (Bool, Bit) type that would be 2 * 2, or 4 different combinations.

Combine them all

You can even use a short hand for enumerated structs:


#![allow(unused)]
fn main() {
enum Things {
    Cat {
        name: String,
        is_a_prince: bool,
    },
    Mantis {
        fly_heads_consumed: u32,
    },
    Bool(bool),
}
}

🏁 Pattern Matching

What is it?

  • destructuring
  • access to data
  • like equality but for the shape of values
  • branching / control

How do we do it?

Let

When a type has a single constructor you can pattern match using the let keyword:


#![allow(unused)]
fn main() {
struct MyRecord {
    name: String,
    age: u8,
}

fn record_string(record: MyRecord) -> String {
    let MyRecord{ name, age } = record;
    format!("{} is {} years old", name, age)
}

let value = MyRecord {
    name: "Frodo Baggins".to_string(),
    age: 50,
};

println!("{}", record_string(value));
}

But this doesn't work when the type has more than one constructor because we can't handle all the forms that a value might take:

enum Hobbit {
    Frodo {
        has_ring: bool,
    },
    Samwise {
        has_potatoes: bool,
    },
    Other {
        name: String,
        likes: String,
    }
}

fn hobbit_string(hobbit: Hobbit) -> String {
    let Hobbit::Frodo{ has_ring } = hobbit;
    format!("Frodo Baggins {} the ring", if has_ring { "has" } else { "doesn't have" })
}

fn main() {
    println!("{}", hobbit_string(Hobbit::Samwise{ has_potatoes: true }));
}

Using the match keyword:

enum Hobbit {
    Frodo {
        has_ring: bool,
    },
    Samwise {
        has_potatoes: bool,
    },
    Other {
        name: String,
        likes: String,
    }
}

fn hobbit_string(hobbit: Hobbit) -> String {
    match hobbit {
        Hobbit::Frodo{ has_ring } => format!(
            "Frodo Baggins {} the ring",
            if has_ring {
                "has"
            } else {
                "doesn't have"
            }
        ),
        Hobbit::Samwise{ has_potatoes } => format!(
            "Samwise Gamgee ... potatoes? {}",
            if has_potatoes {
                "mix em, mash em, throw em in a stew"
            } else {
                "no thanks"
            }
        ),
        Hobbit::Other{ name, likes } => format!("{} likes {}", name, likes),
    }
}

fn main() {
    println!("{}", hobbit_string(Hobbit::Samwise{ has_potatoes: true}));
}

đŸĨ¸ Traits

A trait is a collection of methods for an unknown type.

Most languages have a similar analogue.

  • Haskell has typeclasses
  • Objective-C has interfaces
  • OCaml has modules
  • Javascript has prototypes

Traits allow us to write generic interfaces for types without having to define the types that will implement these interfaces.

As an example we'll write a Polygon trait.


#![allow(unused)]
fn main() {
/// We can't talk about polygons without talking about points.
pub struct Point(f32, f32);

impl Point {
   pub fn distance_to(&self, other: &Point) -> f32 {
       ((other.0 - self.0).powf(2.0) + (other.1 - self.1).powf(2.0)).sqrt()
   }
}

pub trait Polygon {
    fn name(&self) -> String;

    fn points(&self) -> Vec<Point>;

    fn num_sides(&self) -> usize {
      self.points().len()
    }
}
}

Now we'll implement this trait for a few of our favorite polygons.


#![allow(unused)]
fn main() {
pub trait Polygon {
  fn name(&self) -> String;

  fn points(&self) -> Vec<Point>;

  fn num_sides(&self) -> usize {
    self.points().len()
  }
}

#[derive(Clone)]
pub struct Point(f32, f32);

pub struct Triangle([Point; 3]);

impl Polygon for Triangle {
    fn name(&self) -> String {
        "triangle".to_string()
    }

    fn points(&self) -> Vec<Point> {
        self.0.to_vec()
    }
}

impl Polygon for Triangle {
    fn name(&self) -> String {
        "rectangle".to_string()
    }

    fn points(&self) -> Vec<Point> {
        self.0.to_vec()
    }

    /// Let's save some cycles and provide an "optimized" implementation of `num_sides`
    fn num_sides(&self) -> usize {
        4
    }
}
}

We know from maths that there are an INFINITE number of polygons. Now we can write code that uses Polygons without having to know exactly what polygons we're using:


#![allow(unused)]
fn main() {
pub trait Polygon {
  fn name(&self) -> String;

  fn points(&self) -> Vec<Point>;

  fn num_sides(&self) -> usize {
    self.points().len()
  }
}

#[derive(Clone)]
pub struct Point(f32, f32);

impl Point {
   pub fn distance_to(&self, other: &Point) -> f32 {
       ((other.0 - self.0).powf(2.0) + (other.1 - self.1).powf(2.0)).sqrt()
   }
}
pub struct Triangle([Point; 3]);

impl Polygon for Triangle {
    fn name(&self) -> String {
        "rectangle".to_string()
    }

    fn points(&self) -> Vec<Point> {
        self.0.to_vec()
    }

    fn num_sides(&self) -> usize {
        4
    }
}
pub fn perimeter(poly: impl Polygon) -> f32 {
    let mut p = 0.0;
    let points = poly.points();
    for (p1, p2) in points.iter().zip(points.iter().skip(1)) {
        p += p1.distance_to(p2);
    }
    p
}
}

âš ī¸ Error Handling

Rust has a nice error handling story. Because of sum types we can encode our errors in types. Additionally we can use traits to include special functionality across all error types.

But before we get to that, let's talk about panic!.

panic! at the disco đŸĒŠ đŸ•ē

The panic! macro is a special "macro function" (we haven't talked about macros yet) that terminates the program with a nice error message. It can also display a stack trace if your program is compiled with debugging symbols.

Here are some examples:


#![allow(unused)]
fn main() {
panic!("This burrito is good, but it sure is filling!");
}

#![allow(unused)]
fn main() {
fn only_panic() {
    panic!("The Human Torch was denied a bank loan");
}
}

Panicking is a bit like throwing an exception. It's good if the error is unrecoverable and the program should halt.

Result

With the powers of sum types we can define an error type:


#![allow(unused)]
fn main() {
pub enum OkOrErr<T, E> {
    // Everything is ok
    Ok(T),
    // There was an error
    Err(E),
}
}

And now we can use this in our fallible functions:


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum OkOrErr<T, E> {
    // Everything is ok
    Ok(T),
    // There was an error
    Err(E),
}
pub fn divide(num: f32, denom: f32) -> OkOrErr<f32, String> {
    if denom == 0.0 {
        return OkOrErr::Err("cannot divide - denominator is zero!".to_string());
    }

    OkOrErr::Ok(num / denom)
}

println!("{:?}", divide(1.0, 2.0));
println!("{:?}", divide(1.0, 0.0));
}

The ? Operator

You can imagine that in longer functions it might become a pain in your wrist to continually unwrap or match on result values of OkOrErr::Ok(...) or OkOrErr::Err(...). Indeed it is! That's why Rust has a shorthand operator that means essentially "unwrap the value or short circuit and return the error" - namely the ? character. You may be familiar with this in another language like Swift:


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum OkOrErr<T, E> {
    // Everything is ok
    Ok(T),
    // There was an error
    Err(E),
}
pub fn divide(num: f32, denom: f32) -> OkOrErr<f32, String> {
    if denom == 0.0 {
        return OkOrErr::Err("cannot divide - denominator is zero!".to_string());
    }

    OkOrErr::Ok(num / denom)
}
pub fn divide_some_things() -> OkOrErr<(), String> {
    println!("{}", divide(1.0, 2.0)?);
    println!("{}", divide(1.0, 0.0)?);
    OkOrErr::Ok(())
}

if let OkOrErr::Err(e) = divide_some_things() {
    eprintln!("{}", e);
}
}

You'll see that attempting to run this produces the error:

error[E0277]: the ? operator can only be applied to values that implement Try

So I jumped the gun a bit there - we can't use ? on arbitrary types, at least not without implementing Try for those types first.

But Try is not yet stable (it's fine it's just only in nightly).

But luckily, Rust already provides an error type just like OkOrErr called Result, and Result implements Try, so let's rewrite our functions to use the built-in Result type:


#![allow(unused)]
fn main() {
pub fn divide(num: f32, denom: f32) -> Result<f32, String> {
    if denom == 0.0 {
        // it's notable here that we don't need to prefix `Result::` onto `Err` - this is because
        // use std::result::Result::*; is implicit in all std rust code
        return Err("cannot divide - denominator is zero!".to_string());
    }

    Ok(num / denom)
}
pub fn divide_some_things() -> Result<(), String> {
    println!("{}", divide(1.0, 2.0)?);
    println!("{}", divide(1.0, 0.0)?);
    Ok(())
}

if let Err(e) = divide_some_things() {
    println!("{}", e);
}
}

Option

There are other types that implement Try. Most notably is Option, which is the Rust equivalent of a nullable type:


#![allow(unused)]
fn main() {
pub enum Option<T> {
    Some(T),
    None,
}
}

Because Option implements Try we can use the ? operator to clean up our functions that return Options:


#![allow(unused)]
fn main() {
pub fn only_three(n: u8) -> Option<u8> {
    if n == 3 {
        Some(3)
    } else {
        None
    }
}

pub fn stringify_only_three(n: u8) -> Option<String> {
    let _:u8 = only_three(n)?;
    Some("3".to_string())
}
}

Threads

TODO Closures

Channels

Concurrency

TODO Types and Traits

TODO Plumbing

TODO Polish

TODO Conclusion