Project

General

Profile

Actions

Feature #20080

closed

Introduce #bounds method on Range

Added by stuyam (Stuart Yamartino) almost 1 year ago. Updated 9 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) almost 1 year 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) almost 1 year 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) almost 1 year 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) almost 1 year 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) almost 1 year 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) almost 1 year 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) almost 1 year 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) 12 months ago

  • Description updated (diff)
Actions #9

Updated by stuyam (Stuart Yamartino) 12 months ago

  • Description updated (diff)
Actions #10

Updated by stuyam (Stuart Yamartino) 12 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) 12 months ago

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

Updated by stuyam (Stuart Yamartino) 11 months ago

  • Description updated (diff)

Updated by hsbt (Hiroshi SHIBATA) 11 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) 11 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) 11 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) 11 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) 11 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) 11 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) 10 months ago

  • Description updated (diff)

Updated by matz (Yukihiro Matsumoto) 10 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) 10 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) 10 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) 9 months ago

  • Status changed from Open to Feedback
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like1Like0Like0Like0Like0Like0Like0Like0Like1Like0