Designing Around Our Flaws: How safe Rust avoids the pitfalls of C and C++

Jeff Hiner
Aug 9 · 9 min read
Photo by SAMS Solutions on Unsplash

This is part 3 in a series about how we at Dwelo rewrote our IoT platform in Rust.

So now that I’ve thoroughly and perhaps unfairly roasted several design flaws of a programming language that’s over forty years old and runs most of the world’s embedded devices, let’s talk about how Rust designs out those problems while still retaining the parts of C and C++ that make them powerful and useful languages.

Note: specifically, I’m going to be talking about “safe” Rust here. You can still leap headfirst over the guardrails using the unsafe keyword. But in general most code shouldn’t need to.

Algebraic data types

“Algebraic data types” is a fancy way of describing enumeration types that are completely integrated, actually sane and safe, and allow the language spec to enforce best practices. They’re a common feature in more modern counterparts to classic languages, because they have useful properties with respect to correctness. It’s an advantage languages like Scala, Kotlin, and Swift have over Objective C and older versions of Java. Algebraic types are a bit like a C enum on steroids: Rust enumerations can contain data fields. They also resemble a C union in that they only take up as much space as the largest field (plus a discriminator, in most cases). But unlike a union you can’t accidentally misinterpret the bytes of the field as the wrong variant.

Since this is a bit of an abstract concept, its utility is best explained using a couple generic types that are part of the core language and used everywhere: Result and Option.

Result is a type that can be either a successful value or an error. Where a C function would conventionally return an int that might be negative or a file handle that might be 0, Rust does things differently.

use std::fs::File;
use std::io::prelude::*;

The return type of File::create is a singular item containing either a file handle or an error. Before you can even use the file handle, you must unpack the Result type and do something with potential errors. In the example above, the ? operator does an early return from our function in case of an error. If it’s a success, the wrapped File is unpacked into our file variable where we can use it. Note that the write_all call can also return an error, and we have to handle it. Again, this example uses the ? operator because the author wants to percolate that error upstream with an early return. We could just as easily print an error message and skip the file operations, or provide an alternative default, or even panic and halt the program immediately. But we aren’t allowed to just ignore it.

fn frob_widget() -> Result<(), SomeErrorType> { ... }

For functions that don’t return anything under normal conditions, the code can express that an error might happen and must be dealt with.

Option expresses the case where something may or may not exist. Let’s say you’re asking a key/value store (a dictionary or a map, depending on where you learned the concept) to return and remove a value associated with a key. If the key was in the store, the function should return the value. If it wasn’t, the function should return the absence of a value. As with Result, you can’t just assume the thing is there and use it. Here’s an example:

use std::collections::HashMap;

The string isn’t in the map after we remove it, so trying to remove it a second time will return None.

No mystery pointers

Rust forgoes pointers in favor of references. Through a set of clever design decisions surrounding references, safe Rust eliminates the “mystery pointer” problem that pervades C and C++ programs.

Const by default

In C, variables and function arguments are mutable by default, and the const keyword is used to restrict mutability. In Rust, it’s the opposite: variables and function arguments are const by default, and you have to add a keyword to indicate otherwise. This has the really subtle effect of discouraging code with side effects, and promoting a coding style with fewer moving parts. If your code uses the mut keyword when it’s unnecessary, the compiler generates a warning.

Build and return-by-move

Passing pointers-to-uninitialized to a function to store a result was common practice in C, but it’s also the standard way to pass structs that are intended to be read and modified in place. This created some rather awkward mixes of inputs and outputs, and allowed for situations where “output” pointers were written with valid data in some scenarios and left uninitialized in others. For example:

/* Modifies an entity position and returns nonzero on error. */
/* Writes the Cartesian distance changed into distance */
/* if the object could be moved. */
int move(obj_t *obj, double *distance, const vec_t *v);

In Rust the canonical example makes the intent much clearer, and prevents a dangling pointer to an uninitialized double:

/// If successful, returns distance moved
fn move(&mut self, v: &Coordinates) -> Result<f64, ErrorType> { ... }

References always point to something

In Rust, a reference &T always points to an actual T. Like C++ references, a Rust reference can’t be null — within the bounds of safe Rust, it is impossible to deliberately or inadvertently create a reference that points to “null” or a not-yet-created struct. There’s also no way to free an object if you only have a reference to it. In addition, there’s one other clever feature that allows the language to provide even stronger guarantees.

Rust references have lifetimes. This is the one really unique thing about Rust, and the idea with the biggest learning curve. At compile time, this addition to the language guarantees there’s no way to free or move an object out from under a reference — if you try to do something with a potentially referenced object in a way that might compromise this guarantee, the program will not compile. This guarantee even holds across threads. Say goodbye to use-after-free issues forever!

No need for null pointers

What about the use case from C/C++ where you want to pass a pointer to optional data, i.e. a pointer that might or might not point to something? In those languages, you’d pass a pointer argument and then (hopefully) the function implementation would check for null before using it. In Rust, Option<&T> is the safe alternative. Internally Rust uses pointers to represent its reference types, so on computers where 0 isn’t a valid pointer value (i.e. the architectures Rust supports) the compiler will optimize the implementation of Option<&T> to avoid any size penalty for the enumeration. If you’re really curious about the details, there are entire rabbit holes you can dive into about the subject.

In summary: for T that doesn’t need dynamic dispatch, Option<&T> generates machine code identical to a properly null-checked C pointer. And it’s safer.

Slices, not pointers

Arrays in C are just pointers with special syntax. This can cause all sorts of confusion if the API documentation isn’t clear. In Rust, references to single objects have different syntax from composite types, and so the two can’t be accidentally confused.

For composite types, Rust has distinct types for variable-sized arrays (vec), fixed-size arrays, and “slices” of contiguous data. These composite types all inherently know their size and support iteration via both functional paradigms and imperative loops. If you use array indices to access a composite type in Rust, the access is bounds-checked at runtime. This makes it impossible to silently overrun a buffer. (You can avoid this check completely by using iterators.)

In summary: references in Rust are predictable

When reading my own or someone else’s Rust code, these properties of references allow me as a programmer to make better assumptions about what lies on either side of a function call. If I’m calling a function that returns Option<&T> the function is telling me it might return nothing, and I have to understand that the reference has a limited lifetime and points to immutable data — I can immediately invoke functions on it and might even be able to clone the object, but I can’t modify it in place. A return of Option<&'static T> on the other hand indicates that the returned reference, if present, is guaranteed to be valid for the entire execution of the program. If a function accepts String as an argument, that means the function will consume the string and not give it back. I can safely pass a portion of my array as an immutable slice without fear of buffer overruns, and the signature convinces me that the function won’t try to modify or free the memory. All the power and flexibility of pointers is there, but undefined behavior is designed out.

Safer casting rules

On a given platform, u64 and usize might have identical representations in memory, but in Rust they are distinct types requiring an explicit cast. This eliminates 64-bit portability issues in most cases — rather than lurking in the middle of normal-looking mathematical expressions, explicit casts stick out in code reviews like a sore thumb. This encourages everyone to use the correct types from the start. And if there’s a rounding error, there’s an obvious place to start debugging.

I’m not going to lie and say that implicit casting is completely gone. There’s still some silent casting that can happen around “references to references to T” (which generally eliminates clutter in places where there’s only one sane way to do things anyway) but for the most part there’s less magic going on.

Safe threading

Photo by Rod Long on Unsplash

The existence of lifetimes within the type system allows the compiler to prevent you from accidentally doing stupid things with references. If you try to pass a naked reference to a heap-allocated or stack-allocated variable between threads, that’s a compile error, and you’re reminded to wrap your object in an atomic reference counter (Arc) to prevent the possibility of use-after-free. If at least one of the threads holding that reference needs write access, then the object needs to be wrapped in a Mutex or RwLock to avoid data races. And unlike some other languages the lock completely wraps the original object, making it impossible to accidentally access without obtaining the lock.

If you just need a thread-safe queue, there are performant built-ins for that. Create an mpsc, move the receiving end into the other thread, and you’re done. It’s easy to use the right tool for the job, and it just works.

If all of these pieces sound incredibly complicated, that’s because doing threading correctly is in fact actually complicated. If you’re doing threaded work with any sort of shared state in C++ and aren’t some kind of genius then you’re likely making at least one subtle mistake. If you’re coding a multithreaded app on a team, you’d better hope that every single person touching the code consistently follows the tightest code guidelines you can think of— and even then there’s no guarantee the pieces won’t quite line up. But in Rust, when threaded code compiles there are strong guarantees about correctness. It’s guaranteed to be free of data races both in your code and all other Rust code yours touches. The Rust team calls this concept “fearless concurrency,” and after decades of chasing down threading bugs I find it incredibly liberating.

You can still write unmaintainable code in Rust. You can still write code with bugs and deadlocks. But the language gently guides you toward clean, legible solutions. And as a result, a lot of the mental baggage is gone.

At Dwelo, we wanted to build a reliable embedded system for our smart apartment IoT platform. We wanted it to be maintainable by normal human beings, it needed to be fast, and we wanted to avoid the thread safety issues that pervaded our initial implementation. We picked Rust, and it was the right call.

In the next series, we’re going to start digging into the guts of our actual implementation, and go into some detail about where we struggled (and hopefully keep others from doing the same).

Dwelo Research and Development

All the Dwelo R&D news that is fit to render.

Jeff Hiner

Written by

I’m an IoT software engineer at Dwelo, a company that is working to make smart apartments a reality.

Dwelo Research and Development

All the Dwelo R&D news that is fit to render.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade