Add a `clamp` function to Algorithm.swift

So here is the current pull request I have to the Swift repository:

https://github.com/apple/swift/pull/32268

Not to slow this down or anything but what about containers? The idea of clamping all the items in an array or other container seems useful. We might want clamp() and clamped() in that case for the containers. I realize that map can be used for clamped().

1 Like

One thing that could be done is just mapping over all the times with clamp in that case.

Any ideas for a next step?

Hi Nicholas --

Thanks for pushing on this a bit. I think that this would be a great thing to have in the standard library, and the cleaned up proposal is definitely an improvement. I do think that some tweaks and additional discussion is necessary to proceed, however:

First, the proposal as it stands doesn't spell out what happens if a type has "exceptional values" for Comparable (i.e. NaN). The behavior is implicitly specified by the definition that you give, but it would be good to spell it out explicitly in the detailed design section:

  • You cannot make a closed range with NaN as a bound, so we don't need to worry about that case.

  • You can make a partial range with a NaN bound, but this is probably a bug that should be fixed.

  • If a is NaN, then a.clamped(to: range) returns NaN with the definition you have. There are two competing considerations here; on one hand, clamped should satisfy the postcondition that the result is in the specified range, but on the other hand, floating-point operations generally should produce NaN if their inputs are NaN. There are three defensible options that do not require elaborate and far-reaching changes to the type hierarchy of the standard library:

    • return .nan
    • return range.lowerBound or range.upperBound
    • preconditionFailure

    The proposal should choose one of these and document it, and discuss the others under "alternatives considered", and the eventual implementation in the PR should add test cases that cover this.

My own (weak) choice would be to make this a preconditionFailure, but also to add a median free function that lets you get the second behavior (a.clamp(to: b ... c) is precisely median(a,b,c) in the non-exceptional case, and it's easier to reason about what the semantics should be in the exceptional cases, plus it's a generally useful operation that I think we should have in the standard library).

10 Likes

I like the idea of a median free function a lot:

  • It's generally useful beyond just clamping.
  • It seems like a natural, missing peer to min and max.
  • It neatly avoids the question of if/how to handle half-open ranges.
  • For me, a free function is a more intuitive way to surface this functionality (struggling to articulate why).
1 Like

Thank you for bringing up very important talking points!
I will update the proposal with a draft version of answers to the issues you have brought up and yes I also think a median function could be useful!

1 Like

Another design point that is implicit in your implementation, but should be discussed explicitly in the proposal: if a compares equal to b or c, what value is returned by:

a.clamped(to: b ... c)

The simplest real example of this also comes from floating-point:

(-0.0).clamped(to: 0 ... 1) // does this return -0 or +0?
(0.0).clamped(to: -1 ... -0) // what about this?

but these cases are relatively innocuous; it becomes much more important when you have more complex objects with a Comparable conformance that only uses a subset of the fields.

(I believe that the behavior of your implementation, FWIW, is to return lowerBound if self == lowerBound or the range is trivial, and self if self == upperBound and the range is non-trivial. I think that that's fine, but it should be documented. I think one could also argue that self should be returned when self == lowerBound, however.)

6 Likes

I guess this is due to implementation details of min() and max(). And I think you are right. Given that min, max and these clamped methods are all @inlineable I think there might be optimizations the compiler can do if the parameters to min are in the order min(self, upperBound) and to max are max(lowerBound, self). That would also result in self being returned in the cases where self == boundary as you suggest. That is not the case currently in the PR.

PR in progress here: Add checks that the endpoints of partial ranges are not-NaN. by stephentyrone · Pull Request #33378 · apple/swift · GitHub

2 Likes

@scanon I updated the proposal to elaborate the detailed design better and leave less to implicit interpretation.

@phoneyDev I updated the implementation in the proposal to pass self through when in equals a boundary.

Going forward I think it would be best to finish the proposal and then after there is some consensus on the proposal's design looking good I will update the pull request to apple/swift and add tests.

Here is the current proposal:

Add clamped(to:) to the stdlib

Introduction

This proposal aims to add functionality to the standard library for clamping a value to a provided range.
The proposed function would allow the user to specify a range to clamp a value to where if the value fell within the range, the value would be returned as is, if the value being clamped exceeded the upper or lower bound then the upper or lower bound would be returned respectively.

Swift-evolution thread: Add a clamp function to Algorithm.swift

Motivation

There have been quite a few times in my professional and personal programming life where I reached for a function to limit a value to a given range and was disappointed that it was not part of the standard library.

There already exists an extension to CountableRange in the standard library implementing clamped(to:) that will limit the calling range to that of the provided range, so having the same functionality but just for types that conform to the Comparable protocol would be conceptually consistent.

Having functionality like clamped(to:) added to Comparable as a protocol extension would benefit users of the Swift language whom wish to guarantee that a value is kept within bounds, perhaps one example of this coming in handy would be to limit the result of some calculation between two acceptable numerical limits, say the bounds of a normalized coordinate system.

Proposed solution

The proposed solution is to add a general purpose clamped(to:) method to the Swift Standard Library as an extension to Comparable handling ClosedRange (A...B), PartialRangeFrom (A...) and PartialRangeThrough (...B).

The function would return a value within the bounds of the provided range, if the value clamped(to:) is being called on falls within the provided range then the original value would be returned.
If the value outside the bounds of the provided range then the respective lower or upper bound of the range would be returned.

Given a clamped(to:) function it could be called in the following ways, yielding the results in the adjacent comments:


42.clamped(to: 0...50) // 42
42.clamped(to: 200...) // 200
42.clamped(to: ...20) // 20

Detailed design

Overview

The implementation of clamped(to:) that is being proposed is composed of a protocol extension on Comparable accepting ranges of the types ClosedRange, PartialRangeFrom and PartialRangeThrough.

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

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

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

Behaviour of clamped(to:)

Value being clamped is less than lowerBound

If the value being clamped is less than the lowerBound the lowerBound will be returned.

100.clamped(to: 500...1000) // returns 500

Value being clamped is greater than upperBound

If the value being clamped is greater than the the upperBound then the upperBound will be returned.

9.clamped(to: 1...5) // returns 5

Value being clamped is within range

If the value being clamped is within the range the value is returned as is.

9.clamped(to: 1...10) // returns 9

Case where value is already within range

If the value being clamped already falls within the provided range then self will be returned.
To demonstrate that this is the case lets look at the min and max free functions that are used in the implementation of clamped(to:) .

in the case of clamped(to range: ClosedRange<Self>) -> Self

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

we can see that max is being called with the lowerBound passed in as the leftmost parameter followed by min(self, range.upperBound).

Looking at the definition of max we can see as documented max(a, b) will return b if both values passed in are equal.
Here min(self, range.upperBound) is being passed in as the last parameter to max.
Looking at the definition of min will show that min(a, b) where a == b is true will return a unmodified, here we are passing in self in that position bringing us to the conclusion that a.clamped(to: b...c) will return self unmodified when a is equal to the upper bound, equal to the lower bound or within range.

Source compatibility

This feature is purely additive; it has no effect on source compatibility.

Effect on ABI stability

This feature is purely additive; it has no effect on ABI stability.

Effect on API resilience

The proposed function would become part of the API but purely additive.

Alternatives considered

Clamping Exceptional Values

  • Calling preconditionFailure() when receiving exceptional values like .nan
  • return range.lowerBound or range.upperBound
5 Likes

@scanon Oh sorry I forgot to touch on the median free function. Whats your idea for the implementation of it? Another idea is that adding a median free function could be a tiny proposal all to it's self but can you give a code example of what you mentioned? I think it sounds really useful.

ClosedRange has an initializer from Range when the Bound has an SignedInteger stride. So can we add a method overload for Range. The initializer chokes with a precondition failure when the source range is empty; we can have that same condition for the new method.

For similar reasons, can we complete the method overload set with PartialRangeUpTo (when the bound has an integer stride too.)?

1 Like

Ok! So a few improvements and some great feedback. What should we do next to get this rolling forward?

1 Like

The proposal could be implemented with contains(_:), instead of min(_:_:) and max(_:_:). The end result should be the same (after inlining), and it becomes easier to describe in the detailed design.

extension Comparable {

  public func clamped(to limits: ClosedRange<Self>) -> Self {
    self
      .clamped(to: limits.lowerBound...)
      .clamped(to: ...limits.upperBound)
  }

  public func clamped(to limits: PartialRangeFrom<Self>) -> Self {
    limits.contains(self) ? self : limits.lowerBound
  }

  public func clamped(to limits: PartialRangeThrough<Self>) -> Self {
    limits.contains(self) ? self : limits.upperBound
  }
}

You could argue against another clamped(to:) taking a CountableRange, because ClosedRange has a suitable initializer.

42.clamped(to: ClosedRange(0..<100)) == 42
Int.min.clamped(to: ClosedRange(0..<100)) == 0
Int.max.clamped(to: ClosedRange(0..<100)) == 99

But the compiler doesn't suggest a fix-it for 42.clamped(to: 0..<100). And there isn't a similar workaround for PartialRangeUpTo.


The @Clamped(to:) property wrapper could be mentioned as a future direction.

2 Likes

I think clamped(to:) methods should also be added to Strideable.

extension Comparable {

  /// Returns `self` if it is contained within `limits`; otherwise,
  /// returns `limits.lowerBound` or `limits.upperBound`.
  ///
  ///     42.clamped(to: 0...100)       //-> 42
  ///     Int.min.clamped(to: 0...100)  //-> 0
  ///     Int.max.clamped(to: 0...100)  //-> 100
  ///
  /// - Parameter limits: The range with which to clamp `self`.
  @_alwaysEmitIntoClient
  @_transparent
  public func clamped(to limits: ClosedRange<Self>) -> Self {
    return self
      .clamped(to: limits.lowerBound...)
      .clamped(to: ...limits.upperBound)
  }

  /// Returns `self` if it is contained within `limits`; otherwise,
  /// returns `limits.lowerBound`.
  ///
  ///     42.clamped(to: 0...)       //-> 42
  ///     Int.min.clamped(to: 0...)  //-> 0
  ///     Int.max.clamped(to: 0...)  //-> Int.max
  ///
  /// - Parameter limits: The range with which to clamp `self`.
  @_alwaysEmitIntoClient
  @_transparent
  public func clamped(to limits: PartialRangeFrom<Self>) -> Self {
    limits.contains(self) ? self : limits.lowerBound
  }

  /// Returns `self` if it is contained within `limits`; otherwise,
  /// returns `limits.upperBound`.
  ///
  ///     42.clamped(to: ...100)       //-> 42
  ///     Int.min.clamped(to: ...100)  //-> Int.min
  ///     Int.max.clamped(to: ...100)  //-> 100
  ///
  /// - Parameter limits: The range with which to clamp `self`.
  @_alwaysEmitIntoClient
  @_transparent
  public func clamped(to limits: PartialRangeThrough<Self>) -> Self {
    limits.contains(self) ? self : limits.upperBound
  }
}
extension Strideable where Stride: SignedInteger {

  /// Returns `self` if it is contained within `limits`; otherwise,
  /// returns `limits.upperBound.advanced(by: -1)`.
  ///
  ///     42.clamped(to: ..<Int.min)   //-> Fatal error!
  ///     42.clamped(to: ..<100)       //-> 42
  ///     Int.min.clamped(to: ..<100)  //-> Int.min
  ///     Int.max.clamped(to: ..<100)  //-> 99
  ///
  /// - Parameter limits: The range with which to clamp `self`.
  @_alwaysEmitIntoClient
  @_transparent
  public func clamped(to limits: PartialRangeUpTo<Self>) -> Self {
    limits.contains(self) ? self : limits.upperBound.advanced(by: -1)
  }

  /// Returns `self` if it is contained within `limits`; otherwise,
  /// returns `limits.lowerBound` or `limits.upperBound.advanced(by: -1)`.
  ///
  ///     42.clamped(to: 0..<0)         //-> Fatal error!
  ///     42.clamped(to: 0..<100)       //-> 42
  ///     Int.min.clamped(to: 0..<100)  //-> 0
  ///     Int.max.clamped(to: 0..<100)  //-> 99
  ///
  /// - Parameter limits: The *non-empty* range with which to clamp `self`.
  @_alwaysEmitIntoClient
  @_transparent
  public func clamped(to limits: Range<Self>) -> Self {
    precondition(!limits.isEmpty, "The given range contains no elements")
    return self
      .clamped(to: limits.lowerBound...)
      .clamped(to: ..<limits.upperBound)
  }
}
4 Likes

@benrimmington I think Strideable might be a good candidate. Looking over the implementation now.

1 Like

I haven't been following this thread, but that last post is what I use. That last method is better handled with a conversion.

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

public extension Strideable where Stride: SignedInteger {
  func clamped(to limits: Range<Self>) -> Self {
    clamped(to: ClosedRange(limits))
  }
}

I've written some validation tests:

https://github.com/apple/swift/pull/34734

Do you think we could get this ready for the Swift 5.4 release?

1 Like