Add a `clamp` function to Algorithm.swift

Empty ranges brings problem with optional value, as @jenox mentioned.
If we use function with 2 arguments, then such problems are impossible:

5.clamped(between: 0, and: 0) // 0

Though, using range looks more natural. We can restrict this function to using only closed ranges:

5.clamped(between: 0, and: 0) // 0
// vs
5.clamped(to: 0...0) // 0
5.clamped(to: 0..<0) // compiler error, only closed range is allowed

Second variant is more readable.

I don't think the design should be driven by the fact that 0..<0 is an invalid parameter. To me, Range is the right object to use:

  • It is the Swift way of describing something that has a lower bound and an upper bound ā€” included or not.
  • clamped(to: Range) already has a precedence in the standard library, for range.
  • It works well even if at the calling site you don't have a range but two values (i.e., firstValue...secondValue).

About the empty range problem, I would argue that's a programmer error. Clamping a number to something that's empty doesn't make much sense, so I would treat that as a precondition. A bit similar to [Int](repeating: 0, count: -5). This will hit a precondition because -5 doesn't make sense here. And returning an Optional in this case doesn't seem really justified. If we start using Optional for edge cases, I'm afraid we will end-up with Optional everywhere.

Another approach could be to do nothing when an empty Range is provided, but I don't think that's a good approach. What would be the intent in this case? A precondition seems more appropriate.

5 Likes

@alaborie Iā€™m a bit confused by your commentā€”are you just advocating for any ā€œrangeā€ parameter over separate bound parameters, or are you advocating for a Range parameter over a ClosedRange parameter? Supporting Range as the argument here is problematic beyond the possibility of empty ranges. As @jenox notes, you can come up with a sound implementation if you have the appropriate generic constraints on the Bound parameter, but I donā€™t think itā€™s possible to come up with a sound definition for an arbitrary Range.

Simply implicitly including the upperBound and essentially interpreting Ranges as ClosedRanges is only looking for trouble. I'd expect the following postcondition to always hold:

range.contains(value.clamped(to: range))

clamped(to: Range) makes sense on Ranges because we can compute a clamped Range to return in a sound way. Of course, this range can be empty. With Range, we can even express different empty ranges: For example, 0..<0 and 1..<1 are different ranges, even though both are empty.

When we want to clamp values in ranges though, this is generally not possible. There is no value in an empty range that we could return, hence the Optional to deal with empty ranges.

2 Likes

Sorry for the confusion @Jumhyn. Basically I would allow both, like so:

extension Comparable where Self: Strideable, Self.Stride: SignedInteger {

    func clamped(to limits: CountableRange<Self>) -> Self {
        guard let minValue = limits.first, let maxValue = limits.last
            else { preconditionFailure("Can't clamp Comparable with an empty range") }
        return min(maxValue, max(minValue, self))
    }

    func clamped(to limits: ClosedRange<Self>) -> Self {
        clamped(to: Range(limits))
    }
}

5.clamped(to: 0..<3)        // 2
5.clamped(to: 0..<1)        // 0
5.clamped(to: 0...3)        // 3
5.clamped(to: 0...10)       // 10
5.clamped(to: 0..<0)        // "Can't clamp Comparable with an empty range"
1 Like

About precedence, we also have random(in:), which for most supported type, has both Range and ClosedRange non-optional versions, with Range version raises an exception on empty range.

Though I'm not sure if using last on open range will be what programmers expect (and using end would just be wrong).

1 Like

I've been using the ClosedRange version of clamped(to: in my code for a while. It seems simple and avoids all the problems mentioned above. No empty range, works in a clear way with floating point types, no need for returning optionals or fatalError. The only objection seems to be that some would like to write clamped(to: 0..<100) rather than clamped(to: 0...99)

1 Like

The empty range can be created not only by programmer mistake. If lower and upper bound are passed as function arguments or variables, then compiler doesn't help us. clamped(to:) function not always used with literals that can be checked.

I would prefer a variant that prevents making a mistake at all rather than function, that relies on programmer's discipline or any kind of preconditions.

I agree with @alaborie. Such a case is described in the error handling manifesto, where itā€™s not worth adding optionals for any little thing that could go wrong. For example, in subscripts, if we considered that the user could provide an invalid index - which is much more common than empty ranges would occur - we would have optional subscripts everywhere. So, I donā€™t think optional in the clamped method are justified by the possibility of an empty range being given.

Thanks for your opinion, I haven't thought about it from this point of view. Now I've got what you both are saying about :ok_hand:t3:

I think clamp only makes sense when there are clearly defined upper and lower bounds present.

For times when there is only one bound I think it makes sense to use the names "floor" and "ceil" with only one parameter.

extension Comparable {
    func clamp(_ range: Range<Self>) -> Self {
        max(min(self, range.upperBound), range.lowerBound)
    }

    func floor(_ lowerBound: Self) -> Self {
        max(lowerBound, self)
    }

    func ceil(_ upperBound: Self) -> Self {
        min(upperBound, self)
    }
}

Floor and ceil makes me think of the floating point rounding functions, but it might be worth considering this:

let a = b.clamped(to: x...y)
let a = b.clamped(to: x...) // as an alternative to  a = max(b, x) 
let a = b.clamped(to: ...y) // as an alternative to  a = min(b, y)

It could be reasonable to add those two clamped-overloads as alternatives to max(b, x) and min(b, y) just like the original clamped would be an alternative to max(x, min(b, y)).

I don't know if it's only me, but I always have to stop and think about (all three of) the min-max-alternatives, they're not as immediately obvious to me as the clamped alternatives.

14 Likes

Good point on the naming!
Yeah on second thought maybe having the range based version might be nice.

1 Like

So given the feedback and a (quite possibly failed) attempt at the most minimalistic implementation I came up with the solution below.

Range types like Range don't make any sense for the upper bound.
For example:

1..<100

How would you write a generalized solution for code that handles what to return when the thing being clamped passes the upper bound?

So I think the clamp implementation would best be limited to ClosedRange (A...B), PartialRangeFrom (A...) and PartialRangeThrough (...B)

extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        max(min(self, range.upperBound), range.lowerBound)
    }

    func clamped(to range: PartialRangeFrom<Self>) -> Self {
        max(self, range.lowerBound)
    }

    func clamped(to range: PartialRangeThrough<Self>) -> Self {
        min(self, range.upperBound)
    }
}

So in usage it would be something like this:

var answer = 42

answer.clamped(to: 10...) // 42
answer.clamped(to: 0...15) // 15
answer.clamped(to: ...10) // 10

EDIT: fixed function names

4 Likes

I am updating the proposal now, I will have the PR in a bit!

1 Like

Thanks for working on this proposal!

Small nit: I think following the naming guidelines and the precedent set by the existing
ClosedRange.clamped(to limits: ClosedRange)`
would lead to spelling them like this:

let a = 42
let b = a.clamped(to: 0...15) // a = 15
let b = a.clamped(to: 10...) // a = 42
let b = a.clamped(to: ...10) // a = 10

rather than (as you propose):

let a = 42
let b = a.clamp(0...15) // a = 15
let b = a.clamp(10...) // a = 42
let b = a.clamp(...10) // a = 10

Unless clamp is to be considered a term of art, in which case I guess ClosedRange's clamped(to:) would have to be renamed. My personal preference in general is the opposite, ie remove term of art status for map, filter etc and make them follow the general naming system.


Also, together with a hypothetical mutating variant, which I think could make sense, it could be simply:

let r = v.clamped(to: x...y)
mutableValue.clamp(to: x...y)

but, if clamp is to be considered a term-of-art, we'd have to consider something like:

let r = v.clamp(x...y)
mutableValue.formClamp(by: x...y)

or

mutableValue.clampInPlace(x...y)

etc

6 Likes

Clamping to CountableRange makes perfect sense. Iā€˜ve use such extensions for years:

Thanks for the heads up!

When I originally authored SE-0177 I called it clamped(to:) like you suggested and the naming conventions from Swift 3 forward pointed out!

.clamped(to:) would me it returns a new value that had clamp applied to it and clamp(to:) would semantically mean it mutated the value and modified it "in-place" right?

Right.

The implementation should probably just be a matter of "copying" the existing ClosedRange.clamped implementation.

Something like this.
extension Comparable {

  /// Returns a copy of this value clamped to the given limiting range.
  /// ...
  @inlinable
  @inline(__always)
  public func clamped(to limits: ClosedRange<Self>) -> Self {
    return max(limits.lowerBound, min(self, limits.upperBound))
  }

  /// Returns a copy of this value clamped to the given limiting range.
  /// ...
  @inlinable
  @inline(__always)
  public func clamped(to limits: PartialRangeFrom<Self>) -> Self {
    return max(limits.lowerBound, self)
  }

  /// Returns a copy of this value clamped to the given limiting range.
  /// ...
  @inlinable
  @inline(__always)
  public func clamped(to limits: PartialRangeThrough<Self>) -> Self {
    return min(limits.upperBound, self)
  }

}

1 Like

I updated the proposal and sent a pull request here:

https://github.com/apple/swift-evolution/pull/1156