Project

General

Profile

Actions

Feature #20080

closed

Introduce #bounds method on Range

Added by stuyam (Stuart Yamartino) 11 months ago. Updated 8 months ago.

Status:
Feedback
Assignee:
-
Target version:
-
[ruby-core:115864]

Description

Followup Reference: #20027

Update 1/11/24: (based on many wonderful suggestions!)

  1. Call the method #bounds.
first, last = (1..300).bounds # => [1, 300]
first, last = (300..1).bounds # => [300, 1]
first, last = (..300).bounds # => [nil, 300]
first, last = (1..).bounds # => [1, nil]
  1. Add exclude_end? support so re-hydration of Range works:
b = (1..2).bounds  #=> [1,2]
Range.new(*b)      #=> 1..2

b = (1...2).bounds #=> [1,2,true]
Range.new(*b)      #=> 1...2

I did a better job of outlining use cases in this comment below so I will let that speak for itself: https://bugs.ruby-lang.org/issues/20080#note-3

Update: 2/13/24
Browsing the ruby codebase I noticed that the #as_json method on Range (when you require 'json/add/range') does something similar to what the #bounds method we are describing is doing: https://github.com/ruby/ruby/blob/master/ext/json/lib/json/add/range.rb#L34

{
  JSON.create_id  => self.class.name,
  'a'             => [ first, last, exclude_end? ]
}

This tells me we are on the right track. Though the difference here is that exclude_end? is always included. I am thinking the #bounds should maybe always be including the exclude_end? piece rather than only include it if it's true. I am inclined to suggest always including the exclude_end? regardless of if it is true or false to avoid confusion or people ever calling #last on the #bounds results thinking it is the #last value where it could be the #last or #exclude_end? value depending on the type or range. This would still satisfy all of the use cases outlined in this comment: https://bugs.ruby-lang.org/issues/20080#note-3

Therefore I think #bounds should just always return: [ first, last, exclude_end? ]

Original Proposal:
This feature request is to implement a method called #begin_and_end on Range that returns an array of the first and last value stored in a range:

(1..300).begin_and_end #=> [1, 300]

first, last = (300..1).begin_and_end
first #=> 300
last #=> 1

I believe this would be a great addition to Ranges as they are often used to pass around a single object used to hold endpoints, and this allows easier retrieval of those endpoints.
This would allow easier deconstruction into start and end values using array deconstruction as well as a simpler way to serialize to a more primitive object such as an array for database storage.
This implementation was suggested by @mame (Yusuke Endoh) in my initial feature suggestion regarding range deconstruction: https://bugs.ruby-lang.org/issues/20027

This implementation would work similar to how #minmax works where it returns an array of two numbers, however the difference is that #minmax doesn't work with reverse ranges as @Dan0042 (Daniel DeLorme) pointed out in the link above:

(1..42).minmax #=> [1, 42]
(42..1).minmax #=> [nil, nil]

Updated by Dan0042 (Daniel DeLorme) 11 months ago

Can you show an example use case that demonstrates the value of the feature?

Because
first, last = (300..1).begin_and_end is simpler as first, last = 300, 1
first, last = r.begin_and_end might as well be first, last = r.first, r.last
or just use r.first and r.last directly instead of local variables

Updated by ufuk (Ufuk Kayserilioglu) 11 months ago

I agree that this would be a good method to add for cases where one is handed a Range instance and accessing the bounds of that range is needed.

However, I think the name should be #bounds and it should work as:

(1..300).bounds # => [1, 300]
(1...300).bounds # => [1, 300]
(..300).bounds # => [nil, 300]
(1..).bounds # => [1, nil]
(nil..).bounds # => [nil, nil]

(300..1).bounds # => [300, 1]
(300...1).bounds # => [300, 1]
(300..).bounds # => [300, nil]

Updated by stuyam (Stuart Yamartino) 11 months ago

@ufuk (Ufuk Kayserilioglu) I like #bounds as a name also, great suggestion, let's try that. And thank you for showing beginless and endless examples, I agree with that usage.

@Dan0042 (Daniel DeLorme) Sorry for the bad example, I didn't show a good use case I just showed show I thought it would work. Here are a few use cases:

Use Case 1: Query filter, array deconstruction example

def filter_by_date_range(date_range)
  start_date, end_date = date_range.bounds
  where('start > ? AND end < ?', start_date, end_date)
end

(I know rails supports ranges in ActiveRecord but I have needed this for more manual queries)

Use Case 2. Serialize a range to store as an array in a database column:

# assumes a table with a `range_column` of type jsonb[] (array)
def store_in_table
  SomeTable.insert('range_column', range.bounds)
end

Use Case 3: Convert array of ranges to array of array bounds:

range_array = [(1..10), (10..20), (20..30)]
bounds_array = range_array.map(&:bounds) #=> [[1, 10], [10, 20], [20, 30]]

Up until now you can only every "deserialize" the data out of a range into other parts using #begin and #end but have never been able to do it in one go. Sometimes you want just one value but often you want both. Often a range is passes between methods to keep the data as a single object especially if they are contextual to each other. For example, rather than passing around start_date and end_date as method params through a bunch of methods, you can just pass around a date_range. But in that case the range is just a way of keeping those values logically together. Or if one is nil such as a beginless or endless range it is more clear when they are kept together. In the end though, the start_date and end_date can be easily pulled out using array deconstruction with the #bounds method.

Updated by Dan0042 (Daniel DeLorme) 11 months ago

If it's for serialization wouldn't you also want to know exclude_end?

b = (1..2).bounds  #=> [1,2]
Range.new(*b)      #=> 1..2

b = (1...2).bounds #=> [1,2,true]
Range.new(*b)      #=> 1...2

Updated by shan (Shannon Skipper) 11 months ago

An aside, but with Enumerator::ArithmeticSequence, last give you the value excluding end but not so with Range.

(1...2).last
#=> 2

((1...2) % 1).last
#=> 1

Updated by stuyam (Stuart Yamartino) 11 months ago

@Dan0042 (Daniel DeLorme) great idea! At first I was against this because I thought it would make deconstruction harder but it actually wouldn't because deconstruction would work the same. I was thinking the second value if deconstructed would be an array like [2, true] but that would only be if you used a star * during deconstruction. I'm liking this...

a = (1..2).bounds  #=> [1,2]
b = (1...2).bounds #=> [1,2,true]

Range.new(*a)      #=> 1..2
Range.new(*b)      #=> 1...2

start_num, end_num = a #=> start_num = 1, end_num = 2
start_num, end_num, excluded_end = a #=> start_num = 1, end_num = 2, excluded_end = nil
start_num, end_num = b #=> start_num = 1, end_num = 2
start_num, end_num, excluded_end = b #=> start_num = 1, end_num = 2, excluded_end = true
start_num, *remaining = b #=> start_num = 1, remaining = [2, true]

My only thought then is should there be an option to always include exclude_end? even when it is false like (1..2).bounds(true) #=> [1,2,false] so you would get false rather than nil in the array deconstruction example above.
Conversely (1...2).bounds(false) #=> [1,2] if you wanted to not include the excluded_end??

Updated by rubyFeedback (robert heiler) 11 months ago

I have no particular opinion on the suggested feature itself,
but I agree that .bounds() is a better API / name than
.begin_and_end(), even though I understand the rationale
between the latter name making it more explicit. Ruby often
tries to prefer terse names, when that is possible and makes
sense.

Actions #8

Updated by stuyam (Stuart Yamartino) 11 months ago

  • Description updated (diff)
Actions #9

Updated by stuyam (Stuart Yamartino) 11 months ago

  • Description updated (diff)
Actions #10

Updated by stuyam (Stuart Yamartino) 10 months ago

  • Subject changed from Implement #begin_and_end method on Range to Implement #bounds method on Range
  • Description updated (diff)
Actions #11

Updated by stuyam (Stuart Yamartino) 10 months ago

  • Subject changed from Implement #bounds method on Range to Introduce #bounds method on Range
Actions #12

Updated by stuyam (Stuart Yamartino) 10 months ago

  • Description updated (diff)

Updated by hsbt (Hiroshi SHIBATA) 10 months ago

@stuyam (Stuart Yamartino) Can you add this proposal to next dev-meeting? https://bugs.ruby-lang.org/issues/20075#note-9 was after the deadline and will not be discussed.

Updated by stuyam (Stuart Yamartino) 10 months ago

@hsbt (Hiroshi SHIBATA) I just added to the next meeting, thank you for letting me know! https://bugs.ruby-lang.org/issues/20193#note-4

Updated by AMomchilov (Alexander Momchilov) 9 months ago

Could we implement this as #deconstruct, so Ranges can support destructuring?

class Range
  def deconstruct = [self.begin, self.end]
end

case 1..2
in [1, Integer => upper]
  p "matched: #{upper}"
else
  p "not matched"
end

Updated by Dan0042 (Daniel DeLorme) 9 months ago

AMomchilov (Alexander Momchilov) wrote in #note-15:

Could we implement this as #deconstruct, so Ranges can support destructuring?

This was rejected in the original ticket from which this one originated: #20027#note-3

Updated by zverok (Victor Shepelev) 9 months ago

@Dan0042 (Daniel DeLorme) To be fair, that ticket seems to have rejected "old-style" deconstruction mainly (b, e = range). The possibility of #deconstruct is mentioned in one of the comments, but rejection is more vague on it.

Updated by Dan0042 (Daniel DeLorme) 9 months ago

Oh you're right, it seems like I mixed up the #deconstruct comment in note-2 with the rejection notice in note-3.
Although imho it doesn't feel intuitive to pattern-match a range like that. I would expect to able to do 1..8 in [*,5,*]. So for this case I would rather use (1..2).bounds in [1, Integer => upper]

Actions #19

Updated by stuyam (Stuart Yamartino) 9 months ago

  • Description updated (diff)

Updated by matz (Yukihiro Matsumoto) 9 months ago

What ever it is, at least it's not "bounds" especially when a range excludes end.
Maybe we seek another name (or behavior), if we really need to add the feature.

Matz.

Updated by stuyam (Stuart Yamartino) 9 months ago

Thanks for the feedback @matz (Yukihiro Matsumoto)! Is it the word bounds that you don't like in relation to the start and end values of a range? I personally think bounds or a boundary can be considered inclusive or exclusive which is why including exclude_end? as part of the array is useful context. But it really comes down to how you might define bounds so I understand if you feel it doesn't fit. Do you think deconstruct might make sense then to support pattern matching or just manually calling it for serialization or array deconstruction? Admittedly I am not super familiar with how pattern matching works so Im not as clear on how that part would work.

I still feel this would be useful for serializing ranges and deconstructing values out of ranges, but I understand if people don't see the value.

Updated by matz (Yukihiro Matsumoto) 9 months ago

Actually, I don't see the clear benefit of the proposal. first, last = range.bounds can be first = range.begin; last = range.end, and Range.new(*range.bounds) can be range.dup.
By adding bounds the code could become a little concise but only just.

In addition, I don't think the name bounds describe the behavior (returning begin, end and exclude_end?).

Matz.

Actions #23

Updated by mame (Yusuke Endoh) 8 months ago

  • Status changed from Open to Feedback
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like1Like0Like0Like0Like0Like0Like0Like0Like1Like0