Rust edition 2024 annotated
Last Thursday Rust 1.85 was released, and with it, edition 2024 has dropped. The new edition is significantly larger than the two editions that preceded it, and contains many small but significant quality of life improvements to the language. In this post, I’d like to explain what an edition is, and summarize all the changes that were made to the language I love. If you need the details, I recommend reading the edition guide, but for a general overview, read on.
What is an edition?
Rust has, at the time when 1.0 was being finalized, made a promise: code that compiles with any 1.x version of the compiler, should compile without hassle with a later 1.y version of the compiler. Unless behaviour was obviously a bug, or code should keep compiling. This is a beautiful ideal, but it also limits what changes you can make. This is where editions come in.
An edition in Rust is effectively a compatibility mode for the compiler. It is a way to make certain changes “not exist” for older code, while allowing improvements for newer code. Code compiled in older editions can be freely mixed with code with newer editions and vice versa. This makes it easier to upgrade to newer compiler versions, as you can be generally sure that code will continue to compile and work as expected.
As a simple example, the first ever edition, in 2018, made async
a keyword. That meant that
variables could no longer be called async
, but that was deemed worth it in order to enable
asynchronous programming. Crucially, code that existed before that time, could now use what is
called “edition 2015”, could continue to exist unchanged and new code could depend on existing
libraries, while using newer features itself.
Of course, that means that code compiled with edition 2015 could not use async fn
, but that
functionality wouldn’t be released until late 2019 anyway. Edition 2024 offers a similar selection
of small changes, fixing small pain points in the language that would otherwise break the backwards
compatibility promise.
Core language
Most of the changes in Edition 2024 affect the core language constructs and as such will be the most visible, as they apply regardless of what standard library features you use. Luckily, only one is likely to require manual changes to your code base, if at all. Let’s walk through them.
Return-position impl Trait
lifetime capture rules
Putting impl Trait
in the position of the return type can make your code easier to read, but it
can cause issues when lifetimes get involved.
1
2
3
fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> {
nums.iter().copied()
}
The above will fail to compile in edition 2021, as the type impl Iterator
is assumed to be
'static
, that is, it doesn’t borrow anything, while it instead borrows from the nums
slice.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Compiling playground v0.0.1 (/playground)
error[E0700]: hidden type for `impl Iterator<Item = i32>` captures lifetime that does not appear in bounds
--> src/lib.rs:2:3
|
1 | fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> {
| ------ ----------------------- opaque type defined here
| |
| hidden type `Copied<std::slice::Iter<'_, i32>>` captures the anonymous lifetime defined here
2 | nums.iter().copied()
| ^^^^^^^^^^^^^^^^^^^^
|
help: add a `use<...>` bound to explicitly capture `'_`
|
1 | fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> + use<'_> {
| +++++++++
The error message even has the fix: add + use<'_>
to state that the lifetime of the slice is
captured into the returned iterator, so the compiler may correctly reason about its use.
The change coming in edition 2024 is a change in default captures. Instead of capturing none of the
lifetime by default, impl Trait
will now by default capture all lifetimes. That means that with
edition 2024, the example above will compile as-is. To revert to the previous behaviour, you can
explicitly capture no lifetimes:
1
2
3
4
fn display(label: &str, ret: impl Sized) -> impl Sized + use<> {
println!("{label}");
ret
}
I’m not sure whether this change is an improvement; in my code it empirically introduces more
unnecessary lifetimes than it removes workarounds. 'static
lifetime by default seems like the more
sensible choice. Nevertheless, the change has been made, so
References:
Changes to temporary scope
Edition 2024 comes with two small quality of life changes with respect to the lifetime of specific temporaries. These are situations you might have hit already, that have easy workarounds, but now they just work out of the box.
In edition 2021, when using if let
, any temporaries created in the matching expression will live
for the entirety of the if
/else if
/else
branch. This can have some unexpected results:
1
2
3
4
5
6
7
8
9
fn get_cached_or_init(cache: &RefCell<Option<String>>) -> String {
if let Some(value) = cache.borrow() {
value.clone()
} else {
let value = complicated_string_gen();
*cache.borrow_mut() = value.clone(); // PANIC! Already borrowed
value
}
}
Starting edition 2024, the temporary scope for the guard expression will end at the end of the if
branch, and the above code will be valid as-is. If you need the guard to remain valid for the entire
duration, you can use a match
block instead.
Another small change has to do with the lifetime of temporaries in tail expressions. According to the 2021 temporary scope rules, a temporary created inside such a tail expression will live until the end of the next temporary scope, which is after local variables have been dropped. This can cause unexpected errors:
1
2
3
4
5
// Before 2024
fn f() -> usize {
let c = RefCell::new("..");
c.borrow().len() // error[E0597]: `c` does not live long enough
}
Example taken from the edition guide as I have hit this only once, ever, and couldn’t think of a
simple use case. Nevertheless, the Borrow
temporary generated by borrow()
is here expected to
live until the end of the function, but c
is cleaned up before that, so it doesn’t work. In
edition 2024, the temporary’s lifetime is shortened and it will compile as-is.
This may in some cases have side effects when used with temporary lifetime extension, but I expect this will be unlikely to occur in real code.
References:
- Edition guide -
if let
temporary scope - Edition guide - Tail expression temporary scope
- Temporary lifetime extension
Match ergonomics reservations
There is a small change to how ref
, mut
, and ref mut
and friends may be used. In edition 2024,
it is an error to use these capture modifiers in a non-explicit pattern, that is, a pattern using
match ergonomics. In practice, this looks as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
fn f(opt: &mut Option<i32>) {
if let &mut Some(ref mut val) = opt {
// valid, val is &mut i32
}
if let Some(val) = opt {
// valid, val is &mut i32, match ergonomics used
}
if let Some(mut val) = opt {
// Valid in edition 2021, invalid in edition 2024
}
}
The stated reason for this change is that the syntax is intended to be used for match ergonomics expansion in the future. I don’t know what this expansion would look like, so I can’t comment on the usefulness, but I will say that the current compiler interpretation is not that useful or predictable, so not a lot is lost.
References:
Changes to unsafe
unsafe
in Rust is used to denote sections of code where the compiler cannot prove all the
properties of the code that are required for the code to be well-behaved. Edition 2024 requires you
to mark more sections as unsafe
that previously did not require it.
The first change, is that extern
blocks are now required to be marked as unsafe
. Function and
constant definitions in an extern
block must match their counterparts across the FFI bridge, but
the compiler cannot ascertain that they do. Marking the block unsafe
should signal the developer
that they must pay attention.
Rust added the ability for attributes to be unsafe
as well. A few existing attributes now require
being marked unsafe
. The names used for #[no_mangle]
and #[export_name]
need to be globally
unique, otherwise unexpected linking errors may occur. As such, they are now #[unsafe(no_mangle)]
and #[unsafe(export_name)]
. In addition, #[link_section]
must now be unsafe, as it can be used
to add symbols to a section that might not work, such as adding mutable data to a read-only section.
References:
- Edition guide - Unsafe
extern
blocks - Edition guide - Unsafe attributes
- Edition guide -
unsafe_op_in_unsafe_fn
warning
No more references to static mut
data
In Rust, the existence of a reference &T
presumes (barring interior mutability) no writes will
happen to the data being referenced. This has always been in conflict with static mut
variables,
as any thread anywhere may be writing to them at any point in time. Edition 2024 therefore makes it
an error to have a reference to static mut
data. This was previously a warning.
The primary solution to get around this, is by not having static mut
data. Make sure your global
data is covered by a mutex or similar. If you must have static mut
, any access to it will have to
go through the pointer APIs, and you will have to take responsibility for upholding Rust’s safety
guarantees.
References:
Never type coercion
The Never type, !
, is a type of which no value can ever exist. This is used in the language as
the return value for functions that do not return, such as panic!()
. It signals to the compiler
and to a developer that a code path will never (heh) be taken. To ease type checking in these
scenarios, the Never type will coerce to any other type.
Sometimes, the code doesn’t offer an unambiguous type to coerce to, in which case the fallback is
used. Edition 2024 changes the fallback for this coercion from the unit type ()
to the Never type
itself. This might cause minor issues for cases where a trait is implemented for ()
but not for
!
. The workaround is to explicitly coerce to the unit type in those situations by adding type
annotations.
References:
Macro fragment specifiers
Macro fragment specifiers have, in my opinion, an unfortunate name. What it refers to is effectively
the type of an argument of a macro_rules!
-style macro.
1
2
3
4
5
6
macro_rules! my_print {
($val:expr) => { print!("{}", $val); };
}
// later
my_print!(1 + 2 + 3); // prints 6
Here, $val
is captured, but only if it is an expression. my_print!(match)
would not be accepted.
Edition 2024 brings the :tt
fragment specifier up-to-date with the new additions to the language,
allowing it to match _
- and const
expressions as part of an :expr
fragment. If you must
maintain the previous behaviour, you can replace :expr
with :expr_2021
.
In addition, it is now an error to have macro fragments without type specifiers. Previously, this would be alright, as long as the variant with the missing fragment specifier wasn’t matched. Simply remove the variant without the specifier, or add an appropriate specifier.
1
2
3
4
5
6
7
// Compiles in edition 2021, compile error in edition 2024
macro_rules! broken_macro {
() => { println!("Valid variant"); };
($without_specifier) => { unreachable!("Cannot match this"); };
}
broken_macro!();
References:
New reserved constructs
Edition 2024 adds a new reserved keyword, gen
, in anticipation of the upcoming generators which
may or may not be called coroutines. If you really want to continue naming your variables gen
, you
can use raw identifiers.
In addition, Rust now reserves one or more #
in a row followed by a string literal, and two or
more #
in a row not separated by whitespace. There are no workarounds to this.
References:
Standard library
Aside from core language features, the standard library also has some backwards-incompatible changes.
Changes to the prelude
The prelude defines all the types you never have to import, like String
. In edition 2024, Future
and IntoFuture
are added to the prelude. At this point, six years after the async
keyword was
introduced, this is unlikely to cause issues in regular code bases.
References:
Add IntoIterator
to boxed slices
In all editions, Box<[T]>
will now implement IntoIterator<Item=T>
. This could be a breaking
change, as previously, my_box.into_iter()
would evaluate to &[T]::into_iter
, which iterates by
reference rather than by value.
To mitigate this, direct calls to Box::<[T]>::into_iterator
will be hidden in previous editions,
which maintains the previous behaviour. You might remember this is the same workaround that was
chosen back in edition 2021, when IntoIterator
was added to [T; N]
.
Train implementations cannot be edition specific, but this way, old code gets to keep working.
References:
Newly unsafe functions
Over the years, some functions have been added to the standard library that turned out to be
problematic in certain cases. Despite attempts to make them safe and well-behaved in all
circumstances, they fundamentally cannot be. Edition 2024 marks these functions as unsafe
to
signal that these functions require special care.
Two functions for modifying environment variables, std::env::{set_var, remove_var}
have been the
subject of a complicated range of CVEs I don’t want to get in to, but crucially, they cannot be used
safely in a multithreaded environment. Rust tried to fix this by adding a RwLock
internally that
protects the internal state, but this turns out to still be unsafe when combined with the many
libc
functions that might be calling getenv
in unrelated threads. As such, these functions are
now unsafe
and should only be used in single-threaded environments.
std::os::unix::process::CommandExt::before_exec
is also unsafe now, in addition to being
deprecated. pre_exec
was introduced in Rust 1.37 as an unsafe
replacement for it, but now
before_exec
will reflect its unsafe
status.
References:
Cargo
The package manager/build system/holy grail received some minor breaking changes that will generally make life easier.
First, cargo
got a new dependency resolver that will take the current Rust version into account.
In previous editions, this resolver can be opted into by setting resolver = "3"
. From my initial
testing, it doesn’t work as well as it could, mostly because crates claim supported rust versions
they very much do not. I expect this to get better in the future, and to at the very least make it
easier to test for rust version support and maintain backwards compatibility.
A minor change is that the naming of options has been made more consistent. Cargo.toml
(and
related) now consistently use kebab-case
for all keys. Previously, snake_case
was allowed for
some (but not all) keys.
Finally, there is some clean-up with workspace dependencies: it is now an error to disable
default-features
for an inherited dependency when the workspace does not disable
default-features
. This never worked; the dependency would always have its default features enabled
regardless as features in Rust are additive. Now the compiler will simply let you know.
References:
- Edition guide - Cargo: Rust-version aware resolver
- Edition guide - Cargo: Table and key name consistency
- Edition guide - Cargo: reject unused inherited default-features
Rustdoc
While technically breaking changes, Rustdoc mainly received some nice quality-of-life improvements.
In edition 2024, Rustdoc tests will be combined into a single binary as much as possible. This
should generally improve compile times for the tests. It is not always possible to combine tests.
compile_fail
examples will always be compiled separately, and some other obvious breakage will be
detected and avoided. If you need to enforce that an example is compiled separately, you can add the
standalone_crate
language tag to ensure that specific example will be compiled on its own.
1
2
3
4
5
/// Compiles example code
///
/// ```standalone_crate
/// println!("Hello, world!");
/// ```
A smaller, but more breaking change is that nested include!
s in your documentation are now handled
relative to the included document rather than to the original source location. That is, if your code
uses something like #[doc=include_str!(../README.md)]
, any include_bytes!()
that might be in
your examples will now be handled relative to README.md
.
Rustfmt
The biggest change to Rustfmt is that as of edition 2024, Rustfmt can have its own editions with
backwards-incompatible changes now. By default, it will use the same edition for formatting as it
will for compiling, but you can override this by setting style_edition
in rustfmt.toml
. This
allows you to opt in to improved formatting, while not raising the minimum supported rust version.
That said, let’s look at the breaking changes that are included.
Formatting fixes
Most of the changes to Rustfmt don’t involve sweeping changes to the resulting format, but rather fix circumstances where the formatter did not work correctly. The edition guide has examples for all changes, but I’ve tried to summarize them here.
- Trailing comments after a line-end comment will no longer be aligned as if they were related.
- Strings in comments will no longer be indented.
- Long strings will no longer cause the formatter to stop formatting the expression that contains them.
impl
block generics will no longer have a double-indent.- Some complicated
fn
blocks would previously get a strange indentation and no longer do. The complexfn
blocks required to trigger this are still not very readable. - Nested tuple indexings will no longer get an extraneous space.
return
,break
, andcontinue
statements inside amatch
block now consistently send with a semicolon.- Long array and slice patterns now wrap to avoid overflow.
- The last expression statement in a block will now be formatted as a one-liner most of the time.
- The formatting between a macro call and a function call is now more consistent.
- Closures containing a single loop will now get a block to wrap the closure body. This is the only change that feels like a regression to me.
- Empty lines in
where
blocks are now removed. else
clauses from alet else
with an attribute are no longer formatted incorrectly.- Comment- and macro argument wrapping no longer has an off-by-one bug.
- Comments with
=>
inside amatch
block are no longer formatted incorrectly. - Having more than one inner attributes in a
match
block no longer causes extraneous indents.
All but one of these should only ever improve formatting in my opinion, thus working around this shouldn’t be necessary, but you can still ignore these improvements and avoid a diff in your code by opting for style edition 2021 now.
References:
Sort order changes
Rustfmt doesn’t sort things often in source code, but when it does, the resulting order makes sense.
Edition 2024 makes two changes to this. First, raw identifiers, used when an identifier overlaps
with a keyword, will now correctly sort in the position the identifier would, rather than sorting
based on the raw identifier prefix r#
. This should generally put the resulting list in a more
“sensible” order.
In addition to that, the sort order was changed from lexicographical to an algorithm the guide
refers to as “version-sort.” The main difference to this is that it will handle strings containing
numbers better, so that NonZeroU8
will now sort before NonZeroU16
rather than after it, and
lowercase characters will now sort after uppercase characters.
References:
To summarize
Rust 2024 is characterized not by big changes, but rather by a plethora of small ones. I expect for
most code bases, very few changes will have to be made, if any at all, and most of the changes that
do have to be made can be addressed by cargo fix
.
To me, this seems like a healthy state for the language. Change keeps happening, but most of it can be relied on to remain recognizable to people in the future. The language isn’t stagnating, but it’s also not adding a torrent of new features and syntax any more. This kind of slow evolution should allow for greater adoption in the real world, as the world sees that you don’t have to spend half your time keeping up with all the new changes. The future of the language seems alright, and I’m here for it.