Some things that make Rust lifetimes hard to learn

Monday, March 3, 2025

After I wrote YARR (Yet Another Rust Resource, with requisite pirate mentions), one of my friends tried it out. He gave me some really useful insights as he went through it, letting me see what was hard about learning Rust from a newcomer's perspective. Unsurprisingly, lifetimes are a challenge—and seeing him go through it helped me understand why they're hard to learn.

Here are a few of the challenges he ran into. I don't think that these are necessarily problems, but they're perhaps opportunities to improve educational materials.

They don't map 100% to how long a variable is in memory

My friend gave me an example he's seen a few times when people explain lifetimes.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

And for many newcomers, you see this and you expect it is saying that x and y both have the lifetime 'a, so they live the same amount of time.

But the following is valid:

fn print_longest(x: &'static str) {
    let y = "local";
    let a = longest(x, y);
    println!("{a}");
    drop(a);
    drop(y);
    println!("y is gone");
}

In this example, x and y live for different amounts of time. y doesn't even survive to the end of the function, whereas x should be valid for the entire duration of the program.

That's because lifetimes are talking about a bound on the time something can live. There's some lifetime 'a during which we can say that x and y are both certainly valid. But x and y can both live longer than 'a.

Lifetimes don't change the runtime behavior

Most code we write changes what the program does at runtime. Types can be different, because sometimes you're giving the compiler information about what something is. But most type information can change the runtime behavior!

The simplest example is when you have an integer. You can declare one without a type.

let x = 10;

This has an inferred type, and if you set a different type, like u8, you'll get different behavior at run time.

let x: u8 = 10;

In contrast, lifetimes are only used by the compiler to ensure that borrows are all valid. The compiler can reject your program if invalid borrows are performed, but the binary output should not be affected by the lifetimes of the variables.

It's a different kind of type system

We're used to seeing types in our programming languages, and these type systems are usually pretty similar. Rust's lifetimes are different, though. The borrow checker uses a linear type system to do its work.

These are super cool, and something that I don't understand particularly well. I'm familiar with how to use the borrow checker, but I don't know any of the theory behind them. The premise, as I understand it, is that objects can be used exactly once, allowing you to safely deallocate it after use (since it won't be used again). This prevents multiple concurrent uses (yay, data race protection!) or use-after-free (yay, segfault protection!).

The coolness is why we have it, but it's still pretty tough to understand. You have to learn this whole new type system that's pretty different from everything else you've touched. And most of the resources1 out there don't even mention that it's a different kind of type system!

They share syntax with generics

Another challenge is that the syntax is shared with generics. Even though lifetimes are very different in behavior and type system from generics, they sit inside very similar looking syntax.

This is probably unavoidable—lifetimes are related to all the other types in your code—but it certainly makes things harder to learn.

When you see something like this, you expect that it's generic over a type.

fn something_generic<T>(arg: T) { ... }

And you're right that it is!

But then you have something that looks very similar, like this. And you might expect it to also be generic over a type.

fn something_generic<'a>(arg: &'a str) { ... }

But it's not, in the normal sense. Instead it's generic over a lifetime. And that's a little confusing that those sit in the same spot, especially when it's not called out as a potential gotcha in learning materials.

* * *

Lifetimes have some inherent complexity. The borrow checker is a very valuable tool, and it's great we have it! But with that power and complexity can come challenges in learning, and teaching, the underlying concepts.

I think the current difficulty in learning Rust is due to a lot of things. One aspect is certainly some inherent complexity. But another aspect is that many resources aren't really geared toward the kind of programmer coming to Rust without this background knowledge, and there is room for improvement.

We can make explanations of lifetimes and the borrow checker better and less confusing. Or we can at least make them more empathetic, projecting that it's expected to be confused because there are some good reasons it's hard to understand. And that you'll get there, eventually.


Thank you, Ryan, for generously sharing your thoughts as you went through learning Rust. Our conversations were instrumental in writing this post.


1

I suppose, as the author of YARR, I can fix this in at least one instance.


If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts and support my work, subscribe to the newsletter. There is also an RSS feed.

If you're looking to grow more effective as a software engineer, please consider my coaching services. And if you are looking to solve problems that involve software, you may want to consider my consulting services.

Want to become a better programmer? Join the Recurse Center!
Want to hire great programmers? Hire via Recurse Center!