So here is the current pull request I have to the Swift repository:
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().
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, thena.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).
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
andmax
. - 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).
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!
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.)
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
@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
- Proposal: SE-0177
- Author: Nicholas Maccharoli
- Review Manager: TBD
- Status: Returned for revision
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
@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.)?
Ok! So a few improvements and some great feedback. What should we do next to get this rolling forward?
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.
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)
}
}
@benrimmington I think Strideable
might be a good candidate. Looking over the implementation now.
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?