What "gotchas" does Rust have?

Compile time / context switching creep.

Every time I start a Rust project, it starts out nice and fast feedback loop. A few crates here & there later, and the compile time is 10+ seconds, and then off to xkcd: Compiling

8 Likes

Cargo's semver convention slightly deviates from the original spec in that it doesn't only assume a breaking change when the major version is incremented. It always assumes breakage when the leftmost non-zero component of the version number increases (i.e., 1.4 and 1.5 are compatible, but 0.4 and 0.5 aren't).

12 Likes

@dtolnay's rust quiz shares a lot more of such edge cases.

13 Likes

Here is something I I think I have had trouble with. I think if you change a cargo file from using a crates.io dependency to a local dependency (or vice versa) for the same crate name (including same version number), I think cargo incremental build can become confused, assuming they are the same, based on the version number.

The solution I use is to increase the version number of the local crate, so cargo realises it is different.

4 Likes

I wrote up a bunch here; it may be a little out-of-date.

13 Likes

A few others I don't think are in there after scanning:

And I just stumbled across this bug about _ being inconsistent to boot.

14 Likes

Value returned from catch_unwind can have a Drop implementation that panics, and therefore bypass your attempt of catching all panics. Similarly Deref, Eq, and other traits for operator overloading can panic, so you need to be suuuuper careful in unsafe code that touches other types.

18 Likes

Auto traits have some gotchas: you can break downstream code by adding a private field to one of your library's types, and managing this sometimes requires careful use of PhantomData.

8 Likes

With inferred lifetime bounds on structs, lifetime relationships of structs are leaky similarly to auto-traits. Choosing to declare these lifetime relationships or not has intentional semantic impact as well.

return-position impl Trait has similar (intentional) leakage.


Tangentially, implied vs. explicit lifetime bounds have unintentional [1] semantic impact:

trait Foo {}
trait Bar {}

impl<'a, 'b    > Foo for &'a &'b () {}
impl<'a, 'b: 'a> Bar for &'a &'b () {}

fn foo<T>() where for<'a, 'b> &'a &'b T: Foo {}
fn bar<T>() where for<'a, 'b> &'a &'b T: Bar {}

fn main() {
    foo::<()>(); // ok
    bar::<()>(); // error: implementation of `Bar` is not general enough
}

Playground. Issue #95921 and others. There are plenty of other HRTB bugs/incompleteness too.


  1. as far as I know; if it's intentional it's definitely still a gotcha ↩︎

3 Likes

One thing that I'm sure everyone uses, but people often don't think about until it causes problems -- some operators "autoref" -- so you define PartialEq as fn eq(&self, other: &Self) -> bool, but you just then write a==b. Usually, if a method takes a reference, you have to explicitly write & when you call it.

The fact that Result<T, E> and Option<T> implement IntoIterator<Item=T> has definitely caused me some confusion before, especially in conjunction with for loops.

7 Likes

Relying on a destructor running when a borrow has expired can break your code since destructors aren't guaranteed to run. Aka "the leakpocalypse"

It's hard to get into situations where it causes huge problems without unsafe code, but it's an interesting interaction of a couple of Rust's features that can be pretty surprising

2 Likes

It’s always important to further explain this statement, since otherwise there’s potential for confusion.

There is no safety guarantee that destructors are run, so you (or others) can write code that skips destructors, e.g. with mem::forget or by creating an Rc<RefCell<…>> cycle. On the other hand, if you don’t deliberately prevent any destructor calls, or a library you’re using does so, then destructors are in fact guaranteed to run; it’s not the case that the rust compiler could simply decide to skip some destructor.

You can use destructors of local variables for safety reasons in code you control (e.g. guard objects whose destructors safely handle panicking code paths are very common in unsafe code), and library code can rely on destructor calls for anything non-memory-safety-related, so it’s commonly considered a “logic error” to skip calling a destructor, and can have various undesirable effects (except for undefined behavior).


Speaking of gotchas that are exclusive unsafe code there are lots more. A few that come to mind immediately:

  • Rust references have a lot more safety requirements than raw pointers (or pointers/references in C/C++). For example, mutable references need to be “exclusive”[1], even in unsafe code, and safety requirements for references can even translate to raw pointers, following the stacked-borrows model
  • panic paths are very implicit, and for unsafe code it’s essential to review all panic paths for whether they uphold safety guarantees of the language and/or of unsafe functions you’re calling. Keyword: “panic safety”

Looking at that “leakpocalypse” link, I think that all the other thing listed on that same page would actually classify as “gotchas”, too, (and also all of them are – at least somewhat – related to unsafe code). Also, for other readers: this “leakpocalypse” pattern is also commonly known under the term “pre-pooping your pants”.


  1. the details of what does or doesn’t count as being “exclusive” are actually quite complicated, because other references can still exist concurrently if they are being re-borrowed ↩︎

15 Likes

Good news: this should get better soon

https://github.com/rust-lang/rust/issues/99272

4 Likes

Why would a destructor run when a borrow expires? It should be run when the original owning reference goes out of scope, no?

Yes. These were types with a lifetime parameter representing a borrow of something else (struct Guard<'a>;). They were relying on that lifetime to stay valid up until the destructor ran for the type.

However you can use std::mem::forget() to end the borrow without running the destructor and then potentially access the data that the lifetime would have been protecting in a way that violates memory safety.

1 Like

Gotcha! :+1:t2:

Async rust have quite some gotchas, you can see what user submitted in the status quos.

https://rust-lang.github.io/wg-async/vision/submitted_stories/status_quo.html

5 Likes

Lifetime elision in closures and functions is different.

fn foo(a: &i32) -> &i32 means:

fn foo<'a>(a: &'a i32) -> &'a i32

but |a: &i32| -> &i32 {…} means:

|a: &'a i32| -> &'new_lifetime_who_dis i32

and closures don't have a syntax to specify this. You can only hint the right lifetimes via type inference.

17 Likes

Help is on the way, though. And Yandros has a macro implementation of the same syntax.

7 Likes