RangeFrom, Part 2..: What I think is wrong about the design

The Beginning

In December 2025, about 8 months before this post James Munns made a post about how RangeFrom just wraps when it gets to the end. I started with a clippy lint, but it ended up being one of the things my mind would wander to quite often. This have led to me forming quite a few opinions and thoughts about about it. Wich I want to share with the ether in this article.

While not a strict pre-requisite for reading this article, the first part in this series of posts will give some historical background: RangeFrom, Part 1..: History and background.

What would you expect from the RangeFrom iterator?

I will list up a few things that I think people would expect from the RangeFrom iterator.

Lets say we have an iterator let mut iter = (n..). Here are some properties that I would expect from such a iterator:

  1. All values until and including the largest value is yielded by n...
  2. It will only yield values in the range.
  3. It will not panic on overflow when overflow-checks are turned off
  4. It will be monotonically increasing, if it doesn’t overflow
  5. Iteration over various types will work consistently.

In the next parts I am going to show how none of these are correct.

1. All values until and including the largest value is yielded by n...

Because of an implementation detail where the internal counter is incremented before the value is yielded, enabling overflow-checks means that the iterator overflows before the final value (u8::MAX) is yielded. Thus the final value will be the penultimate value before the overflow:

for i in 253u8.. {
    println!("{i}");
}

This code will print 253, 254 and then panic. If overflow-checks are not enabled it will thankfully print 255 as well.

It should be noted that this has been fixed with the new range types.

for i in std::range::RangeFrom::from(253u8..).into_iter() {
    println!("{i}");
}

This will print 253, 254, 255 and then panic if overflow-checks are enabled. But with overflow-checks disabled it will work the same as the current RangeFrom type. That is it will run in a infinite loop, which brings us to:

2. It will only yield values in the range.

This brings us to possibly my main issue with the current design. I would expect that the following unreachable statement was unreachable:

let range = 128u8..;
let iter = range.clone();

for i in iter {
    if !range.contains(&i) {
        unreachable!("Outside of range");
    }
}

It is reachable with the RangeFrom[^1] iterator for all the integers types (u* and i*)[^2]. To me this makes little sense as it seems to break what I think is the the main idea of a range, specifically that it conceptually is something like [𝑛,+]. This means that you need to be careful about not using the RangeFrom iterator as a guard for values unless you ensure you guard against the overflow. One way you can do this is by using n..={Integer}::MAX, to me this is not the range type that I conceptually would reach for first.

3. It will not panic on overflow when overflow-checks are turned off

It is only the primitive integer types that work in this way. The rest of the types that implement the Step trait diverges from this. You could argue that it makes sense for types where all bit patters are well defined such as char and std::ascii::Char where some values are undefined behaviour to create. But if you then look at Ipv4Addr and Ipv6Addr which both complete mappings from the underlying integer type, but both of these always panics on overflow.

// Always panics with
// library/core/src/iter/range.rs:118:45:
// overflow in `Step::forward`
for i in Ipv4Addr::new(255, 255, 255, 250).. {
    println!("{i}");
}

To me this seems like wrong behaviour.

It should be noted that this mostly follows directly from the implementation notes of the Step trait.

If this would overflow the range of values supported by Self, this function is allowed to panic, wrap, or saturate. The suggested behaviour is to panic when debug assertions are enabled, and to wrap or saturate otherwise.

Unsafe code should not rely on the correctness of behaviour after overflow.

The Standard Library

There are multiple types, such as Ipv4Addr, in the standard library that does not follow the suggested behaviour.

A small side note on this is that none of the implementations uses debug-assertions, but instead changes behaviour depending on overflow-checks. Although this is possible more of a documentation issue than anything else since it makes sense for it to depend on overflow-checks more than debug-assertions.

4. It will be monotonically increasing, if it doesn’t overflow

If you read the quote in the previous block you may have spotted the word «/saturate/» which may have made you wonder what type does that? We have only looked at types without any disallowed bit-patterns. But what happens with types does not have that? Well, it depends the standard library does it in two different ways. Types such as char and std::ascii::Char will always panic when you reach the end and the allowed bit-patterns. Then you have NonZero<u*> which saturates.

for i in NonZero::new(250u8).unwrap().. {
    println!("{i}");
}

When this code is run with overflow-checks = true this code will panic with the last value being 254[^3].

When this code is run with overflow-checks = false this code will print out:

250
251
252
253
254
255
255
255
255
255
255
...

And just continue like that forever. This means that it is not always monotonically increasing since it will stay the same. This can be pretty confusing when you see it for the first time since it does not show up anywhere else in the standard library, which leads me to

5. Iteration over various types should work consistently

A last thing I want to highlight is that the standard library is a bit inconsistent with how it works. There are 7 different types (I count signed and unsigned integers as one type each). They each work in one of 3 different ways.

The overflow behaviour of the Step implementation of various types in the standard library[^4].
Type Debug Release
AciiChar panic! panic!
char panic! panic!
i* panic! Overflow to T::MIN
u* panic! Overflow to T::MIN
Ipv4Addr panic! panic!
Ipv6Addr panic! panic!
NonZero<u*> panic! Saturates!

This is at least something that should be documented on the various types, because it is not fully clear. Currently there is some documentation on the nightly only Step type but nothing local.

Arguments for the current Semantics

I’ll go over some of the arguments I have heard for the current semantics.

If I hear new arguments I might update this section.

Zip

When the libs-api team discussed it one of the things they saw as worthy reasons was to use it together with zip. For example something like iter.zip(1..) this gives you a version of Iterator::enumerate that can use a arbitrary type implementing Step. I agree that it is a good idea to be able to do that, but I think that we should be able to do something better.

My proposal would be to add an additional method on Iterator called something like enumerate_with[^5] then you would be able to write: iter.enumerate_with(250u8) and it would use the step implementation. You could even add some way to make multiple steps, but I was not able to make that work in a nice way[^6].

I implemented a quick proof of concept:

pub struct EnumerateWith<T, I, const C: usize = 1> {
    iter: I,
    counter: T,
}

impl<T, I, const C: usize> Iterator for EnumerateWith<T, I, C>
where
    T: Step,
    I: Iterator,
{
    type Item = (T, <I as Iterator>::Item);

    fn next(&mut self) -> Option<(T, <I as Iterator>::Item)> {
        let a = self.iter.next()?;
        let i = Step::forward(self.counter.clone(), C);
        self.counter = i.clone();
        Some((i, a))
    }
}

pub trait EnumerateWithExt: Iterator {
    fn enumerate_with<T: Step>(self, start: T) -> EnumerateWith<T, Self>
    where Self: Sized
    {
        EnumerateWith { iter: self, counter: start }
    }
}
impl<T> EnumerateWithExt for T where T: Iterator + ?Sized {}

Doing it in this way would allow to have a place where you could document the issues with using Zip with a Step type as that is currently only documented on the Step trait itself. This is probably also something you could add as a Clippy lint or similar to make the rewrite automatically.

The current solution works fine

This is one I can agree with in some ways, there should be a good reason before such semantics are changed since there surely is someone out there who uses these semantics. It is something that would probably be hard to find with something like a crater run since even if there were someone relying on this it would probably not show up in normal tests since overflows would cause a panic with the default debug profile.

So it might be hard to find places where this causes breakage.

Though for the same reason you can also argue that it will not cause any serious breakage because most of the time a panic with the debug profile would be bad enough that authors might consider changing their code.

What do I think should happen on overflow?

I personally think that the RangeFrom iterator should just return None when it reaches the end of a bounded type. In that way you could give more control to the implementation of the bounded type. Currently the RangeFrom iterator uses the Step::forward method which must always return a new value, if it was change to using the Step::checked_forward it could return None. If that was done you could have special implementations such as making std::num::Wrapping have the semantics that the current type has. It would also still allow to implement Step for memory backed big integers that can grow unbounded.

I believe that this would remove a foot gun from the language since the semantics are not really clear. There was a chance to do it recently with the new Range* types, but the ship have probably sailed on changing the iterators since the types have been stabilized.

Conclusion

This is the first proper opinion piece here so I would be very happy to hear everyone’s thoughts about this whether it is on some internet forum or to any of my means of communications listed on the about page.

I hope that this article will at least give some food for thought even if I could not convince you here.

Depending on the feedback to this article I might try to revive the current ACP (libs-team#304) or make a new one. If changing the behaviour is something more people agree with.

I will again extend apologies to everyone who have talked with me about this for extended periods of time since it for the past few months have been my goto topic when chatting about Rust. Also again thanks to maya for listening to me and reading this to give feedback for the final article.

Thanks for reading.

Footnotes

  1. This is the case with both the new and old iterators.
  2. This refers to all these types: u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
  3. This follows from expectation 1.
  4. With the default profiles
  5. Other possible names for future bikeshedding is enumerate_in and enumestep
  6. As far as I can tell you cannot have default values for const arguments in functions. So fn enumerate_with<T: Step, const C: usize = 1>(self, start: T) does not work