Feature #22175
openAdd `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) 2 days ago
- Description updated (diff)
Updated by zverok (Victor Shepelev) 2 days ago
+1 for this proposal. Needed this quite a few times.
Updated by mame (Yusuke Endoh) 1 day 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.