6 Rust programming mistakes to watch out for

Everyone's favorite memory-safe programming language has its share of snags. Here are six mistakes to watch for when writing Rust code.

6 Rust programming mistakes to watch out for
Only_NewPhoto/Shutterstock

Rust offers programmers a way to write memory-safe software without garbage collection, running at machine-native speed. It's also a complex language to master, with a fairly steep initial learning curve. Here are five gotchas, snags, and traps to watch for when you're getting your footing with Rust—and for more seasoned Rust developers, too.

Rust gotchas: 6 things you need to know about writing Rust code

  • You can't 'toggle off' the borrow checker
  • Don't use '_' for variables you want to bind
  • Closures don't have the same lifetime rules as functions
  • Destructors don't always run when a borrow expires
  • Beware of unsafe things and unbounded lifetimes
  • .unwrap() surrenders error-handling control

You can't 'toggle off' the borrow checker

Ownership, borrowing, and lifetimes are baked into Rust. They're an integral part of how the language maintains memory safety without garbage collection.

Some other languages offer code-checking tools that alert the developer to safety or memory issues, but still allow the code to be compiled. Rust does not work that way. The borrow checker—the part of Rust's compiler that verifies all ownership operations are valid—is not an optional utility that can be toggled off. Code that isn't valid for the borrow checker will not compile, period.

An entire article could be (and maybe ought to be) written about how not to fight the borrow checker. It's well worth reviewing the Rust by Example section on scoping to see how the rules work for many common behaviors.

In the early stages of your Rust journey, remember you can always work around ownership issues by making copies with .clone(). For parts of the program that aren't performance-intensive, making copies will rarely have any measurable impact. Then, you can focus on the parts that do need maximum zero-copy performance, and figure out how to make your borrowing and lifetimes more efficient in those parts of the program.

Don't use '_' for variables you want to bind

The variable name _—a single underscore—has a special behavior in Rust. It means the value being received into the variable isn't bound to it. It's typically used for receiving values that are to be discarded immediately. If something emits a must_use warning, for instance, assigning it to _ is a typical way to silence that warning.

To that end, don't use the underscore for any value that persists beyond the statement it's used in. Note that we are talking here about the statement, not the scope.

The scenarios to watch out for are the ones where you want something to be held until it falls out of scope. If you have a code block like


let _ = String::from("  Hello World  ").trim();

the created string will immediately fall out of scope after that statement—it won't be held until the end of the block. (The method call is to ensure the results aren't elided out in compilation.)

The easy way to avoid this gotcha is to only use names like _user or _item for assignments that you want to persist until the end of the scope but don't plan to use for much else.

Closures don't have the same lifetime rules as functions

Consider this function:

fn function(x: &i32) -> &i32 {
    x
}

You might try to express this function as a closure, for a return value from a function:


fn main() {
    let closure = |x: &i32| x;
}

The only problem is, it doesn't work. The compiler will squawk with the error: lifetime may not live long enough, because the closure's input and output have different lifetimes.

One way to get around this would be to use a static reference:


fn main() {
    let _closure: &dyn Fn(&i32) -> &i32 = &|x: &i32| x;
}

Using a separate function is more verbose but avoids this kind of issue. It makes scopes clearer and easier to parse visually.

Destructors don't always run when a borrow expires

Like C++, Rust allows you to create destructors for types, which can run when an object falls out of scope. But that does not mean they are guaranteed to run.

This is also true, perhaps doubly, for when a borrow expires on a given object. If a borrow expires on something, that does not imply its destructor has already run. In fact, there are times when you don't want the destructor to run just because a borrow has expired—for instance, when you're holding a pointer to something.

The Rust documentation has guidelines for ensuring a destructor runs, and for knowing when a destructor is guaranteed to run.

Beware of unsafe things and unbounded lifetimes

The keyword unsafe exists to tag Rust code that can do things like dereferencing raw pointers. It's the kind of thing you don't need to do very often in Rust (we hope!), but when you do, you now have a whole new world of potential issues.

For instance, dereferencing a raw pointer produced by an unsafe operation results in an unbounded lifetime. Rustonomicon, the book about unsafe Rust, cautions that an unbounded lifetime "becomes as big as context demands." That means it can unexpectedly grow beyond what you originally needed, or intended.

If you are scrupulous about what you do with an unbounded reference, you shouldn't have a problem. But for safety, it's better to place dereferenced pointers in a function and use lifetimes at the function boundary, rather than let them rattle around inside the scope of a function.

.unwrap() surrenders error-handling control

Whenever an operation returns a Result, there are two basic ways of handling it. One is with .unwrap() or one of its cousins (such as .unwrap_or()). The other is with a full-blown match statement to handle an Err result.

.unwrap()'s big advantage is that it's convenient. If you're in a code path where you don't ever expect an error condition to arise, or where an error condition would be impossible to deal with anyway, you can use .unwrap() to get the value you need and go about your business.

All this comes at a cost: any error condition will cause a panic and stop the program. Panics in Rust are unrecoverable for a reason: they're a sign that something is wrong enough to indicate an actual bug in the program.

If you use .unwrap() or one of .unwrap()'s variants, like .unwrap_or(), be aware that you still only have limited error-handling capacity. You must pass along a value of some kind that conforms to the type an OK value would produce. With match, you have far more flexibility of behavior than just yielding something of the proper type.

If you don't think you will ever need that flexibility in a given program path, .unwrap() is okay. It's still recommended, though, to try writing a full match first to see if you've overlooked some aspect of the handling.

Copyright © 2024 IDG Communications, Inc.