crystal icon indicating copy to clipboard operation
crystal copied to clipboard

Strided subranges for `Array#[]` and `Range`-accepting methods

Open HertzDevil opened this issue 1 year ago • 0 comments

In Python, indexing a collection with a stepped range creates a strided subrange (or rather, ranges have a step of 1 by default):

# all end values are exclusive
x = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
x[2:8:2]  # => [4, 16, 36]
x[2::2]   # => [4, 16, 36, 64]
x[0:10:3] # => [4, 16, 36, 64]

x[8:2:-1] # => [64, 49, 36, 25, 16, 9]
x[8:2:-2] # => [64, 36, 16]
x[4::-1]  # => [16, 9, 4, 1, 0]
x[:2:-1]  # => [81, 64, 49, 36, 25, 16, 9]
x[:2:-2]  # => [81, 49, 25, 9]

# unbounded ranges are allowed
x[::3]  # => [0, 9, 36, 81]
x[::-1] # => [81, 64, 49, 36, 25, 16, 9, 4, 1, 0]

# anything that responds to `__getitem__` is supported
'abc'[2:0:-1] # => 'cb'

In Ruby, support for Array#[](Enumerator::ArithmeticSequence) has apparently been added since 3.0.0, whereas String doesn't work. Note that the two languages handle the end points differently:

# end values may be inclusive
x = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
x[(2..8).step(2)]  # => [4, 16, 36, 64]
x[(2..).step(2)]   # => [4, 16, 36, 64]
x[(0..10).step(3)] # raises RangeError

x[(8..2).step(-1)] # => [64, 49, 36, 25, 16, 9, 4]
x[(8..2).step(-2)] # => [64, 36, 16, 4]
x[(4..).step(-1)]  # => [16, 9, 4, 1, 0]
x[(..2).step(-1)]  # => [81, 64, 49, 36, 25, 16, 9, 4]
x[(..2).step(-2)]  # => [81, 49, 25, 9]

# end values may be exclusive
x[(2...8).step(2)]  # => [4, 16, 36]
x[(2...).step(2)]   # => [4, 16, 36, 64]
x[(0...10).step(3)] # => [0, 9, 36, 81]

# if step is negative, _begin_ values, not end values, may be exclusive
x[(8...2).step(-1)] # => [49, 36, 25, 16, 9, 4]
x[(8...2).step(-2)] # => [49, 25, 9]
x[(4...).step(-1)]  # => [9, 4, 1, 0]
x[(...2).step(-1)]  # => [81, 64, 49, 36, 25, 16, 9, 4]
x[(...2).step(-2)]  # => [81, 49, 25, 9]

x[Range.new(nil, nil).step(-1)] # raises TypeError
'abc'[(2..0).step(-1)]          # raises TypeError

# the above results are inconsistent with `Range#each` for finite ranges;
# when indexed this way the exclusive ranges behave like in python
x.values_at(*(8..2).step(-1))  # => [64, 49, 36, 25, 16, 9, 4]
x.values_at(*(8...2).step(-1)) # => [64, 49, 36, 25, 16, 9]

Elixir is also getting it soon. I do not know which convention it follows.

I wonder how useful this feature is and whether we need it. It seems to be relatively popular among numerical processing libraries. At a minimum Array#[] would support this, but this easily extends to similar methods like String#byte_slice.

Range(B, E)#step(T) produces a Steppable::StepIterator(B, E, T) in Crystal. For most ranges B and T are Int32, whereas E is Int32 or Nil depending on whether the range is endless. Beginless stepped ranges cannot be formed in Crystal, so extra work would have be done on Range apart from adding new overloads.

For some reason, these ranges raise RangeError in Ruby even though their unstepped counterparts merely return nil. For a positive step larger than 1, a range raises if begin < -size || begin > size || (exclusive? ? end - begin > size : end - begin >= size). For a negative step begin and end are swapped. (This is most likely what happens not only for range checking but also for the actual indexing.)

HertzDevil avatar Aug 15 '22 16:08 HertzDevil