, 5 min read, rust, api-design
Why Rust ranges took eleven years to become Copy
ops::Range was never Copy because it is its own Iterator, and a Copy iterator forks its state. Rust 1.96's core::range types implement IntoIterator instead.
, 5 min read, rust, api-design
ops::Range was never Copy because it is its own Iterator, and a Copy iterator forks its state. Rust 1.96's core::range types implement IntoIterator instead.
Put a Range<usize> in a struct, slap #[derive(Clone, Copy)] on it, and the compiler says no. Not a lint, a hard error. Then you go looking for the field that isn't Copy, find out it's the range, and stare at it for a bit. A range is two usize values. No heap, no destructor, nothing to clean up. If anything in Rust should be free to duplicate, it's that.
Rust 1.96 (opens in new tab) finally gives you a range you can copy. What I find more interesting than the fix is the reason it took this long, because it comes down to a mistake that is easy to make in any API: treating "this is a thing" and "this can produce a thing" as the same promise.
std::ops::Range is literally struct Range<Idx> { start: Idx, end: Idx }, and both fields are public. You're allowed to derive Copy on a struct when every field is Copy, and usize obviously is. So by the usual rule, Range<usize> should qualify.
It doesn't, and the error won't tell you why. It points at the field, says it doesn't implement Copy, and leaves it there.
Range implements Iterator directly, and has since Rust 1.0 in 2015. Calling .next() on a range bumps its own start field. The range isn't a description of some numbers you could walk over. It is the walk, and its whole state is where it currently is.
That's the problem. Copy means the value gets duplicated silently, every time you pass it or assign it. An iterator's state is its position. Put the two together and any innocent-looking line can fork the position into two copies that quietly disagree. The 1.96 release post puts it plainly: it is "a footgun to implement both Iterator and Copy on the same type."
Clippy has had a lint for this pattern since Rust 1.30 (October 2018), called copy_iterator.1 Its docs say it "checks for types that implement Copy as well as Iterator" because "implicit copies can be confusing when working with iterator combinators." And its suggested fix is the exact thing the standard library just did for ranges: "consider implementing IntoIterator instead."
Lint docs are easy to nod along to. To watch this one actually bite, I wrote the smallest Copy iterator I could think of (it's basically the example from the lint's own docs) and ran it on rustc 1.96.0.
#[derive(Clone, Copy, Debug)]
struct Countdown(u8);
impl Iterator for Countdown {
type Item = u8;
fn next(&mut self) -> Option<u8> {
if self.0 == 0 {
None
} else {
self.0 -= 1;
Some(self.0)
}
}
}
fn main() {
let mut original = Countdown(5);
let first_two: Vec<u8> = original.take(2).collect();
println!("first_two = {:?}", first_two);
println!("original.next() = {:?}", original.next());
println!("original = {:?}", original);
}.take(2) takes self by value. With a normal iterator that would move original and the next line wouldn't compile. Because Countdown is Copy, it compiles fine: take gets a copy, the copy counts down through 4 and 3, and then gets dropped along with the rest of the chain. Output:
first_two = [4, 3]
original.next() = Some(4)
original = Countdown(4)original.next() hands back Some(4), the same value first_two already got. The original never saw the copy do its two steps of work. In a ten-line program that's obvious. Buried in a struct that gets passed around a parser, it's the kind of bug where you end up staring at a log wondering how the same token got processed twice.
Running the same file through Clippy 1.96 with the lint turned on catches it right away:
warning: you are implementing `Iterator` on a `Copy` type
--> countdown.rs:4:1
|
= note: consider implementing `IntoIterator` instead(I trimmed the code excerpt and the help link out of that warning.) So the standard library was never going to make ops::Range Copy. It would have been shipping the exact pattern its own linter warns you about.
The new types come from RFC 3550 (opens in new tab), and 1.96 stabilizes the library part: core::range::Range, core::range::RangeFrom and core::range::RangeInclusive. The RFC is blunt about the old design: implementing Iterator directly "is now widely considered to be a mistake."
The new ones don't implement Iterator at all. They implement IntoIterator, so when you loop over one you get a separate iterator value that holds the position. The range itself never advances. Iterating it is a conversion, and that split is what makes Copy safe.
Here's the struct from the intro again, with the new type, compiled on rustc 1.96.0 with --edition 2024:
#[derive(Clone, Copy, Debug)]
struct Field {
span: core::range::Range<usize>,
}
fn main() {
let s = "hello world";
let f = Field { span: core::range::Range { start: 0, end: 5 } };
let g = f; // a real copy, not a move
let slice = &s[f.span];
let mut sum = 0usize;
for i in g.span {
sum += i;
}
println!("slice = {:?}, sum = {}", slice, sum);
}It compiles with no warnings and prints slice = "hello", sum = 10. Notice f is still usable after let g = f;, which only works because that line copied it. Now the same file with std::ops::Range<usize> in the field (and 0..5 to build it):
error[E0204]: the trait `Copy` cannot be implemented for this type
--> field_legacy.rs:2:8
|
1 | #[derive(Clone, Copy, Debug)]
| ---- in this derive macro expansion
2 | struct Field {
| ^^^^^
3 | span: std::ops::Range<usize>,
| ---------------------------- this field does not implement `Copy`That's the error from the first paragraph. Now at least you know what it's not saying: the field isn't Copy because it's secretly an iterator.
One more change I like. The new RangeInclusive has public start and end fields. The old one couldn't, because it carries a hidden exhausted flag to remember whether iteration already hit the end, and exposing the fields would have exposed that state too. The new type drops the flag entirely, which per the RFC also makes it the same size as Range. It doesn't need the flag, because you can't exhaust a range you have to convert before iterating.
This is the catch. None of the above changes what 0..5 means today. Range syntax still produces the legacy ops types, and the release post says it will switch to the core::range types "in a future edition," without naming which one. (The RFC was written in late 2023 and aimed at Edition 2024, which shipped without this change.) For now, if you want a new range, you write the type out by name.
The RFC does lay out what the switch is meant to look like:
Syntax changes meaning
`a..b`, `a..` and `a..=b` produce `core::range` types instead of the legacy ones.
Plenty of code doesn't notice
`for` loops, `contains`, and functions that take `RangeBounds` or `IntoIterator` keep working as they are.
Code that used the range as an iterator gets patched
`cargo fix --edition` inserts `.into_iter()` where it's needed, like a direct `range.next()`.
The old types stick around
they live under `range::legacy`, the `std::ops` paths stay as re-exports, and you can `.into()` a new range when an API wants the legacy one.
Until then, the release post's advice for library authors is sensible: take impl RangeBounds<T> in public APIs, since both old and new ranges implement it, and if you need a concrete type, prefer the new ones because they're the eventual default.
I keep coming back to this as an API design lesson more than a Rust one. "I am a traversal" and "I can give you a traversal" are different promises. Iterator makes the first: call next() on me and I change. IntoIterator makes the second: hand me off and you get something new that changes, while I stay put. The legacy range tried to be both a plain value and the traversal over it, and once you're the traversal, being freely copyable stops being safe.
You can make the same mistake with anything that looks like a position in a sequence: a cursor, a parser offset, a pagination token. If that type is also supposed to be passed around freely, decide which promise it's making, because it can only make one of them safely. Rust's ranges spent about eleven years making both. Clippy has been flagging the same pattern in everyone else's code since 2018 (if you had pedantic turned on), so it's nice to see the standard library finally take its own linter's advice.
copy_iterator is in Clippy's pedantic group, which is off by default. You opt in with #![warn(clippy::copy_iterator)], or turn on the whole group with #![warn(clippy::pedantic)]. ↩
Comments