Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

how rust 2018 quietly rewired my instincts

Looking back at the 2018 edition of Rust and the handful of changes that reshaped how I structure code day to day.

A code editor showing Rust source

I was tidying an old crate this week, one that predates the 2018 edition, and the contrast was sharp enough that I stopped to think about it. The 2018 edition didn't add some headline feature that changed what Rust could do. It changed how it felt to write, and five years on those changes are so baked into my habits that the pre-2018 code reads like a different language. Worth writing down what actually shifted, because the edition system is one of Rust's quietly brilliant ideas and it's easy to forget how much it moved.

the module system finally made sense

The single biggest change for me was the module path overhaul. In the old world, use paths and mod paths followed subtly different rules, extern crate declarations littered the top of every file, and self:: and super:: were load-bearing in ways that tripped up everyone learning the language. I taught a couple of people Rust around then and the module system was where most of them stalled. Not the borrow checker. The modules.

2018 made paths consistent. extern crate mostly went away because the compiler reads your Cargo.toml. Crate-relative paths got a clear crate:: prefix, so you could tell at a glance whether something came from your own crate or a dependency. It sounds like cosmetics. It isn't. I restructure code more freely now because moving a module no longer means a fight with path resolution, and I reach for submodules earlier because they stopped being a tax.

A close-up of source code on a dark screen

non-lexical lifetimes took the friction out of the borrow checker

The other change I felt immediately was NLL, non-lexical lifetimes. Before it, a borrow lived until the end of its lexical scope, the closing brace, whether or not you still used it. So this kind of thing was rejected even though it's obviously fine:

let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first);
v.push(4); // pre-NLL: error, `first` still "borrowed" until scope end

Under NLL the borrow of first ends after its last use, the println!, so the push is allowed. The number of times I'd previously added an extra scope { } purely to placate the borrow checker, or split a function in two for no reason other than lifetimes, is embarrassing. NLL didn't make the borrow checker weaker, it made it understand what you obviously meant. The code I write now flows in the order the logic flows, rather than in the order that happened to make the old checker happy.

? and impl Trait changed the small-scale shape

A couple of smaller things compound across a whole codebase. The ? operator had landed just before, but the 2018-era ecosystem was where it became the default, and error handling stopped being a wall of match on every fallible call. And impl Trait in argument and return position let me return an iterator or a closure without naming an unspeakable type or boxing it.

fn even_squares(input: &[i32]) -> impl Iterator<Item = i32> + '_ {
    input.iter().filter(|n| *n % 2 == 0).map(|n| n * n)
}

Returning iterators like that, lazily, without a Box<dyn Iterator> and its allocation, nudged me towards composing iterator chains rather than building intermediate Vecs. That's a style change as much as a feature, and it's one I'd never want to give back.

the part that still impresses me

What I appreciate most in hindsight isn't any single change, it's that none of it broke my old code. The edition mechanism lets the language make breaking-looking improvements while crates on the old edition keep compiling, and editions interoperate, so a 2015 crate and a 2018 crate link together without ceremony. You opt in per crate, with one line in Cargo.toml, when you're ready. That's how you evolve a language people have shipped production software in: you make the new way better, you let people move at their own pace, and you don't punish them for the code they already wrote.

I migrated that old crate to 2018 while I was in there. cargo fix --edition did almost all of it and the rest was a few minutes of reading. The diff was mostly deletions: dead extern crate lines, redundant scopes, paths made explicit. The code got shorter and clearer and did exactly the same thing. Five years on, the edition that taught me how I write Rust today is just the floor I stand on, and that's the highest compliment I can pay it.