Feature #20080
closedIntroduce #bounds method on Range
Description
Followup Reference: #20027
Update 1/11/24: (based on many wonderful suggestions!)
- 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]
- 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.
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)
Updated by stuyam (Stuart Yamartino) 10 months ago
- Subject changed from Implement #bounds method on Range to Introduce #bounds method on Range
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]
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.
Updated by mame (Yusuke Endoh) 8 months ago
- Status changed from Open to Feedback