Feature #22175
closedAdd `Range#clamp`
Description
I would like to propose Range#clamp, which returns a new Range whose begin and end values are clamped to the given bounds.
Proposed call-seq:
This is a range counterpart of Comparable#clamp. While Comparable#clamp clamps a single value, Range#clamp clamps both endpoints of a range.
Examples:
(1..10).clamp(3, 7) #=> 3..7
(1...10).clamp(3, 7) #=> 3..7
(1...10).clamp(3, 10) #=> 3...10
(0...).clamp(0, 10) #=> 0..10
(1..10).clamp(3..7) #=> 3..7
(1..10).clamp(3...7) #=> 3...7
(1..5).clamp(3...7) #=> 3..5
clamp(min, max) behaves like clamping by an inclusive range min..max. If an exclusive upper bound is needed, a range argument can be used:
Beginless and endless ranges are also supported:
(..10).clamp(3, 7) #=> 3..7
(0...).clamp(0, 10) #=> 0..10
(1..10).clamp(..7) #=> 1..7
(1..10).clamp(...7) #=> 1...7
(1..10).clamp(3..) #=> 3..10
If the receiver is entirely outside the clamping bounds, the returned range is empty:
The returned range excludes its end when the returned end value is an excluded end value of either the receiver or the argument range:
Otherwise, the returned range includes its end.
Relation to [Feature #16757]¶
[Feature #16757] proposes Range#intersection / Range#& as a general operation for intersecting two ranges.
Range#clamp is closely related, but intentionally narrower. It treats the argument as clamping bounds for the receiver, similar to how Comparable#clamp treats its arguments as bounds for one value.
For overlapping ranges, range.clamp(bounds) often produces the same result as a range intersection. For example:
However, clamp has a bounds-oriented API and naturally supports the two-argument form:
Also, when the receiver is outside the bounds, clamp returns an empty Range at the nearest bound, rather than needing to decide whether a general intersection operation should return nil, [], an empty range, or raise:
So this proposal can be considered either independently, as a range counterpart of Comparable#clamp, or as a smaller operation that could coexist with a future Range#intersection.
Motivation¶
It is common to restrict ranges to known boundaries, for example when limiting source locations, pagination windows, numeric domains, date/time windows, or user-provided ranges.
Currently this has to be written manually by clamping both endpoints and reconstructing the range while preserving the correct excluded-end behavior. That logic is easy to get subtly wrong, especially with exclusive ranges, beginless/endless ranges, and ranges that become empty after clamping.
Range#clamp would provide a small, direct API for this operation.
Notes¶
The method returns a new Range instance.
The single-argument form accepts a range-like object accepted by Ruby’s range conversion logic.
Source checked: [Feature #16757]: Add intersection to Range.
Implementation¶
Updated by nobu (Nobuyoshi Nakada) 23 days ago
- Description updated (diff)
Updated by zverok (Victor Shepelev) 23 days ago
+1 for this proposal. Needed this quite a few times.
Updated by mame (Yusuke Endoh) 22 days ago
For the record, here is the use case I had in mind when talking with @nobu (Nobuyoshi Nakada):
When traversing the 3x3 neighborhood of a cell (x, y) on a 2D grid of width x height, one is tempted to write code like this:
However, this may access cells outside the grid, so in practice we have to write something like the following, which I don't find very readable:
[y - 1, 0].max.upto([y + 1, height - 1].min) do |ny|
[x - 1, 0].max.upto([x + 1, width - 1].min) do |nx|
grid[ny][nx]
end
end
With Range#clamp, it can be written as:
(y - 1 .. y + 1).clamp(0...height).each do |ny|
(x - 1 .. x + 1).clamp(0...width).each do |nx|
grid[ny][nx]
end
end
That said, I'm a bit concerned about creating a Range object on every iteration, so I'm not sure I would actually use this in a hot loop.
So I'm not super enthusiastic about this proposal, but I can imagine cases where it would be handy, so I'm not against it either.
Updated by Eregon (Benoit Daloze) 18 days ago
- Related to Feature #16757: Add intersection to Range added
Updated by Eregon (Benoit Daloze) 18 days ago
· Edited
This looks nice but I think Range intersection is more general and more useful.
It's also symmetrical which seems a nice property.
Manually implementing Range intersection is not trivial and having built-in would be great.
We already have {Array,Set}#{intersection,&} so this would be the Range variant of that.
Are there use cases where Range#clamp would work but Range#intersection wouldn't, i.e. where the asymmetry would be useful?
Updated by Eregon (Benoit Daloze) 18 days ago
Updated by mame (Yusuke Endoh) 18 days ago
I feel clamp fits better here, since what I want is to "clamp" the range ends into the given bounds.
I personally dislike intersection for a few reasons: (1) its set-theoretic name adds cognitive load to read/write, (2) the name is too long, and (3) having intersection makes me expect a union too. clamp avoids all of them.
Updated by nobu (Nobuyoshi Nakada) 17 days ago
Eregon (Benoit Daloze) wrote in #note-5:
This looks nice but I think Range intersection is more general and more useful.
It’s true that [Feature #16757] is more general, but as a result, it exhibits strange behavior in edge cases.
For example, (1..10).intersection(20..30) returns an empty Array rather than a Range.
Are there use cases where
Range#clampwould work butRange#intersectionwouldn't, i.e. where the asymmetry would be useful?
Range#clamp is intentionally designed to be asymmetric in order to avoid such edge cases.
Updated by matz (Yukihiro Matsumoto) 16 days ago
Accepted.
Range#clamp is a natural range counterpart of Comparable#clamp, and the use case (restricting a range to known boundaries) is common enough.
Regarding Range#intersection (#16757): I see them as different operations, even though they often produce the same result. clamp is asymmetric by design; the argument is bounds, not a peer range, which is why the two-argument form clamp(min, max) makes sense and why returning an empty range at the nearest bound is a natural answer for disjoint cases. A symmetric intersection would have to decide between nil and an empty range for disjoint inputs. Also, once we have intersection, people would naturally expect union as well, but Range#union cannot be defined in general because the union of two ranges may be discontiguous. These points can be discussed separately in #16757; accepting clamp does not preclude intersection.
Matz.
Updated by nobu (Nobuyoshi Nakada) 5 days ago
- Status changed from Open to Closed
Applied in changeset git|513875464f0b71ad6c311ef6ae469eb935d5d7ca.
[Feature #22175] Add Range#clamp
Return a new Range with its begin and end clamped to given
bounds, either as clamp(min, max) or clamp(range).