Project

General

Profile

Actions

Feature #22175

closed

Add `Range#clamp`

Feature #22175: Add `Range#clamp`
1

Added by nobu (Nobuyoshi Nakada) 23 days ago. Updated 5 days ago.

Status:
Closed
Assignee:
-
Target version:
-
[ruby-core:125920]

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:

range.clamp(min, max) -> range
range.clamp(bounds)   -> range

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:

(1..10).clamp(3...7)      #=> 3...7

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:

(1..10).clamp(20..30)     #=> 20...20
(1..10).clamp(-10..0)     #=> 0...0
(1..).clamp(..0)          #=> 0...0

The returned range excludes its end when the returned end value is an excluded end value of either the receiver or the argument range:

(1...10).clamp(3, 10)     #=> 3...10
(1..10).clamp(3...10)     #=> 3...10

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:

(1..10).clamp(3..7)       #=> 3..7

However, clamp has a bounds-oriented API and naturally supports the two-argument form:

(1..10).clamp(3, 7)       #=> 3..7

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:

(1..10).clamp(20..30)     #=> 20...20

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

GH-17652


Related issues 1 (0 open1 closed)

Related to Ruby - Feature #16757: Add intersection to RangeClosedActions

Updated by nobu (Nobuyoshi Nakada) 23 days ago Actions #1

  • Description updated (diff)

Updated by zverok (Victor Shepelev) 23 days ago Actions #2 [ruby-core:125921]

+1 for this proposal. Needed this quite a few times.

Updated by mame (Yusuke Endoh) 22 days ago Actions #3 [ruby-core:125929]

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:

(y - 1).upto(y + 1) do |ny|
  (x - 1).upto(x + 1) do |nx|
    grid[ny][nx]
  end
end

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 Actions #4

Updated by Eregon (Benoit Daloze) 18 days ago · Edited Actions #5 [ruby-core:125961]

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 Actions #6 [ruby-core:125962]

Regarding the 3x3 neighborhood of a cell, my favorite solution is this:

board = {} # x+y.i => v
fill(board)

[-1i-1, -1i, -1i+1, -1, +1, 1i-1, 1i, 1i+1].each do { |dir|
  board[pos + dir]
}

It avoids the nested loop and the need to check for bounds since Hash#[] is just nil if out of bounds.

Updated by mame (Yusuke Endoh) 18 days ago Actions #7 [ruby-core:125969]

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 Actions #8 [ruby-core:125990]

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#clamp would work but Range#intersection wouldn'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 Actions #9 [ruby-core:126018]

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 Actions #10

  • 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).

Actions

Also available in: PDF Atom