Rust: Beyond the Syntax

Finding enlightenment in unexpected places

Brendan Gray
Rust in Production

--

I hate C++ with a passion. I usually love programming, but every project I’ve tackled in C++ has felt like a tedious chore. In January 2023, I embarked on a journey to learn Rust so that I could say I know a systems programming language that I’d actually want to use.

Rust’s first stable release was in 2015, and it has been voted most loved language in Stack Overflow’s Annual Developer Survey every year since 2016 (now called “Admired” in 2023). Why is it that once developers get a taste of Rust, they can’t stop using it. In a world of hyped up C/C++ successors, it looks like Rust is the one that is bubbling up to the top. How is it that a language that only stepped onto the main stage in the last decade has become so popular?

Ferris, the rusty red crab. Imagined by Midjourney

The learning curve was steep. While I found many things to love about Rust, I kept finding pitfalls to catch me out. But in the end, it was the obstacles and frustrations I encountered that I learned to love the most.

I will start this story by talking about the things that were easy to love — Rust’s environment, the package management, and the documentation. I’ll then talk about the type system and traits. I’ll then talk about the sort of testing and test driven development that Rust enables. Finally, I’ll talk about the most confusing and frustrating part — Rust’s obsession over who owns each and every variable.

The Rust Ecosystem

Most languages I use regularly have tacked on package and version management. Systems like npm, pip, and NuGet are very useable these days, but they weren’t always that way, and they are still far from perfect. Managing the installed version of the language itself is still a pain in most languages.

You install rust with rustup, a tool which later helps you manage your Rust version and associated tools.

Cargo combines package management and build tool functionality, and it represents all of the best characteristics of package management out there. It’s simple and stays out of the way.

The other part huge aspect of Rust’s ecosystem is its documentation. I learned the language entirely from the official documentation, and never felt the need to look elsewhere for a tutorial. Between “the book” and Rust By Example, everything I needed to know was covered. In fact, whenever I found myself on Stack Overflow with a problem, the most helpful answers were usually the ones pointing to the right section of either the official documentation or one of those two sources.

I could go on about the compiler messages that feel like they’re coaching you to be a better programmer (I’ll save that for later), or the Rust Playground that is a great way to test if something works. But let’s rather move on to the features of the language that really stood out. It’s time to dive into the intricacies of Rust’s type system, notably the concept of Traits.

Quack Quack! Duck Typing with Traits

Rust actually had classes in its early days, but they survived for little more than 6 months. They were replaced with the much simpler data structure, the struct. You define types by declaring a struct, which is little more than a bundle of related fields that can store data. Rust lets you add implementations for types, which are sets of functions that can carry out operations on or related to that type.

A neat concept that I’ve come to love from working with dynamically typed languages is duck typing. It’s the principle where a function can accept an object of any type as long as it has the right properties and methods that the function needs. “If it walks like a duck and it quacks like a duck, then it must be a duck”. If the function we are calling needs its input to be able to swim, then we shouldn’t care that it’s a duck. We should only care whether it can swim.

An elephant pretending to be a duck. Image by Midjourney

Despite being a statically typed language, Rust still manages this beautifully. Using traits, you stop thinking about what types your functions need to accept, and instead start thinking about what the inputs to your function need to be able to do.

Let’s look at an example. Here is a trait for swimming. Any type that implements the Swim trait is able to swim.

A function that has an argument that needs to be able to swim doesn’t need to specify the type. It just needs to specify that it needs to have implemented the Swim trait. We don’t need to worry about what types will come along in the future. The compiler will look at what types we call the function with, do the appropriate checks, and generate the appropriate machine code to handle it.

Let’s create some types that can be passed to the cross_the_pond function. We can create a type called Duck by defining a struct, and implementing the Swim trait for it.

But a duck isn’t the only thing that can swim. Let’s define an Elephant struct, and implement the Swim trait for it as well.

Our main function can instantiate ducks and elephants and bring it all together.

This produces the following output:

Crossing the pond...
Sir Quacks-a-lot paddles furiously...
Ellie BigEndian is actually just walking on the bottom...

You can play around with this code in the Rust Playground here.

The Rust standard library also offers some really useful types like Option and Result that let you handle cases where a value may or may not be present. With Rust’s pattern matching, you can write concise and readable error-handling code using these types. We won’t be going into them or the match statement in this story, but they are worth getting familiar with if you’re getting started with Rust. Instead, let’s discuss Rust’s approach to testing.

Test the Code in the Code

Developers tend to have strong opinions about folder structure and file naming conventions. Everyone agrees that we want to keep our folders as clean as possible, but people tend to disagree about what that actually means. A big point of contention is where to put tests. Should you have a separate folder for tests? Does the structure of the test folder mirror the source folder? Do you mix tests in with the source code? Do you prefix your test files with `test_` so the tests are grouped together or do you suffix them with `_test` so the tests are with the code they test?

A messy structure makes it hard to find things. But what is a neat structure? Image by Midjourney

The other issue is testing private functions. In most languages, you have a choice either to settle for testing only the public interfaces, or you have to make your private functions public (this is gross, please don’t do it), or you have to resort to reflection tricks that make your tests clumsy and difficult to read and maintain. How does Rust handle these challenges?

In Rust, it’s a common best practice to put your tests in the same file as the code they test. The benefits of this are amazing. There’s no file system clutter, no arguments over naming conventions, and your tests can access private functions if they need to without having to compromise on revealing implementation details.

Let’s look at an example. The module below provides one silly function that adds two numbers and returns double the sum. It makes use of a private helper function, and some tests.

The #[cfg(test)] attribute tells the compiler to only compile the tests module when running tests, and the tests are stripped out in the production build.

Rust also offers other neat features such as documentation tests — where examples in the documentation are actually run as tests so that your documentation never goes out of date — and concurrent test execution, which makes your test suite run blazingly fast.

Typing and testing were the two features which stood out and excited me immediately, but let’s look at the one feature that was the hardest to love.

Borrowed it, never gave it back, then moved?

For me, the most challenging part of learning Rust was understanding the concepts of ownership, lifetimes, borrowing, moving, and copying.

Rust is a language designed with memory safety at its core. Rust was inspired by a crash in the operating system of an elevator in an apartment building. Crashes are often caused by memory errors, including null references, dangling pointers, or memory leaks that can be avoided by writing better programs. Rust’s ownership system makes sure that these sorts of errors can’t happen.

A value in memory has one and only one owner. The owner is just a variable that holds the value, and the compiler can work out at compile time when the owner will go out of scope, and knows exactly when memory can be freed. Any other scope that needs to use the value needs to borrow it, and only one scope can borrow a value at a time. This ensures that there is never more than one reference to a value at a time. There is also a strict management of lifetimes, so a reference to a value cannot outlive the variable that owns the value. These concepts are the core of Rust’s memory safety.

If you’re coding in any neat and sensible way, your functions should be short and variables shouldn’t live for very long. But any useful program needs to keep data around for a lot longer than a single function call. Enter moving.

When you return a value from a function, or assign it to a new variable, then a different variable needs to take ownership of that value. This is called moving, and after moving, the original variable is no longer allowed to use the value. This means that simple code like the following that would be not be a problem in most other high level languages will not compile in Rust.

I found that very confusing. Let’s have a look at the error message:

error[E0382]: borrow of moved value: `original_owner`
--> src/main.rs:6:20
|
3 | let original_owner = String::from("Something");
| -------------- move occurs because `original_owner` has type `String`, which does not implement the `Copy` trait
4 | let new_owner = original_owner;
| -------------- value moved here
5 |
6 | println!("{}", original_owner);
| ^^^^^^^^^^^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
4 | let new_owner = original_owner.clone();
| ++++++++

For more information about this error, try `rustc --explain E0382`.

This is typical of the error messages that you get from the Rust compiler. It doesn’t just yell at you leaving you wondering what you did wrong. It calmly points out why the value had to be moved, it shows you where the move happened, suggests how you might fix it, and provides a helpful warning about the performance cost of the workaround.

Consider this for a while. The first time I encountered any moderately complicated compiled error caused by a moved value, I felt like I was banging my head against a wall. Something so simple that I’d taken for granted for my whole programming life was impossible in this new language. How could so many developers love a language that made simple things so frustrating?

But then I saw it. Rust is giving me a way to do whatever I want, but it is first making me consider whether that’s what I really want to do, and making me think about the implications of my decision. Now when I write code in other languages, I find myself thinking about which objects are holding references to which value, and where values are being referenced or copied and the performance and robustness implications of that. Rust is making me a better programmer, even when I code in other languages.

Closing Thoughts

In 2019, I gave a talk entitled “Not Just Syntax” about my experience learning Racket, a language in the Lisp family. While I have never and would never want to use a Lisp language professionally, the experience led me to profound epiphanies about functional programming at a level that I had never experienced before. I closed that talk with the following quotes:

“A language that doesn’t affect the way you think about programming is not worth knowing.” — Alan Perlis

and

“It is not only the violin that shapes the violinist, we are all shaped by the tools we train ourselves to use, and in this respect programming languages have a devious influence: they shape our thinking habits.” — Edsger Dijkstra

My revelations while learning Rust over the past few months have similarly transformed me. The learning curve was steep. Rust is not a gentle and forgiving language. It’s strict and firm, but only to stop you from writing the sort of code you’ll regret a couple years down the line.

I’ve discovered what the nearly 85% of developers who have used Rust see in it, and when that email about the 2024 Stack Overflow survey arrives in my inbox and the survey asks me whether I want to continue using Rust next year, I’ll certainly be answering “Yes”.

--

--

Brendan Gray
Rust in Production

I'm a seasoned software developer with a passion for crafting elegant code. I solve Rubik's cubes, play D&D, and enjoy over analysing everything.