Why do people even like Rust?

A common argument for choosing to use rust is: "Well, it's memory safe isn't it?". But that is not a good argument; many (most) languages are memory safe. "But it gives you speed/concurrency and control?", yes but so does C, C++, Zig. And while Rust has less footguns than C or C++, it's not likely the small minority of people needing this concurrency or control wouldn't consider avoiding them (e.g. by using C++14 w/ best practices or using Zig).

Well, when the suggestion of using "modern" C++, I think many Rust users will cringe; where the blame is usually put on the complex tendril-like Standard Template Library (STL). But this doesn't seem like a significant factor for this type of crowd (although it's certainly a factor), as they (collectively) seem to be happy to rewrite entire decades of work. What I'm trying to find here is whatever can't be solved with just tooling; like a well-designed package manager + build system + test system.

So what's left to like about Rust? The type system!

To illustrate this let me compare two of my early projects in C++(17)[1] and Rust[2]:

C++

My first non-trivial project in C++17 was a multi-threaded HTTP Server, a challenge (at the time) but nothing too bad as I didn't intend for it be spec compliant or DoS safe; just functional, efficient, and reasonably thread/memory-safe.

Initially I wanted to make the ClientHandler worker threads accept a generic parser so I could easily hack it to support other protocols (e.g. FTP, SMTP, etc), but I quickly gave up on this as I couldn't find a way to do this effectively. I could have used an abstract class with template classs for the ClientHandler/Server but that was quite a bit more effort than worth for something I might not use. I initially copied two libraries: xxhash_cpp and fmt, utilized one that was packaged by my distro liburiparser; I later realized libfmt was also packaged so I removed the copy.

Then everything went smoothly, until I got to the stream buffer. I wanted to use the << operator (a language feature!) to stream data into the socket directly from file if possible, but headers would need to be concatenated so I needed to wrap it something like socket << concat(headerstream, filestream); no problem I thought I'll just look up how the << works and... oh god. I spent 2 entire days of what was a 7 day project just on figuring out how to do this; and guess what I only got half-way: I got the stream concatenation (not the socket input) and even that I cheated on by using a streambuf and just copying everything over manually from the streams at underflow.

Still I liked a lot of the languages features (that actually worked) like lambdas/capture-groups, constructor/destructors (incl. lock_guard), methods, reference-model & std::move; but that's about it over C.

Rust

My first* non-trivial project in Rust was a FT810 GPU driver for a micro-controller (MCU); only one issue, I didn't know which MCU and it wasn't actually the FT810 but the entire range of FT810 to BT816 since I didn't know for sure what we would put in the hardware before-hand (don't ask).

Luckily the BT816 is backwards compatible with the FT810, so I made some feature flags which marked certain functions as available when using certain chip versions. I then proceeded to make a trait for the communication interface (embedded_hal SPI) and started implementing all the functions; added structs to manage the DiplayList, and a method onto the device trait/impl to update them, etc. And even made a DMA direct-blit mode to support embedded-graphics. All without hardware, just cargo check.

*Then the project was delayed indefinitely; just over 9 months later the project was resumed.

I dug my code back up, grabbed a test board, and got to work. embedded_hal was now 1.0 so I wanted to change the communication interface, I missed the initialization sequence (oops), added some additional features, and the direct-blit mode had a few off-by-one errors. I also removed Vec usage to make it no_alloc as well as no_std. All-in-all it took around 6 days to get to a functional driver. All of these changes and I still wouldn't have needed to know which MCU I was going to be running on, because all I needed to do was use the spi trait and I could guarantee all chip that had SPI could use it.

To see why this is incredible (for the non-embedded crowd), there is actually a reference implementation of the FT810-FT813 driver for C but if you check their repository you will find that for every single MCU they have a separate implementation with unknown-to-you default pin numbers and peripheral usage (and you need to search deep to find which peripheral it uses). Due to this the rust implementation also only takes up 4% of the SLOC as the C implementation despite supporting more chips, more configurability, more features, and a more ergonomic interface.

Now my C++ and Rust projects are a few years apart, but weirdly having re-reviewed the code of both I wouldn't change much if I were to remake either which is why I wanted to make this article.

"But...

isn't that still an ecosystem difference? If someone published a C++ base virtual class then vendors could implement that." And technically that is correct but you are going to run into multiple issues:

  • Vendors can't implement multiple versions of the same interface/trait (e.g. the embedded_hal 0.2 vs 1.0 example).
  • Vendors somehow need to decide on the interface without a common way to distribute/fetch/align versions (linearly-read headers can also screw with this).
  • Programmers (traditionally C) need to be convinced that using C++ but without STL is the way to go.

Rust has all of these issues covered, however with Rust you also still get the following advantages:

  • Vendors (& multiplexing libraries) can enforce safe usage of peripherals by forcing a move on usage
  • Not all peripherals are as easily defined in terms of statically defined methods, having generics, constraints, and associated types/constants helps define how an interfaces needs to be implemented.
  • Even without STL/std, heap or no heap, Rust provides memory-safety
  • Undefined/Unexpected behavior can be handled 'gracefully' using the panic_handler or if you want to go more extreme you can do #[no_panic]
  • Improved macros; which allows for, low-overhead debug prints (through any interface you desire) with string interpolation, log-levels, and formatting (defmt), and many more neat features that are only a trait away.
  • Built-in async improving code maintainability of polling & I/O code. Also allowing library developers to expose async versions of their drivers. This is also 'thread'-safe.
  • Well-defined feature flags and configuration route, no global #define #undef #ifdef only constants that are actually constant (typically).
  • Better & more unified tooling.

A lot of these points are specific to embedded or bare-metal code. And that's because these platforms are the least common denominator; if it runs on a 48MHz MCU with 16KB flash/2KB RAM it must be able to run on PCs, cloud VMs, mainframes, phones and browsers. That is what C built it's huge buy-in from, along with some grandfathering.

Maybe I'm restating what everyone already knew, but the key feature of Rust above any other "systems" language is the ability to define strict yet generic interfaces (traits et al.), allowing those interfaces to be published, implemented, and used by anyone who wants to, with minimal effort (which is where the package manager does become important).

Given this I want to leave you with the following thought:
The borrow checker and more specifically lifetimes are useful for strict interfaces and safety, but are they necessary for successful next-gen systems language?

Footnotes

  1. Yes, my first non-trivial C++ project was in C++17, I'm late to the party. Non-trivial here means: projects from scratch, not including any exercises or things I (could have) finished within a weekend. In terms of raw experience I had done a far bit or regular C, and worked on some existing C++ codebases before starting this; much more experience than Rust when I started that project.
  2. Sorry Zig I haven't tried you yet, but I believe your type system is effectively non-existent (in the sense that you need to build your own vtables), but I could be wrong.