Compile time errors are nice

2023-02-22

When programming in Rust, it’s common to write a bunch of code and then have it magically work the first time you run it. This is not because the language gives us programmer superpowers, but because its strong and rich type system prevents certain types of bugs from ever making it into your application. This is something we as programmers should aim for, as any error that we can catch while writing code is an error that could otherwise cause our system to fail when we least expect it.

In this article, I’ll go through a few situations in which the Rust compiler finds issues in your code that other languages would not detect until your program is run. While Rust is known to fix memory safety issues that languages like C and C++ often hit, I’ll instead focus on problems you can find in other languages as well.

Consuming values by transferring ownership

Sometimes you want certain operations to “consume” the arguments they’re applied to, and to prevent the programmer from using them afterwards. In most languages, nothing is stopping you from using these “consumed” values afterward and this is instead a rule you must keep in your head while writing code.

As an example, let’s look at something we can easily translate into various languages: the act of closing a file, which causes any subsequent read or write operations on it to fail. In most languages, you’ll only realize you made a mistake during your program’s execution:

file = open("/some/path")

# do something useful...

file.close()

# do something else...

file.read() # oops!

The Python code above opens a file, does something with it, closes it, and eventually attempts to read from it. This last operation will fail because the underlying file descriptor is already closed.

The same example can be translated to Java:

FileReader reader = new FileReader(someFile);
reader.close();

reader.read(buffer); // oops

And C++:

ifstream input("/some/path");
input.close();

// oops. This doesn't even throw by default!
input >> some_variable;

The list goes on but you get the idea. None of these languages can represent operations that consume the file and prevent the programmer from accidentally using it after it’s closed.

On the other hand, while Rust’s std::fs::File doesn’t have a close function as that happens automatically when the file goes out of scope (e.g. by drop()-ping it), one could imagine if it existed it would likely look something like:

impl File {
  // ...
 
  fn close(mut self) -> std::io::Result<()> {
    // ...
  }
}

Since this function takes the file (self) by value, the compiler (via the borrow checker) will enforce that you don’t use the file after calling this function:

let mut file = File::open(...)?;
file.close()?;

// Fails to compile: "borrow of moved value: `file`"
file.read(&mut some_buiffer)?; // nope!

The person using this type can’t misuse it. At the same time, the person implementing this type can write simpler code as there’s no need to track whether the underlying file descriptor is valid; you’ll never have a file handle that points to an already closed file.

Resource Acquisition Is Initialization (RAII)

RAII is a pattern introduced by C++ and adopted by D and Rust (and possibly others) where a resource is allocated when an object is constructed and deallocated when it’s destructed. RAII gets rid of a whole class of errors that require some operation to be performed after we’re done using a resource: freeing an allocated chunk of memory, closing a file, releasing a mutex, etc.

Some languages contain special statements that allow you to acquire a resource and run a block of code while holding it, automatically releasing it at the end of it. Both Python (via the with statement) and Java (via the try-with-resources statement) allow for this, but the problem with these is you need to explicitly choose to use them. That is, nothing is preventing you from running into the problems listed above, whereas in languages that support RAII and use it appropriately you can’t circumvent that. In this case, C++ will automatically close the file when it goes out of scope, but it also allows you to circumvent RAII by letting you prematurely close it.

I prefer being in a situation where the compiler will shout at me rather than something blowing up on runtime and then realizing that “duh, you’re supposed to do that to avoid this issue!”.

Go’s defer

Go contains a defer keyword which indicates a certain operation should be performed at the end of the current function. This can be used, for example, to defer the release of a mutex and let that be done automatically when the function ends. This just sounds like a slightly more manual RAII but it contains several gotchas. While most of these are surprising (at least to me!), my favorite is probably the fact that defer operates at the function and not at the scope level:

func Something(m *sync.Mutex) {
  if foo {
    m.lock();

    // this runs at the end of the function...
    defer m.unlock();

    // do something useful...
    // and something else...
  }

  // oops: we're still holding the lock!
  SomeLongBlockingOperation();
}

This violates the notion of a scope: why is it that something that’s guarded by an if statement ends up having side effects that extend beyond its scope? This example looks silly but you can imagine bits of this function being written at different times (e.g. the if statement, then the blocking operation, etc) by potentially different people and now you have a perfectly possible scenario where every change was thoughtfully made but all of them combined produced unexpected results.

References and values

In some languages like Java, (almost) everything is a reference. This coupled with the inability to specify immutability can have surprising side effects as you’re handing over references to your object to other functions without knowing whether they’ll modify it or not.

Other languages provide value semantics where objects/values can be copied. This can prevent the problem above but can also create others:

vector<uint8_t> compress(vector<uint8_t> data) {
  // ...
}

vector<uint8_t> data = ...;

// oops: this copies a bunch of data!
auto compressed = compress(data);

The C++ code above accidentally copies the argument, incurring some performance penalty that varies in severity depending on the size of the vector. I remember making this exact mistake with a function that was being called several times per second taking a large map (500MB+) by value. It sure felt good to make a one-character change (adding a &) that improved performance by 100x though!

What’s in some way nice about C++ is that you can specify that objects of a given type can’t be copied. Go, on the other hand, doesn’t allow this (unless you reach for hacks) so you can have arguably worse side effects:

// oops: we're taking it by value!
func Foo(m sync.Mutex) {
  m.Lock();

  // oops: not actually guarded!
  DoSomething();

  m.Unlock();
}

There are more details in this article but what’s happening here is the function is taking a mutex by value rather than accepting a pointer to it. This means the caller will provide a copy of the lock protecting a resource and therefore giving multiple threads the illusion that they’re holding the lock when in reality they’re holding a volatile and useless copy of it.

Rust, on the other hand, makes copying an opt-in feature. By default nothing is copyable but only movable (well, almost everything), and you need to explicitly implement the Clone and Copy traits (or more often use their derive macro counterparts) on a per-type basis to allow for that:

struct OnlyMovable;

#[derive(Clone)]
struct Cloneable;

#[derive(Clone, Copy)]
struct Copyable;

fn foo<T>(value: T) {}

fn bar() {
  let movable = OnlyMovable;
  let cloneable = Cloneable;
  let copyable = Copyable;

  // We can no longer use it.
  foo(movable);

  // Hand over an explicit copy, we can still
  // use the original.
  foo(cloneable.clone());

  // Hand over an implicit copy, we can still
  // use the original.
  foo(copyable);

  // Same as the above (except the linter will
  // complain for being too explicit!).
  foo(copyable.clone());
}

It’s your responsibility to decide whether it makes sense for your types to be only movable, cloneable, or copyable:

  • Types that can only be moved make sense for situations where the type requires exclusive ownership over an underlying resource. Allowing things like file handles to be cloned doesn’t make sense as you lose track of who is in charge of ultimately closing it.
  • Besides move-only types, most types you define should support cloning. For example vectors, strings, and maps need to support being cloned as there’s no reason to add that restriction.
  • Copying is just an implicit call to clone() and only makes sense for very lightweight types that are cheap to clone. For example, an integer is very cheap to clone, whereas a vector isn’t as that involves allocating memory.

Runtime errors

Several languages use exceptions to represent runtime errors. The main functional issue with these is that there’s no way for the programmer to know that a function can raise unless they read its documentation and they’re lucky enough that the library’s author specified that. In Rust, just like in Go, errors are part of the type system so you can immediately know if a function can fail by looking at its signature. The compiler will even shout at you if you’re not checking whether a fallible function has failed or not!

fn fallible() -> Result<u32, std::io::Error> {
  // ...
}

// Compilation fails: "unused `Result` that
// must be used".
fallible();

In other languages, it’s common to have the contract for functions specified as part of their documentation. For example, have you seen a function’s documentation that looks like this?

Note: calling functions a, b, and c after calling this function will raise an AlreadyFooedException.

What about this one?

Note: this raises a NotBarredException if MyType.bar wasn’t called before calling this function.

These “documentation-based contracts” have various issues:

  • You need to thoroughly read the documentation to ensure your code won’t let an exception through. You should always RTFM but relying on it to ensure your code is correct when you could avoid it is a bad idea.
  • This assumes documentation is perfect and always indicates what a function’s contract is. This is certainly not always the case and it’s very common to find libraries that contain undocumented function restrictions.
  • New versions of a third-party library can change a function’s contract. Even if you are extremely careful and read the new version’s changelog (assuming this is even mentioned in there!) when upgrading, it can easily slip through.

Of course, there are good and bad APIs but without the language’s support, it’s often hard to avoid these types of “unenforceable” contracts. Rust’s type system allows many of these contracts to be instead directly enforced when you compile your code.

What about linters?

Linters are tools that analyze your source code and try to detect issues that someone previously defined a rule for. For example, Go’s mutex copying issue can be detected by go vet. However, relying on linters to detect these issues is not a perfect solution:

  • You need someone to realize some particular situation is an issue and come up with a rule for it before the linter can detect it.
  • You simply can’t catch all issues. Many situations are far too complex for a linter to detect, at least not without the language’s support.

This is not to say that Rust’s type system catches every bug at compile time. Clippy has a lot of useful lints, including several on the “correctness” and “suspicious” categories which typically indicate there’s a bug in your code. In my experience, the lints that I usually hit are either style or performance related. For example, I’ve either added a & that is unnecessary or I’ve done a clone() that’s not needed and therefore hurts performance. This is not to say that I don’t make mistakes but I think that instead most of the mistakes are caught by the compiler before Clippy is run.

No Fewer surprises

All of this makes Rust a boring language: you write your code and it usually just works, which is great. Rust doesn’t prevent you from writing logic errors but many of the bugs that would blow up in your face during runtime in other languages are instead detected by the compiler. This gives us a higher sense of confidence when writing code and helps us produce more reliable applications.