My First Month With Rust

8 minutes read · 2017-03-29

Roughly a month has passed since I started using the Rust programming language as my “daily driver”, and I feel like it would be interesting to share my early impressions and thoughts about the language.

Rust's Logo

NB: The title of this post is not entirely accurate, since I had been toying on and off with the language in my free time for a while. Rust has a steep learning curve, so don't feel demotivated if you don't hit the ground running in the first month.

Rust?

Rust is a systems programming language with a focus on execution speed, memory and data-race safety. It's sponsored primarily by Mozilla, who are using it to develop their next generation browser engine, Servo.

Learning Curve

My first impression of Rust was that it was significantly harder to learn than most languages I had experimented with, perhaps with the exception of Haskell. During the first weeks, it felt like I didn't know how to properly structure anything beyond the simplest programs. That was quite frustrating*.

My advice if you're struggling in this stage is to power through and keep trying to use the language, even if you can't fully understand what's going on yet or get everything to work. Much like learning git and vim, it does pay off later on. After some time, it's finally going to “click”, and you'll start to develop an intuition of how things work.

The following were my major pain points during my first month with rust:

Pain Point #1: Move Semantics

Most other languages provide either copy semantics or reference semantics for dealing with values. What that means is: if you pass a variable x into a function f(), it will receive either a copy of the value in x or a reference to it. (Depending on the language and the type of x)

Rust does away with that, and opts to use move semantics by default for most custom data types. Attempting to compile the following code:

fn f(x: String) {
    // Do something with x
}

fn main() {
    let x = String::from("Hello");
    f(x);
    println!("{}", x);
}
Run

Will result in an error, since the value of x is consumed by f(), and cannot be reused by println!():

rustc 1.16.0 (30cf806ef 2017-03-10)
error[E0382]: use of moved value: `x`
 --> <anon>:8:20
  |
7 |     f(x);
  |       - value moved here
8 |     println!("{}", x);
  |                    ^ value used here after move
  |
  = note: move occurs because `x` has type `std::string::String`, which does not implement the `Copy` trait

error: aborting due to previous error

Instead, you need to either explicitly clone the value of x, or take a reference and pass that instead:

fn f(x: String) {
    // Do something with x
}

fn g(x: &String) {
    // Do something with x
}

fn main() {
    let x = String::from("Hello");
    f(x.clone());
    g(&x);
    println!("{}", x);
}
Run

This forces you to think about ownership, which is very convenient for providing automatic memory management and compile-time optimizations. However, it does take some time getting used to.


* It doesn't help that most of the “classic” CS 101 assignments that people will commonly implement when learning a new language—e.g. linked lists, binary trees, hash tables—all traditionally involve shared mutable state, which is heavily discouraged by the language in the first place.
† Rust was not the first programming language to introduce move semantics for values. C++11 implements it to some extent via move constructors.
‡ Primitive data types—and in fact all types that implement the Copy trait—are exempt from this rule, and are instead passed by copy.

Pain Point #2: Borrow Checker and Lifetimes

One of the unique aspects of rust is the borrow checker. It works like a compile-time shared-exclusive lock for references, ensuring that each value either has:

  1. One writable/mutable reference: &mut value;
  2. One or more read-only/immutable references: &value;
  3. No references.

The borrow checker is usually dreaded among beginners due to how often it will trigger compilation errors. The more you get used to the borrowing rules above, the less often you'll stumble upon it, though.

Most of the time the compiler is able to infer the lifetime of each reference automatically, but some circumstances require you to mark it explicitly. Having to do so has been my least favourite aspect of Rust so far.

Pain Point #3: Many Ways of Doing (Almost) the Same Thing

Rust is very flexible, and provides several means of achieving (almost) the same goal, with slight variations between them.

For instance, in a situation where in C you'd normally use a pointer to a type T (T*), in Rust you may choose between using:

  • &T; (Reference to T)
  • &mut T; (Mutable reference to T)
  • Box<T>; (Owned, heap allocated autopointer to T)
  • Rc<T>; (Reference counting autopointer to T)
  • Arc<T>; (Atomic reference counting autopointer to T)
  • *T; (Unsafe pointer to T)
  • *mut T; (Unsafe mutable pointer to T)
  • In fact, any custom type that implements the Deref<Target=T> trait.

Each option has different mutability, safety, concurrency and performance implications. For a novice Rust programmer, the amount of options available can be overwhelming*.

Syntax

Rust's syntax is really pleasant to work with. In my opinion, it strikes the right balance of being C-like, while also diverging where needed to keep things simple, consistent and flexible.

I'm particularly fond of the everything is an expression approach, which allows you to treat arbitrary code blocks as expressions:

fn main() {
    let number = 3;
    let x = if number > 2 {
        "Yep"
    } else {
        "Nope"
    };
    println!("{}", x);
}Run

I'm also glad the language went with Pascal-style type annotations (e.g. let x: Type) like TypeScript, instead of C-like declarations (e.g. Type x) or Go-style “bare” postfix declarations. (e.g. var x Type)

* It gets really overwhelming once you factor in cell and lock types that can be combined with the pointer types, like Rc<RefCell<T>> or Arc<Mutex<T>>.

Cargo & the Crate Ecosystem

Rust ships with cargo, a package manager and build tool. It takes on a similar role to npm on the Node.JS ecosystem.

Out of the box cargo is able to:

  • Build your code by invoking rustc;
  • Keep track of modified files; (like make does)
  • Install, build and link dependencies from the crates.io registry;
  • Build offline HTML documentation, (using rustdoc) for your entire dependency tree. (I love this.)

Having a tool like cargo as part of the default distribution removes the need for separate build systems and IDEs (e.g. CMake, Automake, xcodebuild, msbuild) and ensures Rust libraries are distributed and built consistently.

Your chances of having something build cleanly on the first try are really close to 100%, except perhaps for packages which are bindings for non-rust code. (Though so far I've been really lucky with those as well)

By default, cargo will create a Cargo.lock file with exact dependency versions and checksums, ensuring reproducible builds. It does that without the need of running a separate command, like npm shrinkwrap, a gentle “nod” in the right direction that I appreciate.

Batteries not Included… Yet.

Rust is a relatively young language, so some commonly needed features are still missing. Notably, random number generation and regular expressions are, as of early 2017, not bundled with the standard library.

The crate (package) ecosystem is meant to complement that, with crates like rand and regex being readily available. The plan is for commonly used, general purpose crates to be eventually merged into the standard library.

Language design is community-driven, via an RFC process. Experimental language features that are not yet stabilized can be tried by using Beta or Nightly versions of the compiler.

Community

Rust's community has been incredibly welcoming and friendly so far. The main hangout places seem to be:

  • #rust and #rust-beginners IRC channels on Moznet; (The latter being particularly responsive and helpful. Thanks for putting up with all my newbie questions!)
  • The Rust Users Forum;
  • /r/rust on Reddit

Approaching Compiler Errors With a Different Mindset

In this first month, Rust has made me rethink how I approach compiler errors. I was initially upset on how often the compiler would reject my perfectly valid-looking code over some technicality.

Other languages I am familiar with will mostly perform syntax and type checks, so a compiler error means that I either typed something wrong, or that I'm trying to perform an operation with an incompatible type. — i.e. “I'm doing something stupid and it's clearly my fault!” 😓

Rust on the other hand will also perform safety, borrow and lifetime checks, and it will downright refuse to compile code that can't be proven to be safe.

So it really stops being a matter of “the code I wrote is not correct” and starts being a matter of “I haven't yet proven that the code I wrote is correct“.

You do spend more time reading compiler output and addressing whatever it's complaining about, however that translates to significantly less time spent debugging your program. By raising the bar on the requirements for a successful compilation, Rust helps you get things right the first time.

-MB