Thoughts on Rust

The past few weeks, I’ve been taking some time to pick up the Rust Book, and learn a new (programming) language.

Amazon has big enthusiasm for Rust internally, not without some controversy of course, and as I have continued in my Amazon adventures, I’ve found myself coming across it increasingly. So as a personal growth area I’ve been trying to learn the language.

As I’ve been reading the book, looking at the examples, and doing the little quizzes, I’ve been building a little toy implementation of Conway’s Game of Life, I’ve had some time to gather my thoughts on the language and programming style.

I’ve braindumped some of the more coherent thoughts below…

1. Cargo is Lovely

$ cargo build
    Updating crates.io index
  Downloaded getrandom v0.2.8
  Downloaded once_cell v1.16.0
  Downloaded rand_core v0.6.4
  ...
   Compiling game_of_life v0.1.0 (/home/karl/Development/rust-game-of-life/game_of_life)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 35s

Cargo is the build system for Rust, and I love it. Coming from C++ where “there is not default build system” (the real default build system is still awful IMHO), Cargo is a breath of fresh air.

It is extremely easy to use. In some ways, this ease of use makes Cargo inflexible. That might sound like a bad thing, but in reality, it makes building Rust code very consistent and simple to understand.

A basic Cargo.toml file looks like this:

1
2
3
4
5
6
7
8
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"
description = "A brief description of the package (i.e. executable or library)"

[dependencies]
rand = "0.8.5"

Concise right? Compare this with some roughly equivalent CMake for “Hello, world!” in C++17:

cmake_minimum_required(VERSION 3.0)

project(
  MyHelloWorldProject
  VERSION
    0.1.0
  DESCRIPTION
    "A brief description of the package (i.e. executable or library)"
  LANGUAGES
    CXX
)

find_package(rand REQUIRED)

add_executable(hello_world src/main.cpp)
target_compile_features(hello_world PUBLIC cxx_std_17)
set_target_properties(hello_world PROPERTIES CXX_EXTENSIONS OFF)
target_link_libraries(hello_world rand)

By comparison, and obviously in my humble opinion, the CMake version is super cryptic. And this is just a hello world project. CMake scales far worse once you start introducing multiple build targets, lots of dependencies, lots of code files, and so on.

Now to be fair, the comparison is a little unfair. Cargo is SPECIFIC to Rust, while CMake is somewhat programming language agnostic. But in terms of clarity, Cargo wins hands down.

So as to not make this look like a gripe just against CMake, Cargo compares favourably as well compared to plenty of other languages like Python (setup.py/setup.cfg), Ruby (Rakefiles, gemspecs), and .NET languages (sln files, csproj etc).

2. Rust-Analyzer is Great Too

rust-analyzer is the language server for Rust, it provides fully featured text completion for Rust projects:

rust-analyzer enabling Rust autocompletion in Visual Studio Code

I use vscode for most things these days, but even with vscode having decent extension support, I was surprised by how seamlessly the rust-analyzer extension worked.

3. Rust Expressions and Statements Changed How I Think About Code

The heading seems like an exaggeration, but it’s literally the case. When you begin learning Rust, you are taught very early on that idiomatic rust returns the value of “the expression” to it’s calling scope. For example:

/// Get a random number, chosen by fair die role.
fn get_random_number() {
    4
}

Simply by writing a line of code without a semicolon, it implicitly becomes the return value of that scope. Equally we can do the following:

fn get_random_number2(seed: i32) {
    if seed < 5 {
        4
    } else {
        123
    }
}

Note that in this example, the if/else block functions as a single expression, the result of which is then returned from the function.

More generally, I find that this lend’s itself very gracefully to the Single Responsibility Principle. You can generalize a rust function (or any scope) to something like this:

fn my_function() {
    // Code that prepares the expression parameters:
    let a = foo();
    let b = bar();
    let c = baz();

    // Execute the expression:
    a + b + c
}

Note that the function only “does one thing” which is return the result of a single expression, but it may do multiple things to generate the parameters feeding the expression.

When I initially learned this, I didn’t like it, but in fact now that I am used to it, it really makes a lot of sense, not just for Rust, but for writing clean code in general.

4. Trait Scoping Sucks

C’mon Rust, if I specify a that class implements a trait, why can’t rust figure that out? The following code will fail to compile:

504
505
506
507
508
509
510
511
512
513
#[cfg(test)]
mod foomod {
    use super::mock::MockPlotter;

    #[test]
    fn test() {
        let plotter = MockPlotter::new();
        plotter.flush();
    }
}

In this example above, I am creating an object of type MockPlotter, which implements a trait called Plotter. The flush() method is a part of the Plotter trait, but Rust can’t find it!

error[E0599]: no method named `flush` found for struct `MockPlotter` in the current scope
   --> tui/src/lowlevel.rs:511:17
    |
277 |     fn flush(&mut self) -> Result<&mut Self, std::io::Error>;
    |        ----- the method is available for `MockPlotter` here
...
452 |     pub struct MockPlotter {
    |     ---------------------- method `flush` not found for this struct
...
511 |         plotter.flush();
    |                 ^^^^^ method not found in `MockPlotter`
    |
    = help: items from traits can only be used if the trait is in scope
help: the following trait is implemented but not in scope; perhaps add a `use` for it:
    |
506 |     use crate::lowlevel::Plotter;
    |

(Side note: How great are Rust compiler diagnostic messages? :D)

This error basically tells us that the MockPlotter struct DOES implement the method as part of the Plotter trait, but it is not accessible because we didn’t bring the Plotter trait into scope…

WHY?

I’m not sure I understand why, if a trait is implemented in a class, and I bring the class into scope, why are it’s traits not just automatically brought into scope as well?

Instead I have to manually use the trait:

504
505
506
507
508
509
510
511
512
513
514
#[cfg(test)]
mod foomod {
    use super::mock::MockPlotter;
    use super::Plotter;

    #[test]
    fn test() {
        let plotter = MockPlotter::new();
        plotter.flush();
    }
}

5. Enums are Incredibly Expressive

Enums in C and C++ are are functional, but limited, there’s no denying it. They amount to simply a list of integral values with a concise syntax for doing different work based on the current value. Rust enums on the other hand are more akin to std::variant<> (or union in C) rather than actual C-style enumerations:

enum Command {
    SendMessage(String),
    SumValues(i32, i32),
    Exit
}

This enum holds three possible values:

Traditionally, no discussion of enumerations can avoid the logical counterpart, match expressions. Rather than simply evaluating an integer value, it can do pretty expressive stuff:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
    let mut is_exiting = false;
    while !is_exiting {
        match get_command() {

            // Message content is accessible from the `msg` variable:
            Command::SendMessage(msg) => println!("Message: {}", msg),

            // Two matches for the `SumValues` enumeration value here, one
            // which is specialized where the left operand is <5, and another
            // to handle any other values.
            Command::SumValues(l, r) if l < 5 => println!("Total (l is less than 5): {}", l + r),
            Command::SumValues(l, r) => println!("Total: {}", l + r),

            // Sentinel value which quits the application.
            Command::Exit => is_exiting = true
        }
    }
}

You can find a building example here.

6. Reference Handling is Sometimes Ineloquent

6.1 The Ref Keyword

Interestingly, one thing I came across while playing with match expressions was the ref keyword. Consider the following example:

let val = Some("foobarbaz".to_string);

// Match expression takes ownership of `val` here.
match val {
    Some(s) => println!("{}", s),
    _ => println!("idunno"),
}

// Compiler error, val is unavailable because it was owned by the match
// expression.
println!("Value is: {}", val);

We can fix this example by making the match borrow the val variable, however in some more complex cases, you may need to use the ref keyword. This example above could be fixed by forcing the match statement to take Some(s) as a reference rather than passing ownership:

let val = Some("foobarbaz".to_string);

// Match expression takes ownership of `val` here.
match val {
    Some(ref s) => println!("{}", s),  // <--- Ise of `ref` here.
    _ => println!("idunno"),
}

// Compiler error, val is unavailable because it was owned by the match
// expression.
println!("Value is: {}", val);

Documentation for the ref keyword is here.

In a language where we’re taught 99.9% of the time to use the & operator, the ref keyword was a sudden surprise.

Fortunately, Rust appears to have deprecated this keyword now for match statements, however some edge cases appear to continue to exist (see this discussion).

6.2 The * Operator

On the “other side”, sometimes we find that we have to dereference a value in order to perform operations on it. For example:

1
2
3
4
5
6
7
8
9
fn do_thing(s: &mut String) {
    s = "Foo".to_string();
}

fn main() {
    let mut s = "some string".to_string();
    do_thing(&mut s);
    println!("{}", s);
}

In this example, we can see that we create a mutable string, then pass it to a function do_thing which replaces the string contents. The problem is that this doesn’t compile. Instead, the compiler believes that I am trying to reassign the s variable (which is an &mut String), but we’re assigning it to String.

Coming from a C++ background, I would read this code to believe that s on line 2 is replacing the contents of the referenced variable, which is incorrect.

In this example, rust treats s more like a C-style pointer rather than a C++ reference. So to fix we actually have to use the * operator:

1
2
3
4
5
6
7
8
9
fn do_thing(s: &mut String) {
    *s = "Foo".to_string();  // <--- Dereferenced here with `*`
}

fn main() {
    let mut s = "some string".to_string();
    do_thing(&mut s);
    println!("{}", s);
}

I accept fully that this might just be my C++ bias, but it feels very much like Rust should’ve allowed the first variant, and always made reference variables not re-assignable. Fortunately, this isn’t a major deal as the compiler will suggest this as the fix, and ultimately we can’t change the behavior this late on, so c’est la vie.