Discovering Rust by Playing the Quoridor Board Game
All Marmelab coworkers like to experiment with new technologies. But most of the time, it's still around JavaScript technologies. With Rust, we wanted to explore another path. That was not easy and we had to change our mental model to understand how to use it properly.
Here is the description of our first step in this adventure.
Why Rust?
Rust's popularity keeps growing every day. It has the reputation to be efficient and multipurpose. We wanted to give Rust a try and find if it can fit our needs in web development. Graph based on the Redmonk ranking stats
By the way, Rust has a lot of interesting characteristics, quite far from our habits:
- It's a strongly typed language
- It's compiled, not interpreted
- It doesn't use a garbage collector, so you have to manage the memory yourself
Our Goal
We decided to discover Rust the same way as we do during integration at Marmelab, i.e. by developing a game from scratch.
We developed the same game as Matthieu did during his integration: Quorridor. He already talked about the game rules in another blog post, refer to the Links section if you want to know more.
We wanted to develop the game as a REST API including data persistence - about the same as Mathieu did in Go
Setting Up The Project
You can bootstrap a Rust project in few minutes. Here is how we did it.
Rustup
First of all, you have to use rustup, which installs the rust compiler. You can choose between stable, beta, and nightly compilers, and you can easily switch from one compiler to another.
For our project, we had to use it to install clippy and rustfmt.
We were surprised to have to install them in our system rather than as project dependencies.
Cargo
Then, you have to use Cargo, the Rust package manager. Cargo gets the project dependencies, compiles, builds, and distributes your application (Rust calls an application a crate).
Cargo is embedded with rustup, so no need to care about its installation.
Two commands are enough to run your project:
cargo build
cargo run
The compilation is very fast: less than 5 seconds for our tiny project.
One command to test your crate:
cargo test
And that's it, no need for more to begin.
Crates.io
If you need a crate to generate random numbers, to serialize/deserialize structures, or to build an HTTP server, you can find everything at crates.io, which is the Rust crate registry. Think about it as an npmjs.com equivalent for Rust.
Docker
As for the previous Quoridor integration challenges, we wanted to dockerize the development and deployment of the server. We managed to do it, but with a big disadvantage: dependencies were downloaded every time we built the application. We didn't find any better solution in the few days we spent on this discovery.
Meeting (And Accepting) The Syntax
Rust contains specific features and concepts that you need to understand in order to code correctly. Let's just focus on the ones that we think are the most important.
Modules Structure
Rust uses modules to declare (with mod
) and access (with use
) code parts. Modules can be implicit and can be files or folders.
That reminds us a lot of the Go modules, which were already difficult to apprehend at first, coming from a Node.js background. We met the same difficulty with Rust - it took us some time to make modules work.
To summarize, a module can be:
- a folder containing multiple files
// Insert this line in the main file to declare the folder as a module
mod my_folder;
- a single file containing functions
// Insert this line in the main file to declare it as a module
mod my_file;
- a bloc
mod
inside a file
// Use this line in your file to declare it
pub mod outer_mod {
//...
}
Modules can then be imported into another file using a simple line:
use crate::game::{Game};
Luckily, it is also possible to pick Objects inside a module.
Here, game
is the module name, and Game
is a struct inside this module.
Using Structs and Traits
This one is very important in Rust. Structs and associated Traits are everywhere in Rust design. To summarize, Structs are used to describe object types, and Traits are used to define features associated with these types.
struct Fence { x: u8, y: u8, sensVertical: bool }
trait Orientation {
// Static method signature; `Self` refers to the implementor type.
fn new(x: u8, y: u8, sensVertical: bool ) -> Self;
fn getSens(&self) -> &'static str;
}
impl Orientation for Fence {
// `Self` is the implementor type: `Fence`.
fn new(x: u8, y: u8, sensVertical: bool) -> Fence {
Fence { x: x, y: y, sensVertical: sensVertical }
}
fn getSens(&self) -> Fence {
if self.sensVertical {
"vertical"
} else {
"horizontal!"
}
}
}
To go further, we recommend reading this article: Rust structs, enums and traits
Checking Unused Code
Continuing the GoLang comparison, we were happy to discover the Rust unused code feature. Rust checks unused code when compiling, but it's not blocking like in Go.
Having an unused variable will only return a warning message, and this message is very comprehensible, always pointing to the precise involved code, and proposing a solution to fix it.
api_1 | Compiling quoridor v0.1.0 (/usr/quoridor)
api_1 | warning: unused variable: `p`
api_1 | --> src/game/mod.rs:109:64
api_1 | |
api_1 | 109 | pub fn has_already_a_fence_at_the_same_position(&mut self, p: Position) -> bool {
api_1 | | ^ help: if this is intentional, prefix it with an underscore: `_p`
api_1 | |
api_1 | = note: `#[warn(unused_variables)]` on by default
This feature concerns not only variables but also every unused piece of code.
api_1 | warning: variant is never constructed: `NotFoundError`
api_1 | --> src/error.rs:7:5
api_1 | |
api_1 | 7 | NotFoundError
api_1 | | ^^^^^^^^^^^^^
api_1 | |
api_1 | = note: `#[warn(dead_code)]` on by default
This feature is very useful for debugging and keeping the code clean.
Visibility and Privacy
Rust variables and functions are private by default. That means they can only be used inside the block where they are defined.
To use them outside of their block, you have to use the pub
keyword:
pub fn new_position_square(center: Position) -> PositionSquare {
//...
}
This is the equivalent of export
for JavaScript users. But in Rust, pub
can be set with a specific scope:
- pub(in path)
- pub(crate)
- pub(super)
- pub(self)
Learning how to use these different scopes is a good way to structure a Rust project.
pub mod outer_mod {
pub mod inner_mod {
// This function is visible within `outer_mod`
pub(in crate::outer_mod) fn outer_mod_visible_fn() {}
// This function is visible to the entire crate
pub(crate) fn crate_visible_fn() {}
}
}
Mutability
Rust doesn't like mutability. So every variable is immutable by default, and you'll have this error if you try to change its value:
api_1 | error[E0596]: cannot borrow `squares` as mutable, as it is not declared as mutable
api_1 | --> src/game/board.rs:29:17
api_1 | |
api_1 | 26 | let squares = Vec::new();
api_1 | | ------- help: consider changing this to be mutable: `mut squares`
api_1 | ...
api_1 | 29 | squares.push(Position { column, row });
api_1 | | ^^^^^^^ cannot borrow as mutable
The mut
keyword will allow you to compile the mutating code. But clearly, Rust wants you to avoid this as much as you can.
let mut squares = Vec::new();
Implicit Return In Functions
The last strange thing in Rust for us is that if you don't write a return
statement at the end of a function, the function will automatically return the result of the last code lines - as in Ruby.
For instance, this function will return the built struct PositionSquare
without using the return
keyword
pub fn new_position_square(center: Position) -> PositionSquare {
let north_position = center.translate(0, -1);
let east_position = center.translate(1, 0);
let south_positio = center.translate(0, 1);
let west_position = center.translate(-1, 0);
PositionSquare{north_position, east_position, south_positio, west_position}
}
This seems harmless, but it is very important to keep it in mind when trying to debug some code having an unexplained behavior.
Never Forget The CI, So What About Testing?
Unit Tests
In Rust, unit tests must be in the same file as the tested function. It is a pity because as one unit test checks one behavior of a method, you can have 3-4 tests per function. So your files can become very large, and then, difficult to read. We tried to externalize them, without success.
Integration tests
Cargo looks for integration tests in a dedicated /tests
folder at the root of your project. With actix (the web framework we used for our API), we did not manage to define tests there. We did not find working examples or documentation with it.
Conclusion: First Try, First Failure
We did not go very far for this first contact with Rust. We could only get the board of the starting game. The question is: Why did we get this frustrating result?
- Rust is not a language you can start without reading the documentation. There are a lot of concepts to understand before starting - especially borrowing.
- The velocity was low due to fixing errors one after the other
- We did not find examples of Web APIs that fit our needs
- It was a mistake to convert a procedural go application to a functional Rust one. In Rust, you have to think differently.
For the moment, Rust cannot be an efficient choice at Marmelab to use as a backend. In our opinion, the Rust environment is still too young. There is a lack of important modules to develop an HTTP server, like authentication. A lot of crates are still in version 0.xx, and a lot of crates are very low-level.
But we were happy to discover Rust, and we want to have another try another time.
By the way, the community keeps getting bigger and new specialized crates are regularly created. This language is surely becoming a major one, and we want to be present and not miss its potential for our everyday work.
Links
Rust references
- Official documentation
- Rust Examples
- French podcast talking to rust
- Fast learning article
- Rust structs , enums and traits