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 Polygon
s 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 implementTry
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 Option
s:
#![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()) } }