step_by on negative numbers

I would find this very confusing. step_by isn't a general every_nth iterator adaptor. It specifically applies to ranges, and specifies what value should be added each iteration on the way from begin to end. Every call to next on a range checks if begin is equal to or past end. If so, it returns None. If not, it returns Some(begin) and updates begin in the range to be begin + step. I think a negative step should work exactly the same way, with begin naturally being greater than end to start.

3 Likes

Well I guess that's a matter of perspective. I just wanted to say how I felt about it :wink: But in the end it doesn't matter much, once a form will be imposed it will be picked up real fast by everyone.

I donā€™t consider whether the first or the second of these is currently intuitive as particularly relevant (people always adapt with time). With rules, both are clear:

// Ranges always go up. `step` indicates step direction.
(0..5).step(-1);
// Ranges may go up or down. Step direction must agree
// with the range.
(5..0).step(-1);

What is relevant is how inverting the range interacts with the [a, b) set notation by inverting it. Especially if the triple ... is added meaning [a, b]. This is much less obvious than the number order.

// Sometimes the right is skipped. Sometimes the left.
(0..5).step(+1)  // [0,5)
(0..5).step(-1)  // (0,5] in reverse order
(0...5).step(+1) // [0,5]
(0...5).step(-1) // [0,5] in reverse order

// Only the right number is ever skipped.
(0..5).step(+1)  // [0,5)
(5..0).step(-1)  // [5,0)
(0...5).step(+1) // [0,5]
(5...0).step(-1) // [5,0]

Of note: English speakers always read text left to right. Subverting the order requires extra krow (work: read right to left).

Unless we want step_by(_) to include an if, itā€™s going to be (5..0).step_by(-1).

@llogiq, thatā€™s not going to work for unsigned types given the current step_by definition.

True. Would it be possible to overload it for integral types - e.g. with i32 for u32 and so on?

We could choose a different Step definition as I noted above:

trait Step {
    type Step = Self;
    fn step(&self, by: &Step) -> Option<Self>;
    fn steps_between(start: &Self, end: &Self, by: &Step) -> Option<usize>;
}

Also, the problem isnā€™t having an if statement in step_by, but having one in the iterator. If we allowed (5..0).step_by(1), one would expect (5..0) to behave the same. In (5..0).step_by(1), we can have some logic to create a ā€œreverse stepā€ if necessary when the iterator is constructed so thereā€™s no performance issue. However, we donā€™t have the chance to do this when constructing (5..0) so weā€™d have to check the direction on every iteration.

What if we had up_by and down_by instead of step_by on unsigned types? This would remove all additional checks even on creation.

Signed types could then have step_by which would create an UpBy or DownBy depending on argument sign.

Edit: Just wanted to add: This would also have the benefit of making the intent clearer. Lints could easily pick up up_by or down_by with negative constants.

2 Likes

In principle this is fine since weā€™re already relying on iterator adaptors being inlined into the ground, and non-constant steps are, to my knowledge, the exception.

I use MATLAB a lot for prototyping machine learning algorithms, and find that the inclusive range is most intuitive from an application/algorithm development perspective. That is:

1:5         // {1, 2, 3, 4, 5} => mathematically, [1,5] for integers
1:1:5       // same as above
5:1         // empty array []; range has step = 1 by default
5:-1:1     // {5, 4, 3, 2, 1}
etc.

MATLAB also has similar behaviour for floating point numbers (actually any number in MATLAB is double by default).

If Rust were to adopt the inclusive range like in MATLAB, it would eliminate any differences between a .step_by(-1) and .rev(). Not to mention, that will also make Rust very attractive when porting the prototypes to operational applications.

Disclaimer: I'm a newbie to Rust and still unfamiliar with all its features and progress to date. Also, not being a computer scientist, I'm unaware of the benefits of a range as mentioned in ...

Late to the party, but hereā€™s two cents:

I, for one, am happy with the current implementation. It may be counter-intuitive at first, but once one learns how it works, there are very few ā€œgotchasā€. The current implementation guarantees (0..100).step_by(x).next() to be Some(0) or None, while implementing step_by(-x) as step_by(x).rev() could give a 100. Also, what does (0..).step_by(-1) return with that implementation? I want to be able to use (0..).step_by(prime) occasionally.

Additionally, I am against splitting the implementation into two parts when one will do, but that is just an opinion.

1 Like

in case you have variables. a..b could then either be a forward or a reverse range

How about fix method to flip the range if necessary to restore b ā‰„ a invariant?

impl Range<T> {
  fn fix(self) -> Self {
    if self.to >= self.from {
      self
    } else {
      Range { from: self.to, to: self.from }
    }
  }
}

Would it be useful, how do you think?

Perhaps, but the problem here is the exact inverse: Some have suggested that ranges behave as if they were .fix()ed all the time. This has a few unfortunate consequences in some settings.

What kind of error are you thinking?

EDIT: s/are/were -- I didn't realize how old this thread was :smile:

It could only possibly panic, right?

Ideally compile-time checks would be great, but we donā€™t really have any great tooling for that.

We already have two clippy lints (one for step_by(0) and one for negative bounds without step_by(..)). It should be fairly straightforward to extend this to cover other cases, but no one has done it yet.

Personally, I think this should be valid but yield no elements (since 0 is already past 10 when stepping by a negative amount).

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.