Post

Exploring Rust 2024 edition

Less exciting than I thought

Exploring Rust 2024 edition

Rust 2024

The Rust 2024 edition went live this week with the release of Rust 1.85.0. It’s been available for a long time on the unstable channel but now it’s finally stable and we get to play around with it for real. With it comes a whole slew of changes such as async closures, new lifetime capture rules and changes to if let temporary scopes. This is the first time I am actively using Rust around the release of a new edition so I am excited to see what this is all about. The last one was Rust 2021 released in version 1.56.0 in October, 2021. This won’t be an exhaustive look at all the changes but focus on the more interesting ones.

What are “Editions”?

As usual the Rust book contains great documentation on what an edition is but I will try to summarize it here. Around the Rust 1.0 release “Stability as a Deliverable” was declared as a commitment for the Rust team. This means that once a feature hits stable that feature is expected to be supported in perpetuity. Wait, isn’t that how we ended up with some of the more maligned aspects of C++? One of the most talked about issues in the C++ ecosystem is how much old crud needs to be maintained because rarely are things removed while new additions are common. This means the language is always increasing in complexity, never decreasing. The C++ committee is also very reluctant to add new keywords and syntax that risk breaking code bases such as discussions surrounding typeof versus decltype, concept and lambda among others. What’s the plan for Rust make sure it doesn’t end up with the same problems?

Enter editions. We recognize there is a need to break backwards compatibility sometimes. Editions exist as a structured way to solve this problem. Here’s how it is done:

  • Editions are opt-in, defined on the crate level
  • Compatibility breaking changes for the language are pushed into upcoming editions
  • Editions can reserve keywords without adding functionality
  • Crates in one edition must seamlessly interoperate with crates compiled for other editions

This means that if your project is stuck on the 2021 edition you can use crates updated to the 2024 edition as long as you are able to upgrade your compiler version to also support the latest edition. This does mean that all editions compile to the same intermediate representation (IR) so you could for example not break ABI or IR between editions.

Now we know what an edition is and what purpose they fulfill, let’s jump to what is included in the 2024 edition!

Language

These are the more interesting changes to the Rust language itself. Not all of them are something that will have immediate impact on most codebases but they might point towards an exciting future!

Return Position Impl Trait (RPIT) lifetime capture rules

We’re opening with a mouthful but probably the most interesting change in the edition. You can read the full RFC here. Rust 2024 is flipping the default captures when returning traits from functions. Instead of not capturing any lifetime generics it it now captures all of them default.

1
2
//Does not compile in 2021 but does compile in 2024
fn foo<'a>(x: &'a ()) -> impl Sized { x }

This does not compile in the 2021 edition with the error hidden type for `impl Sized` captures lifetime that does not appear in bounds. We can solve this using a lifetime bound.

1
2
//Compiles in 2021, with some caveats
fn foo<'a>(x: &'a ()) -> impl Sized + 'a { x }

This is fine because what we are saying is that the return impl Sized needs to outlive 'a, which it does because it’s the only lifetime in our function. But it’s not actually what we want. We’re saying that impl Sized is guaranteed to outlive any lifetime 'a but what we really want to say is that our return type should be outlived by 'a, the complete opposite of the code above! This is something a lot of people, including myself, did not know. The correct way to write this in Rust 2021 is the “use bound” introduced in Rust 1.82.0.

1
2
//Correct capturing of 'a in 2021
fn foo<'a>(x: &'a ()) -> impl Sized + use<'a> { x }

The Rust team has a great writeup on this topic and the reasoning why Rust 2024 will implicitly capture all generics in the return type. Our first example now compiles in Rust 2024 without any changes which I think is a great success. If you want to limit what generics are captured you use the use bound in the third example. I’ve seen some complaints about implicit versus explicit captures. I can see why someone would think so with Rust in general leaning towards explicit syntax. In that case RPIT would be capture nothing by default and you always have to type out the use bound if you want to capture any generics. Personally I prefer the chosen way forward. It should result in less verbose code overall without making it harder to reason about.

Overall this in my opinion is a great change for better developer ergonomics and the ability to reason about lifetimes. It also turns out its great for the development of other trait related changes as it standardizes generic captures across “Type alias impl Trait (TAIT)”, “Associated type position impl Trait (ATPIT)”, “Return position impl Trait in Trait (RPITIT)” and “async fn in trait (AFIT)”. I’m hoping that the stabilization of RPIT lifetime captures is bringing us closer to the stabilization of these other trait features as they are all looking very exciting.

if let temporary scope

This is straight forward but with some implications. For if let $pat = $expr { .. } else { .. } any temporary value generated by $expr is dropped before entering else. What does that look like?

1
2
3
4
5
6
7
8
if let Some(x) = ... {
  ...
} 
// Some(x) is dropped here in 2024
else {
  ...
}
//Some(x) is dropped here in 2021

The 2024 edition adds another “temporary scope narrowing rule” that allows for this change to happen. But why is this important? Some(x) can’t be used inside the else so why does it matter if it’s dropped before or after? Well the docs showcases why this matters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Before 2024
fn f(value: &RwLock<Option<bool>>) {
    if let Some(x) = *value.read().unwrap() {
        println!("value is {x}");
    } 
    // Read lock is dropped here in 2024
    else {
        let mut v = value.write().unwrap();
        if v.is_none() {
            *v = Some(true);
        }
    }
    // <--- Read lock is dropped here in 2021
}

Because acquiring the write lock requires no other locks being active the else will cause a deadlock in the 2021 edition. This type of bug will no longer be possible with this change. I haven’t encountered this specific issue myself but I can imagine it being very annoying to debug when it happens. If you for some reason want to retain the old behaviour then the solution is to replace if let with match. The temporary will be dropped at the end of the match as you would expect.

Tail expression temporary scope

Tail expressions refer to the expressions that appear last in a block. Here’s our example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::cell::RefCell;

fn process_data(input: &str) -> (Vec<String>, usize) {
    // Create a temporary cache for memoizing expensive operations
    let cache: RefCell<Vec<String>> = RefCell::new(Vec::new());
    
    // Helper function that needs to modify the cache
    let process_chunk = |chunk: &str| {
        let mut cached = cache.borrow_mut();
        if let Some(result) = cached.iter().find(|r| r.starts_with(chunk)) {
            result.clone()
        } else {
            // Simulate expensive processing
            let result = chunk.to_string();
            cached.push(result.clone());
            result
        }
    };

    // Use the closure with our temporary cache
    (input.split_whitespace()
        .map(process_chunk)
        .collect(), cache.borrow().len())
}

This is the first time I encounter this and the way I understood the Rust drop rules I would have expected all temporaries to be dropped “bottom up” in this block. Turns out that is not the case and this code fails in the 2021 edition because cache is dropped before the temporary produced by cache.borrow(). This is because temporary values in tail expressions can live longer than their block before the 2024 edition because of lifetime extension rules. This is another temporary scope narrowing rule just as described for if let temporary scopes that guarantees the drop order to be what I expected in this case. This does have additional consequences.

1
2
3
4
// This example works in 2021, but fails to compile in 2024.
fn main() {
    let x = { &String::from("1234") }.len();
}

Because the temporary produced within the expression can’t be extended into the outer scope this code now fails in the 2024 edition. The way we solve it is luckily easy.

1
2
3
4
5
// This example works in 2021 and 2024
fn main() {
    let s = { &String::from("1234") };
    let x = s.len();
}

I personally find this more consistent and easier to reason about so I am happy about the change even if I haven’t encountered it before.

Macro Fragment Specifiers

The expr fragment specifier now supports const and _ expressions. This is a nice change that gives a good example of backwards compatibility breaking syntax changes.

1
2
3
4
5
6
7
8
9
macro_rules! example {
    ($e:expr) => { println!("Matched in 2024"); }; //Matched in 2024
    ($e:expr_2021) => { println!("Retain behaviour from 2021")} //Allows our macro to retain the same behaviour as it did previously
    (const $e:expr) => { println!("Matched in 2021"); }; //Matched in 2021
}

fn main() {
    example!(const { 1 + 1 });
}

So now we have a unified way of support const and _ expressions in our macro_rules. Const blocks are relatively new only being introduced in Rust 1.79 but it’s great to see them more “normalized” in the language considering the impact of constexpr/consteval in C++ and comptime in Zig.

gen keyword

gen is now a reserved keyword. This is because of the upcoming “gen blocks”. This new construct is a great simplification how to write iterators in Rust that I hope will show up sooner rather than later. If you look at the RFC linked the difference in complexity for writing the iterator is staggering. Something to look forward to but if you look at the tracking issue we’re quite a ways off from getting this in stable Rust.

Standard library

The changes to the standard library are limited but I’ll highlight the changes to prelude. Prelude is a module that is “invisibly” imported into every Rust module and contains a subset of the standard library. You’ve noticed that you don’t have to import Result<T,E> or Option<T> for example. This now includes std::future::Future and std::future::IntoFuture. Nice to see these kind of quality of life changes being bumped into the language but I guess also annoying that they have to be gated by editions because adding traits to prelude risks breaking backwards compatibility.

Migrating

I’ve already migrated zeebe-rs to the 2024 edition. Rust supplies the very convenient cargo fix --edition that automatically applies the necessary changes to move into a new edition. It’s not perfect but it’s certainly still impressive. In my case my library was small enough that I didn’t really have to change anything. An example of code it switched over was the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//2021 edition
for item in order.items {
    if let Some(quantity) = stock.items.get_mut(&item) {
        if *quantity > 0 {
            *quantity -= 1;
        } else {
            message = Some(format!("We're out of stock of {}", item));
            order_accepted = false;
        }
    } else {
        message = Some(format!("We don't serve {}", item));
        order_accepted = false;
    }
}

This was migrated because of the new if let temporary scope.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//2024 edition
for item in order.items {
    match stock.items.get_mut(&item) {
        Some(quantity) => {
            if *quantity > 0 {
                *quantity -= 1;
            } else {
                message = Some(format!("We're out of stock of {}", item));
                order_accepted = false;
            }
        }
        _ => {
            message = Some(format!("We don't serve {}", item));
            order_accepted = false;
        }
    }
}

In my case you can see that it didn’t actually matter, I am fine with Some(quantity) being dropped when reaching the else so I could disregard this change and keep my code as is. I will eventually have a look at async closures as well but they’re not a part of Rust 2024, just the 1.85.0 release so I am saving that for later.

Conclusion

Overall, I think editions are a great way to solve problems with breaking backwards compatibility compared to how it’s done in C++. I think some people would argue that “Stability as a Deliverable” falls flat on its face when you refuse to deliver a stable ABI but I digress. The release of a new edition is certainly less exciting than the release of a new C++ standard but with the 6 week release cadence of Rust I guess it should be seen as a herald of things to come rather than a feature package in itself. It was definitively worth it for me to sit down and write all this out because I had to review some assumptions and misunderstandings I had about Rust that hadn’t surfaced yet. I hope you enjoyed reading, leave me a comment on what you think of editions and the included changes!

This post is licensed under CC BY 4.0 by the author.

Trending Tags