Rust Workshop

Table of Contents

1 welcome

This workshop's primary purpose is to introduce Rust to an audience of moderately-to-highly experienced programmers, and give them enough tools to be able to continue studying the language independently.

1.1 introducing the mentors

1.2 introducing the audience

1.3 a short outline of the workshop

The workshop is divided into two parts of roughly the same length.

The first part is structured as a tutorial where we go through the basics of Rust and its tooling, some of the more important and non-obvious language features, and a few items from the ecosystem that might be interesting, including a generic serialization/deserialization library, a command-line argument parser, a server-side web framework, and a client-side web framework.

The second part is a hands-on, guided coding session, where we will try to build a fairly simple, yet functional feed reader webapp. We will start by defining the server-side API, and if time permits, we might conclude with a browser-based WebAssembly client.

2 a very short introduction to Rust

Rust is a new(-ish) general-purpose, multi-paradigm programming language focused on "systems" programming.

Development started in 2006 as a personal project of Graydon Hoare; Mozilla started sponsoring its development in 2009. rustc became self-hosting in 2011, and the first stable version, rustc 1.0, was released in 2015.

Initially, the compiler was built in OCaml, and Rust shared a significant amount of both aesthetic and functional traits with the language. With time, the aesthetics shifted towards a C-like syntax, but the spirit of OCaml is still present. The compiler uses LLVM as its backend; rustc ships with LLVM bundled.

Rust is based on three pillars: performance, reliability and productivity. It doesn't have a garbage collector, but instead uses a fairly sophisticated "ownership" and "lifetime" system to determine when a variable leaves the scope, freeing the memory.

Rust also has an "unsafe" mode where many guarantees provided by the compiler are turned off, but which allows for much more freedom in manual memory management, as well as interfacing with unsafe code.

There are a lot of efforts to make the language target WebAssembly, and as a result, Rust has one of the better-developed wasm ecosystems out there.

3 a crash-course in Rust

3.1 installing Rust on your system

The canonical way of installing Rust is using rustup, a helper program that takes care of downloading the various components of the infrastructure for building Rust programs.

Rust is also packaged for many operating systems, but the compiler is updated and released in a regular fashion, and these packages are usually at least slightly out of date. In general, you want to have a way of getting your hands onto as recent version of the compiler as possible.

As a bonus, rustup allows you to have multiple versions of the compiler installed on the system, as well as seamless switching between them on demand. This is useful if you want to play around with, e.g., the nightly builds of the compiler.

3.1.1 Exercises

  1. Install rustup on your system and use it to fetch the latest stable version of rustc.

3.2 versions of Rust

3.2.1 stable vs. nightly

The Rust compiler comes in two main "flavors": stable and nightly.

The "stable" compiler is the regular, production-quality release which is well-documented and stable.

The "nightly" is packed with various experimental and unstable features. These features usually need to be explicitly enabled using various compiler hints, and their quality is fairly uneven.

We will be dealing exclusively with the stable compiler, but sooner or later you can expect to run into crates or features that are available only if you use nightly.

3.2.2 2015 vs. 2018

Another important distinction that you're bound to run into is the Rust "edition".

There are currently two editions: 2015 and 2018, and it's fairly easy to switch between them on a per-package basis. The 2018 edition changes some of the semantics of the language in a way that is not backwards compatible with the 2015 edition, but that doesn't necessarily warrant an increase in the major version of the compiler.

An example of such feature is the try keyword – in the 2015 edition, this was not a reserved word in the Rust language, and you've been able to use it as a variable name. In the 2018 edition, this is no longer the case.

We will be exclusively covering the 2018 edition during this workshop.

3.2.3 Exercises

  1. Use rustup to install the nightly version of rustc and switch between the two versions. Confirm with rustc --version.

3.3 Hello, World!

…in which we write the proverbial archeprogram.

Each Rust binary has an entry point called main. Main usually returns the "unit" type (i.e. "nothing", void).

We print stuff to stdout in Rust using one of two macros: print! or println!. These are macros, among other reasons, due to one significant limitation of Rust: the lack of support for variadic functions.

Since this is our first contact with Rust, here's the program in its entirety:

fn main() {
  println!("Hello, World!");
}

3.3.1 Exercises

  1. Create a file called hello.rs with the program from the listing. Compile it with rustc.
  2. Observe the resulting executable. Notice its file size. Try running ldd on the binary.

3.4 Hello, Cargo!

Cargo is the standard build and dependency management tool for Rust. It takes care of:

  • fetching and compiling the dependencies (both internal and external) of your project;
  • building your project;
  • creating distributable packages for your project (and optionally uploading them to the public).

(Do exercise 1.)

Cargo "packages" are called "crates". By default, Cargo will create a "binary" crate; to create a "library" crate, you can pass --lib to cargo init. A crate can generate both binaries and a library at the same time by tweaking the configuration file by hand.

The vast majority of Cargo configuration is done through the Cargo.toml file in the project root. This will list your dependencies, the files in the project you would like to build, and some basic metainformation about the crate.

(Do exercises 2 and 3.)

3.4.1 Exercises

  1. Install cargo using rustup.
  2. Set up a new crate called hello, and move the previously written program into a crate. Build it with cargo build and run it with cargo run. Inspect the generated Cargo.lock file.
  3. To demonstrate dependency management, set up a new crate called hello_strings as a library with a single function with the following signature:

    pub fn get_string() -> String {
       return String::from("Hello, World!");
    }
    

    Include hello_strings as a dependency of hello and use the newly written function to fetch the string to be written to the screen.

3.5 Rust syntax

3.5.1 defining "variables"

// the compiler will infer the type of `a`
let a = 32;

// we can also set the type explicitly
let a : u32 = 32;

// values are immutable by default...
let mut a = 32;
a = 42;

3.5.2 defining functions

// returns are implicit
fn a_function -> u32 {
  42
}

// ...but they can be explicit, too
fn another_function -> u32 {
  return 42;
}

3.5.3 branching

let a = 2;

// `if`s work as expected
if a == 2 {
  println!("Bingo!");
}

// we can also `match` on values, but these have to be exhaustive.
match a {
  2 => println!("Bingo"),
  _ => println!("Better luck next time."),
};

3.5.4 structures and enums

Rust supports algebraic data types in the form of product types (structs) and sum types (enums).

// defining enums
enum Genre {
    Crunk,
    Ghettotech,
    // variants can have parameters
    Other(String),
    // another option:
    // Other { name: String },
    Powerviolence,
    Psychobilly,
    Zeuhl,
}

struct Album {
    artist: String,
    title: String,
    year: u16,
    genre: Genre,
}

fn main() -> () {
    let album = Album {
        artist: String::from("Nick Cave & The Bad Seeds"),
        title: String::from("Ghosteen"),
        year: 2019,
        genre: Genre::Other(String::from("Sad")),
    };

  if let Genre::Other(genre) = album.genre {
      println!("{}", genre);
  }
}

3.5.5 looping

while and loop are the standard looping constructs.

fn main() -> () {
    let mut i = 10;

    while i > 0 {
        i -= 1;
    }

    loop {
        if i >= 10 { break; }
        i += 1;
    }

    println!("{}", i);
}

for is used to loop over iterators.

fn main() -> () {
    for i in 0..10 {
      println!("{}", i);
    }
}

3.5.6 closures

There is support for anonymous functions, and they are used pervasively throughout the standard library.

fn main() -> () {
    let to_ordinal = |x: &i32| {
      let x_str = x.to_string();
      let suffix =
          if x_str.ends_with("1") { "st" }
          else if x_str.ends_with("2") { "nd" }
          else if x_str.ends_with("3") { "rd" }
          else { "th" };

      format!("{}{}", x_str, suffix)
    };

    let numbers = vec![1, 23, 32, 44, 0, -1];
    let ordinals : Vec<String> = numbers.iter().map(to_ordinal).collect();
    println!("{:?}", ordinals);
}

We will talk again about the types of closures in the section on Traits, and about the ownership of values captured by the closure in the section on Ownership.

3.5.7 scopes

Rust has a concept of "scopes" which can be used to declare blocks of code which return values, as well as encapsulate values into temporary blocks.

fn main() -> () {
    let a = 2;
    let b = {
      let a = 3;
      a * 6 // remember implicit returns!
    };

    println!("{}", b); // prints out 18
}

3.5.8 macros

Rust has a mechanism of augmenting syntax with macros. In-depth discussion about macros is outside of the scope of this workshop, but we'll try to hint at what's possible.

Macros come in two general flavors: declarative and procedural.

  1. declarative macros

    This is the most straightforward and most constrained type of macro. It allows us to match against and capture a predefined set of syntax patterns – i.e., we can match against an expression, or a type, or a block. These will be syntax-checked during compilation; there are some limited options for capturing arbitrary tokens, but this gets fairly ugly fairly quickly.

    An example of a declarative macro is the vec! macro from the standard library. We can try to implement a similar macro for ourselves. Remember that in Rust, blocks can return values:

    macro_rules! vec2 {
        ( $( $x:expr ),* ) => {
            {
                let mut temp_vec = Vec::new();
                $(
                    temp_vec.push($x);
                )*
                temp_vec
            }
        };
    }
    
    fn main() -> () {
        let v = vec2![1, 2, 3];
        println!("{:?}", v);
    }
    
  2. procedural macros

    Unlike declarative macros, procedural macros are functions that do not match on various components of the syntax, but rather accept an entire stream of tokens as their input, and produce another token stream as their output.

    There are three types of these macros:

    • custom #[derive] macros, allowing us to semi-automatically implement traits;
    • arbitrary attribute macros, which serve as function decorators;
    • function-like macros that allow us to operate on tokens and implement arbitrary syntax (see: bodil/typed-html: Type checked JSX for Rust).
  3. further reading

3.5.9 Exercises

  1. Write a function which calculates a factorial of a given number.

3.6 Generics and Pattern Matching

Rust has a rich set of tools to help you write code that works across data types. Generics allow us to write code which will work with any data type. For example, we can implement the Either data type:

enum Either<L, R> {
  Left(L),
  Right(R),
}

fn main() -> () {
  // the compiler can infer the type for "R", but has no way of infering it
  // for "L", thus we have to annotate explicitly.
  let a : Either<&str, u32> = Either::Right(42);

  // the compiler can infer the type for "L", but has no way of infering it
  // for "R", thus we have to annotate explicitly.
  let b : Either<&str, u32> = Either::Left("Error.");
}
// we can also destructure within `if`s with `if let`
if let Either::Right(result) = a {
  // "result" is now == 42
}

// of course, `match` shines here...
let end_result = match a {
  Either::Left(_)  => -1,    // `_` is a convention for "discard this value"
  Either::Right(t) => t / 2, // `end_result` would be 21
};

The vast majority of container types in the standard library are generic. Vectors are std::vec::Vec<T>, hashmaps are std::collections::HashMap<K, V>, etc.

Rust performs "monomorphization" of generic code, i.e. it generates code with concrete types at compile time, eliminating runtime overhead.

3.6.1 Exercises

  1. Rewrite the factorial function to return Either<String, u32>. In case the argument is less than or equal to 20, return Either::Right with the result, and in case it is greater than 20, return Either::Left with an appropriate error message. Use destructuring to properly handle the errors in main.

3.7 Traits

Traits are a feature that allows us to inform the compiler of shared functionality between a set of types in an abstract way. In other words, it's how Rust supports ad-hoc polymorphism, and it's one of the most pervasive and powerful mechanisms of abstractions in the language.

Conceptually, traits are equivalent to type classes in Haskell or concepts in C++. They share some common ground with the idea of interfaces in other object-oriented languages.

Traits allow us, among other things, to constrain the behavior of generic parameters in our code.

// defining traits
trait Addressable {
    fn get_address(&self) -> String;
}

struct Contact {
    name: String,
    address: String,
}

// implementing traits
impl Addressable for Contact {
    fn get_address(&self) -> String { self.address.clone() }
}

// constraining type parameters to certain traits
fn send<T: Addressable>(what: T) -> () {
    ship_to(what.get_address());
}

// equivalent to (arguably more readable):
fn send2<T>(what: T) -> () where T: Addressable {
    ship_to(what.get_address());
}
// iterators in Rust are simply objects that implement a certain trait...
trait Iterator {
    // "Item" is called a "placeholder type". It is used to avoid using
    // generic type parameters which can be implemented multiple times for a
    // single object, making method resolution ambiguous.
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

struct AnIterator {}

impl Iterator for AnIterator {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        return Some(42);
    }
}

3.7.1 traits and closures

Now when we know what traits are, we can come back to the question: what are the types of our closures?

It turns out that the answer to this question isn't easy: every closure has its own unique anonymous type defined by the compiler. However, each closure implements at least one of three traits defined by the standard library: Fn, FnMut or FnOnce.

We'll have to defer the exact semantics of these three traits until we figure out ownership & borrowing, but for the time being we can just focus on the simplest Fn trait.

fn any_map<F>(f: F, v: &Vec<i32>) -> Vec<i32>
where F: Fn(&i32) -> i32 {
    v.into_iter().map(f).collect()
}

fn main() -> () {
    let mapped_vals = any_map(|x| x * 2, &vec![2, 3, -1]);
    println!("{:?}", mapped_vals);
}

3.8 Error handling in Rust

Previously, we have written the Either type which can serve well to represent a computation that can either succeed (returning Either::Right(T)) or fail (returning Either::Left(E)). It turns out this is a very powerful construct that powers the primary error handling mechanism in the Rust ecosystem: it is available, and prominently used in the standard library.

In the Rust standard library, this type is called std::result::Result<T, E>; it's so important, however, that it's included in the "Rust prelude", a list of things that get imported by default into every Rust program.

Instead of the slightly confusing left/right convention, the Result type is an enum of two more appropriately named variants: Ok(T) or Err(E).

Result has a slightly weaker cousin, std::option::Option, also in the prelude, which – instead of representing computations that either produce a value or an error – denotes an optional value. Its variants are Some(T) or None, and the APIs between the two types are very similar.

fn fac(n: u32) -> Result<u32, String> {
  if n > 20 { return Err(String::from("n too large.")); }
  Ok((1..=n).fold(1, |acc, x| acc * x))
}

fn main() -> () {
  // these types have a very rich API
  let t = fac(21).map(|factorial| factorial / 2);
  if let Ok(end_result) = t {
    println!("{}", t);
  }

  // unwraps the `Result`, panics with the error message in case it's an Err.
  println!("{}", t.expect("Unable to calculate the factorial:"));

  // you can convert between `Result` and `Option` (and vice-versa)
  let opt_t : Option<u32> = fac(10).ok();
}

3.9 Ownership, Borrowing, and Mutability

3.9.1 ownership

Ownership is the way Rust guarantees memory safety without having a garbage collector. Ownership boils down to a set of rules enforced by the compiler:

  • each value has has an owner,
  • there can be only one… owner of a value at a time,
  • when the owner leaves the scope, the value is dropped.
{
    let s = String::from("owned, scoped string.");
} // `s` leaves scope, and is no longer valid

Passing the string to another function will move the ownership of the value to the function, invalidating the string in its original scope:

fn takes_string(s: String) {
    // this function will take ownership of any `String` that has been passed
    // to it...

    // ...do something with the string...
}

fn main() -> () {
    let s = String::from("owned, scoped string.");
    takes_string(s);

    println!("{}", s);
}

The above won't compile with the following error message:

error[E0382]: borrow of moved value: `s`
 --> error.rs:9:22
  |
6 |       let s = String::from("owned, scoped string.");
  |           - move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
7 |       takes_string(s);
  |                    - value moved here
8 | 
9 |       println!("{}", s); // this won't compile!
  |                      ^ value borrowed here after move

3.9.2 borrowing

If we don't want our takes_string function to take ownership, we can borrow the string:

fn takes_string(s: &String) {
    // this function will now borrow the string (immutably!)
    // ...do something with the string...
}

fn main() -> () {
    let s = String::from("owned, scoped string.");
    takes_string(&s);

    println!("{}", s);
}

Values in Rust are immutable by default. To make them mutable, we use the mut keyword; this equally works for references. Rust allows us to have multiple immutable references to a single value, but the compiler will not allow us to have multiple mutable references or a combination of references to a single piece of data:

fn takes_mut_string(s: &mut String) {}

fn main() -> () {
    let mut s = String::from("owned, scoped string.");
    let s1 = &mut s;
    let s2 = &s;

    takes_mut_string(s1);
}

This will cause the following compile-time error:

error[E0502]: cannot borrow `s` as immutable because it is also borrowed as mutable
 --> error.rs:6:16
  |
5 |       let s1 = &mut s;
  |                ------ mutable borrow occurs here
6 |       let s2 = &s;
  |                ^^ immutable borrow occurs here
7 | 
8 |       takes_mut_string(s1);
  |                        -- mutable borrow later used here

3.9.3 closures

Now we can finally understand all three traits describing closures. Since closures capture their environment, the different traits describe the type of ownership the closures force onto the captured data.

  • FnOnce, where the closure owns and consumes all of the surrounding data, and it effectively gets destroyed when the closure's done (think: threads);
  • FnMut, which grabs its context as mutable reference(s);
  • Fn, which grabs its context as immutable reference(s).

3.10 Intermezzo: C++ vs. Rust

3.10.1 Returning reference to local variable

In c++ this is OK, while …

#include <iostream>

using namespace std;

int& foo() {
  int a = 5;
  return a;
}

int main() {
  cout << foo() << endl;
}

… the same code in Rust doesn't compile

fn foo() -> &u32 {
    let a = 5;
    &a
}

fn main() {
    println!("{}", foo());
}

3.10.2 Reference in structure

The struct instance outlives the variable it references to …

#include <iostream>

using namespace std;

struct Foo {
  int x;
  int& y;
};

Foo make_foo() {
  int a = 1;
  int b = 2;
  return Foo { a, b };
}

int main() {
  auto foo = make_foo();
  cout << foo.y << endl;
}

… and the same code in Rust is invalid.

struct Foo {
    x: u32,
    y: &u32,
}

fn make_foo() -> Foo {
    let a = 1;
    let b = 2;
    Foo { x: a, y: b }
}

fn main() {
    let foo = make_foo();
    println!("{}", foo.y);
}

However, if a local variable outlives a reference everything works.

struct Foo<'a> {
    x: u32,
    y: &'a u32,
}

fn make_foo<'a>(b: &'a u32) -> Foo<'a> {
    let a = 1;
    Foo { x: a, y: b }
}

fn main() {
    let b = 2;
    let foo = make_foo(&b);
    println!("{}", foo.y);
}

3.10.3 Data race

The idiomatic example of data race with one shared and one mutable reference.

#include <iostream>
#include <vector>

using namespace std;

int main() {
  vector<int> v;
  v.push_back(0);
  const auto &a = v[0];
  cout << a << endl;
  for(int i = 1; i < 1000; ++i)v.push_back(i);
  cout << a << endl;
}

And again, Rust prevent us of doing this.

fn main() {
    let mut v = Vec::new();
    v.push(0);
    let a = v.get(0).unwrap();
    println!("{}", a);
    for i in 1 .. 1000 { v.push(i) }
    println!("{}", a);
}

3.10.4 Reference to local variable (again)

Thread captures a reference to a local variable …

#include <iostream>
#include <thread>

using namespace std;

thread run_thread() {
  int a = 0;
  return thread([&]() { for(int i = 0; i < 1000000; ++i) ++a; cout << a << endl; });
}

int main() {
  auto t = run_thread();
  t.join();
}

… and (again) borrow checker prevents that.

use std::thread;

fn run_thread() -> thread::JoinHandle<u32> {
    let a = 0;
    let b = &mut a;
    thread::spawn(move || {for _i in 0 .. 1000000 { *b += 1} *b})
}

fn main() {
    let ret = run_thread().join();
    println!("{}", ret.unwrap());
}

3.10.5 Race condition

Now we mutate unprotected variable from multiple threads and results are undefined. Note that threads here are properly scoped.

#include <iostream>
#include <thread>
//#include <atomic>

using namespace std;

int main() {
  int a = 0;
//atomic<int> a(0);
  auto t1 = thread([&]() { for(int i = 0; i < 1000000; ++i) ++a; });
  auto t2 = thread([&]() { for(int i = 0; i < 1000000; ++i) ++a; });
  t1.join();
  t2.join();
  cout << a << endl;
}

And the proper solution in Rust with scoped threads.

use std::sync::atomic::{AtomicUsize, Ordering};
use crossbeam;

fn main() {
    let mut cnt: AtomicUsize = AtomicUsize::new(0);
    crossbeam::scope(|scope| {
        let t1 = scope.spawn(|_| { for _ in 0 .. 1_000_000 {
            cnt.fetch_add(1, Ordering::SeqCst);
        }});
        let t2 = scope.spawn(|_| { for _ in 0 .. 1_000_000 {
            cnt.fetch_add(1, Ordering::SeqCst);
        }});
    });
    println!("{}", cnt.load(Ordering::SeqCst));
}

3.11 C and Rust: FFI and shared libraries

No programming language in the "systems" category can avoid interfacing with C. Rust can interact equally well with C in both directions: when calling C code from Rust (through its FFI interface), and when calling Rust code from C.

3.11.1 calling Rust from C

The main secret is setting crate-type = ["cdylib"] in Cargo.toml for our library in the lib section:

[dependencies]
libc = "*"

[lib]
crate-type = ["cdylib"]

We also annotate the function with #[no_mangle] to tell the compiler not to mess with the names of our function:

use libc::uint32_t;

#[no_mangle]
pub extern fn add(a: uint32_t, b: uint32_t) -> uint32_t {
    a + b
}

After cargo build, we get a standard .so (or .dll) shared library we can link against in a C program (or a Python, Lua, Go, … program through FFI).

3.11.2 calling C from Rust

Example calling the snappy compression library from the Rust nomicon, a lengthy guide to the "unsafe" portion of Rust:

extern crate libc;
use libc::size_t;

#[link(name = "snappy")]
extern {
    fn snappy_max_compressed_length(source_length: size_t) -> size_t;
}

The extern definitions can be generated using rust-bindgen, which makes our job even easier (particularly for large libraries with hundreds of functions in their API).

The problem: calls across the FFI boundaries are unsafe! Calling into C libraries is an easy (or even trivial) task, but writing safe wrappers across FFI boundaries is where the real magic happens.

3.11.3 References

3.12 The Rust Standard Library

Rust comes with two "standard" libraries built-in:

  • libcore, and
  • libstd.

libcore is heap allocation-free, platform-agnostic, and depends on a handful of symbols like memcpy and memset. It lives in the core::* namespace.

When writing code for embedded systems, it's usual (and often necessary) to only import libcore, and avoid the use of the higher-level libstd, something that's popularly called no_std in the Rust community. There are numerous consequences to this, most notably the fact that many 3rd party crates depend on libstd. When crates do support no_std, this is usually explicitly mentioned in the documentation.

libstd is the "standard" standard library of Rust that we've been using so far – it defines various portable, but platform-dependent features and types like I/O, multithreading, containers etc. It lives in the std::* namespace.

Rust imports a reasonable number of often-used standard library features implicitly into every crate. This is called the Rust Prelude, and it's the reason we can use Vec, Result, Option et al. without importing them explicitly.

An important caveat is that the WebAssembly target has a separate "standard" library, and basically falls into the no_std category.

3.13 Testing

Rust includes basic built-in support for writing unit tests. These can be inline with your library/module files, or in a separate tests/ directory. It has support for basic assertions, and cargo includes a test runner which makes executing tests easy.

fn make_ordinal(nums: &Vec<i32>) -> Vec<String> {
    let to_ordinal = |x: &i32| {
      let x_str = x.to_string();
      let suffix =
          if x_str.ends_with("1") { "st" }
          else if x_str.ends_with("2") { "nd" }
          else if x_str.ends_with("3") { "rd" }
          else { "th" };

      format!("{}{}", x_str, suffix)
    };

    nums.iter().map(to_ordinal).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ordinals() {
        let v = vec![1, 32, 3, 0];
        let exp = vec![
            String::from("1st"),
            String::from("32nd"),
            String::from("3rd"),
            String::from("0th"),
        ];
        assert_eq!(make_ordinal(&v), exp);
    }
}

3.14 Debugging

Rust generates binaries with full symbol metadata, thus any native debugger should do. GDB got native support for debugging Rust programs with version 7.12, and LLDB is reported to work well.

Rust itself comes with two wrapper scripts, rust-lldb and rust-gdb, which load some handy pretty printers.

Sometimes (particularly in a safe language which doesn't segfault often), an easy alternative to debugger is printing. In Rust, there is Debug trait which allows any type to be converted into string. Together with derive macros, is is trivial to implement Debug for the most of the types. Alongside the Debug trait, there is dbg! macro which offers quite convenient way to print results.

3.14.1 Exercises

  1. Write a simple Rust program, compile it in debug mode, and use your debugger of choice to set a breakpoint and inspect an arbitrary value.

3.15 The Rust Ecosystem: WebAssembly

Rust is seriously targeting WebAssembly as one of its core platforms.

There are two main approaches to WebAssembly: the stdweb approach, and the wasm-bindgen approach. They significantly differ in their philosophies, approach to interop, and community adoption.

There are certain degrees of compatibility between these two projects, i.e. they are not necessarily mutually exclusive.

3.15.1 stdweb

stdweb aims to be a full-featured Rust wrapper for the entire browser ecosystem. It allows seamless interop with JavaScript, going so far to actually allow you to embed JS into Rust using the js! macro.

This approach aims to enable writing browser-based applications without ever leaving the Rust crate, and without the need to reach out to npm or other parts of the JavaScript ecosystem; instead, it uses cargo-web.

React-inspired frameworks have been built on top of stdweb (see yew), and it has seen some traction.

3.15.2 wasm-bindgen

wasm-bindgen's approach is more similar to the C/FFI mechanism of dealing with interop: you will explicitly import and export the necessary functions and methods for interaction between your Rust code and your JavaScript code, and the tooling around wasm-bindgen will generate the so-called "glue code" that's required to make it all work.

This has the obvious advantage of not going all-in with Rust, and allowing more gradual shift towards Rust code in an already existing system. It also gently nudges you (but doesn't require) to use the npm/webpack approach of bundling and deploying your software.

wasm-bindgen seems to be the current focus of wider Rust/wasm community.

3.16 The Rust Ecosystem: futures, tokio and async/await

The Rust ecosystem for asynchronous programming is very rich, although temporarily slightly fragmented.

One of the first serious attempts at bringing async I/O to Rust is Tokio. Tokio builds on top of the futures crate, bringing an ecosystem of executors and reactors for futures.

In the meantime, the community has started a process of stabilization of Rust futures, bringing them into the standard library. This has been rooted in the work made by the futures crate, but has some significant (and incompatible) changes to the API.

Tokio has support for std::future, but it's only available as an alpha release.

async-std is a new attempt at bringing foundational async building blocks to Rust, and stands in "competition" with Tokio. std::futures are a first-class citizen.

async and await are new additions to the Rust syntax which will become available in the upcoming version of the compiler (most probably 1.39). They are syntax sugar on top of standard std::future APIs, allowing us to write more synchronous-looking code.

In-depth discussion of asynchronous programming is out of the scope of this workshop, but it's important to keep the distinction between these subtly incompatible libraries and concepts in mind when browsing through the ecosystem.

3.17 The Rust Ecosystem: serde

Serde is a serialization/deserialization framework for Rust with a very wide support for data formats, and a focus on high performance. The most obvious target for serde is JSON (through the serde_json crate), but TOML, MessagePack, YAML, Python's pickle, and many others are well-supported.

Rust's standard data types and data structures are covered by serde out of the box, meaning you can trivially serialize from and deserialize into, e.g., hashmaps and vectors. A derive macro is provided for simple generation of serialization and deserialization traits in arbitrary structs.

For highly customized cases, or cases not covered by the out-of-the-box functionality, implementing the traits "by hand" is also supported.

Serde is widely accepted by the Rust community, and a lot of crates defining custom data types (e.g. the uuid crate) provide serde support. It provides no_std support, with some obvious caveats like the lack of support for data types which feature heap allocation.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
enum Genre {
    Crunk,
    Ghettotech,
    // variants can have parameters
    Other(String),
    // another option:
    // Other { name: String },
    Powerviolence,
    Psychobilly,
    Zeuhl,
}

#[derive(Serialize, Deserialize, Debug)]
struct Album {
    artist: String,
    title: String,
    year: u16,
    genre: Genre,
}

fn main() -> () {
    let album = Album {
        artist: String::from("Nick Cave & The Bad Seeds"),
        title: String::from("Ghosteen"),
        year: 2019,
        genre: Genre::Other(String::from("Sad")),
    };

    let album_serialized = serde_json::to_string(&album).expect("Unable to serialize");
    println!("{}", album_serialized);
    // {"artist":"Nick Cave & The Bad Seeds","title":"Ghosteen","year":2019,"genre":{"Other":"Sad"}}

    let album_obj : Album = serde_json::from_str(&album_serialized).expect("Unable to deserialize");
    println!("{:?}", album_obj);
    // Album { artist: "Nick Cave & The Bad Seeds", title: "Ghosteen", year: 2019, genre: Other("Sad") }
}

3.18 The Rust Ecosystem: actix-web

actix is a generic actor framework for Rust, and actix-web is a high-performance HTTP/web framework for Rust written on top.

There are other projects bringing a high-performance HTTP layer to Rust, but they mostly suffer from various drawbacks like building only with the nightly version of the compiler (Rocket) or being too low-level (hyper).

Actors are a first class citizen in actix-web, and allow for a nice abstraction over asynchronous computation.

3.18.1 References

4 the project

To wrap up the workshop and apply some of the things we've learned, we'll face one last big exercise: we'll create a simple RSS feed reader API.

To do this, we will use several important crates: actix-web for the web server, serde for serialization of the data, and rss for actually parsing the feeds. Our persistence layer will consist of simple JSON files on the filesystem, and all of our communication will be based on JSON and standard HTTP verbs.

To give you a general specification, the program should consist of two parts.

The first part we'll call a "loader", and it is a short-living process that will read a bunch of lines from the standard input. Each line will be a URL to a single feed to be fetched and parsed. The loader will take each of the feeds, parse it, and persist all of the items from the feeds into one big JSON file.

The second part is the API itself, and this will be an Actix web app. There will be a few endpoints to implement:

  • /channels, which will return a list of feeds ("channels" is the RSS lingo);
  • /items/{feed_id}, which will return a list of all articles ("items") for a given feed ID;
  • /item/{item_id}, which will return the details of a single article.

One of the main features of a good feed reader is tracking of whether an item has been read or not, so you'll be expected to toggle a read flag on an item once its details have been requested. The implementation details are left to your imagination (and the inevitable discussion during the workshop).

Depending on how fast we end up being, possible further features could be:

  • grouping channels into folders, and filtering items per-folder along with per-channel;
  • programatically adding a new channel to the list;
  • end-to-end tests for the API.

Author: nikola

Created: 2019-10-09 Wed 08:20

Validate