I've been a programmer for 20+ years, and few things excite me as much as Rust. My background is mostly in C++, though I have also worked in Python and Lua, and dabbled in many more languages. I started writing Rust around 2014, and since 2018 I've been writing Rust full time. In my spare time I've developed a popular Rust GUI crate, egui
.
When I co-founded Rerun earlier this year, the choice of language was obvious.
At Rerun we build visualization tools for computer vision and robotics. For that, we need a language that is fast and easy to parallelize. When running on desktop we want native speed, but we also want to be able to show our visualization on the web, or inline in a Jupyter Notebook or IDE.
By picking Rust, we get speed that rivals C and C++, and we can easily compile to Wasm. By using Rust for both our frontend and backend, we have a unified stack of Rust everywhere, simplifying our hiring.
Speaking of hiring, we hoped that by picking Rust, we would attract more high quality developers. This bet on hiring turned out even better than we had hoped.
Ok you got me! Those are only a part of the reasons we chose Rust. If I'm honest, the main reason is because I love Rust.
I believe Rust is the most important development in system programming languages since C. What is novel is not any individual feature ("Rust is not a particularly original language"), but the fact that so many amazing features have come together in one mainstream language.
Rust is not a perfect language (scroll down for my complaints!), but it's so much nicer than anything else I've used.
I'm not alone in loving Rust - Rust has been the most loved language in the Stack Overflow Developer Survey for seven years straight. So what are the features that make me love Rust so much?
"Wait, that's two features!" - well yes, but what is novel is that I get both.
To be clear: what I'm talking about here is memory safety, which mean handling array bounds checks, data races, use-after free, segfaults, uninitialized memory, etc.
We've had fast languages like C and C++, and then we've had safe languages like Lisp, Java, and Python. The safe languages were all slower. Common wisdom said that a programming language could either be fast or safe, but not both. Rust has thoroughly disproved this, with speeds rivaling C even when writing safe Rust.
What's even more impressive is that Rust achieves safety and speed without using a garbage collector. Garbage collectors can be very useful, but they also tend to waste a lot of memory and/or create CPU spikes during GC collection. But more importantly, GC languages are difficult to embed in other environments (e.g. compile to Wasm - more on that later).
The big innovation leading to this "fast safety" is the borrow checker.
The Rust Borrow Checker has it's roots in the Cyclone research language, and is arguably the most important innovation in system program languages since C.
The gist of it is: each piece of data has exactly one owner. You can either share the data or mutate it, but never both at the same time. That is, you can either have one single mutating reference to it, OR many non-mutating references to the data.
This is a great way to structure your program, as it prevents many common bugs (not just memory safety ones). The magic thing is that Rust enforces this at compile-time.
A lot of people who are new to Rust struggle with the borrow checker, as it forbids you from doing things you are used to doing in other languages. The seasoned Rustacean knows to cut along the grain, to not fight the borrow checker, but to listen to its wisdom. When you structure your code so that each piece of data has one clear owner, and mutation is always exclusive, your program will become more clear and easy to reason about, and you will discover you have fewer bugs. It also makes it a lot easier to multi-thread your program.
Rust's enum
s and exhaustive match
statement are just amazing, and now that I'm using them daily I can barely imagine how I could live without them for so long.
Consider you are writing a simple GUI that needs to handle events. An event is either a keyboard press, some pasted text, or a mouse button press:
enum Event { KeyPress(char), Pasted(String), MouseButtonPress { pos: Pos2, button: MouseButton, } } fn handle_event(event: Event) { match event { Event::KeyPress(c) => { … } Event::Pasted(text) => { … } Event::MouseButtonPress{ pos, button } => { … } } }
If you add another alternative to enum Event
, then handle_event
will fail to compile until you add a handler for that new alternative.
Implementing the above in C or C++ would be very difficult and error prone (and the very existence of std::variant
makes me weep in dispair).
Error handling is an extremely important aspect of the job of an engineer, and failure to report errors can lead to very serious bugs.
In C and Go you have to manually check and propagate errors:
obj, err := foo() if err != nil { return 0, err } result, err := obj.bar() if err != nil { return 0, err }
This is extremely verbose and it is easy to forget an error.
In languages with exceptions, like C++, Java, and Python, you instead have the problem of invisible errors:
auto result = foo().bar();
As a reader, I can't see where potential errors can occur. Even if I look at the function declaration for foo
and bar
I won't know whether or not they can throw exceptions, so I don't know whether or not I need a try/catch
block around some piece of code.
In Rust, errors are propagated with the ?
operator:
let result = foo()?.bar()?;
The ?
operator means: if the previous expression resulted in an error, return that error. Failure to add a ?
results in a compilation error, so you must propagate (or handle) all errors. This is explicit, yet terse, and I love it.
Not everything is perfect though - how error types are declared and combined is something the ecosystem is still trying to figure out, but for all its flaws I find the Rust approach to error handling to be the best I've ever used.
Rust will automatically free memory and close resources when the resource falls out of scope. For instance:
{ let mut file = std::fs::File::open(&path)?; let mut contents = Vec::new(); file.read_to_end(&mut contents)?; … // when we reach the end of the scope, // the `file` is automatically closed // and the `contents` automatically freed. }
If you're used to C++ this is nothing new, and it is indeed one of the things I like the most about C++. But Rust improves this by having better move semantics and lifetime tracking.
This feature has been likened to a compile-time garbage collector. This is in contrast with a more common runtime garbage collected language, where memory is freed eventually (at some future GC pass). Such languages tend to use a lot more memory, but worse: if you forget to explicitly close a file or a socket in such a language, it will remain open for far too long which can lead to very subtle bugs.
I find WebAssembly (or Wasm for short) a very exciting technology, and it probably deserves a blog post on its own. In short, I am excited because with Wasm:
So what does Wasm have to do with Rust? Well, it is dead easy to compile Rust to Wasm - just pass --target wasm32-unknown-unknown
to cargo
, and you are done!
And then there is wasmtime
, a high performance runtime for Wasm, written in Rust. This means we can have fast plugins, written in Rust, compiled to Wasm, running in our tool. Rust everywhere!
The Rust trait
is really nifty as it is the interface for both run-time polymorphism and compile-time polymorphism. For instance:
trait Foo { fn do_stuff(&self); } // Run-time polymorphism (dynamic dispatch). // Here `Foo` acts like an Java interface or a abstract base class. fn runtime(obj: &dyn Foo) { obj.do_stuff(); } // Compile-time polymorphism (generics). // Here `Foo` acts as a constraint on what types can be passed to the function // (what C++ calls a "concept"). fn compile_time<T: Foo>(obj: &T) { obj.do_stuff(); }
Rust has amazing tooling, which makes learning and using Rust a much more pleasant experience compared to most other languages.
First of all: the error messages from the compiler are superb. They point out what your mistake was, why it was a mistake, and then often point you in the right direction. The Rust compiler errors are perhaps the best error messages of any software anywhere (which is fortunate, since learning Rust can be difficult).
Then there is Cargo, the Rust package manager and build system. Having a package manager and a build system for a language may seem like a low bar, but when you come from C++, it is amazing. You can build almost any Rust library with a simple cargo build
, and test it with cargo test
.
Rust libraries are known as crates (and can be browsed at crates.io). Though the ecosystem is nascent, there is already a plethora of high quality crates, and trying out a crate is as easy as cargo add
. There is of course some legitimate worry that the Rust crate ecosystem could devolve into the crazy left-pad world of npm
, and it is something to be wary about, but so far the Rust crates keep an overall high quality.
And then there is the wonderful rust analyzer which provides completion, go-to-definition, and refactoring to my editor.
Rust documentation is also really good, partially because of the effort of its writers, partially because of the amazing tooling. cargo doc
is a godsend, as are doc-tests:
/// Adds two numbers together. /// /// ## Example: /// ``` /// assert_eq!(add(1, 2), 3); /// assert_eq!(add(10, -10), 0); /// ``` fn add(a: i32, b: i32) -> i32 { a + b }
The compiler will actually run the example code to check that it is correct! Amazeballs!
It's not all unicorns and lollipops. Rust has some pretty rough edges, and may not be for everyone.
Rust is difficult, and it takes a while to learn. Even if you know C and some functional programming, you still need to learn about the borrow checker and lifetime annotations. Still, I would put Rust as both simpler and easier than C++.
This is unfortunately something Rust has inherited from C++. Things are bad, and are only slowly getting better, and I doubt it will ever be fast as e.g. Go.
You will see a lot of <'_>
and ::<T>
in Rust, and it ain't always pretty (but you get used to it).
f32
and f64
does not implement Ord
. This means you cannot sort on a float without jumping through a lot of hoops, and this is very annoying. I wish the float would just use total ordering and take the performance hit.
Same with Hash
, which f32
and f64
also doesn't implement.
Thankfully there is the ordered-float
crate, but the ergonomics of using a wrapped type isn't great.
The Rust crate ecosystem is good, but C and C++ has a huge head start and it will take a long time for Rust to catch up. For us at Rerun, that pain is most urgently felt in the lack of libraries for scientific computing and computer vision, as well as the lack of mature GUI libraries.
Five years ago my gripes with Rust were much longer. Rust is steadily improving, with a new release every six weeks. This is an impressive pace, especially since there are no breaking changes.
At the end of the day, a programming language is a tool like any other, and you need to pick the right tool for the job. But sometimes, the right tool for the job is actually the tool you love the most. Perhaps that is exactly why you love it so much?
In many ways, using C++ for the engine, Go for the backend, and JS for the frontend would have been the "safe" choice. We could have made use of the many great C++ libraries for linear algebra and computer vision, and we could have used one of the many popular and excellent frontend libraries for JS. In the short term that might have been the right choice, but it would have severely limited what we could accomplish going forward. It would have been a bet on the past. At Rerun, we are building the tools of the future, and for that we need to be using the language of the future.
If you're interested in what we're building at Rerun, then join our waitlist or follow me on Twitter.