Rust through the ages

How has Rust changed over the years? It's been nine years since 1.0 was released (well, next week, technically). In that time, there have been 78 major releases and two editions, with a third due later this year. Quite a lot has changed! Those changes have been fairly incremental, so if you've been using Rust all that time, it probably doesn't feel like a lot. But comparing the 1.0 flavour of Rust with today's is pretty startling!

I wondered what it would look like to write down all the big changes (just the big ones, none of the small ones, though some of them are not so small). It turned into a long blog post! I've only covered changes that made it to the stable channel and I've missed a lot (so many performance enhancements, so many bug fixes, so many small additions, especially to the standard libraries which feel so much more complete now, but it's hard to pick out headline changes). I've organised the changes by edition, but within each edition I've grouped them by theme. I've had to be very concise in my descriptions, sorry. But you can have a fun time searching for all the features you missed!

Let me know what I've missed!

1.0 to 2018 edition

The first part of this period felt like finishing the job of the 1.0 release. There were lots of really fundamental language changes which I can't imagine not being part of Rust, lots of library stabilisations, lots of performance improvements. Later, in the run up to the 2018 edition release, were some big changes which changed the feel of Rust in small but significant ways. There were also a lot of tooling improvements which made Rust feel much more production-ready and less like a new, risky language.

Language

? operator (1.13). Works with Option (1.22). Return values from loops using break value (1.19). Inclusive ranges, e.g., 0..=10 (1.26). Return Result from main (1.26) and tests (1.28).

pub(path) including pub(crate) and pub(super) (1.18). Nested groups of imports, e.g., use std::{fs::File, io::Read, path::{Path, PathBuf}}; (1.25). Renaming in imports, e.g., use foo::bar as baz; (1.4). crate in paths (nbot just imports) (1.30). Don't require prefix :: to import from an external crate (1.30). Import an reexport macros from external crates using use (1.30). No more need for extern crate (2018). Unification of paths in imports and other paths (2018).

#[derive] procedural macros (1.15), other procedural macros (1.30 and 1.45). Macros in type position (1.13).

const functions (1.31). 128 bit integers (1.26). Raw identifiers, e.g., r#type (1.30). Overriding +=, -=, etc. (1.8). Smart pointers can contain dynamically sized types, allowing types like Rc<[T]> (1.2).

Unions (1.19). Numeric fields can be used to name fields of tuple structs, e.g., Foo("hello", 42) and Foo { 0: "hello", 1: 42 } are equivalent (1.19). Field initialiser shorthand, e.g., Foo { foo } instead of requiring Foo { foo: foo } (1.17). Empty structs with braces, e.g., struct Foo {} (1.8). Allow empty tuple structs, e.g., struct Foo(); (1.15). Use Self in struct initialisers, e.g., Self { foo: 42 } (1.16).

impl Trait as return type and argument type (1.26). dyn Trait as an alternative to just Trait for trait objects (available in 1.27, mandatory in 2021). Associated constants (1.20). ?Sized in where clauses (1.15). Remove anonymous arguments in trait definitions, e.g., fn foo(&self, u8); (2018).

#![no_std] (1.6).

Attributes on statements (1.13); attributes on generics (1.27). Non-string arguments in attributes (1.30).
#[deprecated] attribute (1.9). #[repr(transparent)] (1.28).

Automatic dereferencing in patterns, this is not very visible but a massive ergonomic win (1.26).

'_ (1.26). Lifetime elision in impl headers (1.31). Default lifetimes for static and const values ('static) (1.17). Non-lexical lifetimes (2018).

Libraries

SIMD intrinsics (1.27). Duration (1.3). Instant and SystemTime (1.8). thread::sleep (1.4). ManuallyDrop (1.30). std::panic, catch_unwind etc. (1.9 and 1.10). ptr::NonNull (1.25). Box::leak (1.26).

{:#?} pretty printing version of the debug formatter (announced in 1.2, but apparently was there before 1.0, though I could swear this came later).

eprint and eprintln macros (1.19). println!() rather than requiring println!("") (1.14).

Specifying a global allocator and the std::alloc module (1.28).

Tooling

Rustup 1.0 (1.14). Clippy and Rustfmt 1.0 (1.31). RLS initial release (first generation IDE support) (1.31).

New error format (1.12); short error format (--error-format=short) (1.28).

cargo install (1.6), cargo check (1.16), cargo fix (1.29), cargo init (1.8).

Attributes for tools, e.g., #[rustfmt::skip] (1.30); lints for tools, e.g., `#[allow(clippy::filter_map)]`` (1.31).

--explain flag added to the compiler to give more detail on error messages (1.1). MIR added to rustc (1.12, yeah it's an implementation detail, but it's a pretty important one).

MSVC toolchain for Windows (1.2). Non-official Windows XP support (1.3).

crates.io no longer supports wildcard versions (1.6).

rustc builds with Cargo (1.8) and Rustbuild (no makefiles) (1.15).

rust-gdb and rust-lldb scripts (1.10).

2018 edition to 2021 edition

This period inevitably felt a lot slower than 2015-2018. In retrospect, it looks like a real maturation phase for Rust (one aspect of this is the increase in compiler targets in this time). There were a few big new features and a bunch of everyday-life improvements to the language. One of those big features was const generics and over this period (and continuing after it), a big trend is converting functions into being const. Similarly on the language side of things, there has been a steady stream of work making more and more features work in const context.

Language

async functions and blocks, .await (1.39).

const generics using integers, char, or bool (1.51); unsafe const functions (1.33). Use Self in type where clauses, constructors for tuple structs, etc. (1.32). Some types (Rc, Arc, Pin) can be used as the method receiver type (1.33).

? in macro definitions to indicate zero or one repetitions (1.32). Literal specifier in macro definitions (1.32). Use macros in type position (1.40).

Multiple patterns using | in if let and while let (1.33). Able to use | in nested fashion in patterns, e.g., Some(1 | 2) (1.53). Removed ... range syntax in favour of ..= (warning in 1.37, error in 2021).

#[non_exhaustive] (1.40), #[track_caller] (1.46). Multiple attribute arguments in cfg_attr (1.33).

Import traits anonymously, e.g., use std::io::Read as _; (1.33).

Unicode identifiers (1.53).

Cast uninhabited enums to integers (1.49).

Libraries

dbg macro (1.32), todo macro (1.40), matches macros (1.42), try macro is deprecated (1.39).

Pin, Unpin, etc. (1.33). Sized atomic integers, e.g., AtomicU16 (1.34). mem::MaybeUninit (1.36). ops::ControlFlow (1.55).

Factored out the alloc crate from std (1.36).

Tooling

New feature resolver in Cargo (2021). Alternate Cargo registries (1.34). --offline for Cargo (1.36); -all changed to --workspace (1.39). cargo tree (1.44). Minimum supported Rust version in Cargo.toml (1.56).

Default allocator changed from jemalloc to the system allocator (1.32).

Profile-guided optimisation (1.37).

2021 edition to today

It's been an interesting few years, and the next few months leading up to the 2024 edition will undoubtedly bring some more big changes too. There have been a few pretty big additions or visible additions, but as you might expect for a nine-year old language, there are a lot of pretty deep and subtle changes, and compared to previous years, the pace of big changes has slowed down. However, one thing I noticed is that the big changes are getting bigger and consequently, there are a lot of smaller, incremental changes landing, and then later the big feature gets released. I think this speaks to the increasing experience and confidence of the Rust project in managing and implementing these big, multi-year changes.

There's a bunch of trends I noticed which don't have any headline features to highlight below. As in previous years, there are a lot of new methods on library types. The integer types seem especially blessed, recently. There's also a load more compilation targets available, and the range of hardware which Rust supports now is huge. It would be mind-blowing to consider this list at the 1.0 release. Another thing happening is that Clippy lints are moving into the compiler proper. Not sure how visible that is (you all run Clippy, right?), but it's great to see.

Language

Generic associated types (1.65).

let else (1.65). Break from a labelled block returning a value (1.65).

Async functions in traits and trait methods returning impl Trait (1.75).

Use variable names directly in format strings, e.g., "hello {name}!" (1.58). C string literals, e.g., c"hello, world" (1.77).

Inline assembly (asm and global_asm macros) (1.59).

Destructuring assignment, lets the left hand side of assignment be a pattern just like in let statements (1.59).

Default arguments for const parameters (1.59).

#[derive(default)] for enums using #[default] (1.62).

Libraries

Scoped threads (1.63).

OnceCell and OnceLock (1.70).

Saturating type (1.74).

try_reserve on many collections (1.57 and 1.63).

new_cyclic for Rc and Arc (1.60).

std::process::{ExitCode, Termination}, facilitating custom exit codes (1.61).

Backtrace (1.65).

Tooling

cargo add (1.62), cargo remove (1.66), cargo logout (1.70). [lints] section in Cargo.toml (1.74). Custom profiles in Cargo (1.57). Inherit package settings from the workspace (1.64).

#[diagnostic] attributes for letting libraries influence compiler error messages (1.78).

Rust Analyzer distributed by rustup (1.64), RLS removed (1.65).