Revisiting SE-0177: Adding `clamped(to:)`

It has been a while since SE-0177 was sent back for revision (three years?) and it
might be worth it to start a new thread here about what to do with SE-0177.

At the moment I have one pull request for the Swift Evolution repository and another pull request adding clamped as an extension on Comparable to the standard library.

I would love to start this discussion with everyone about what to do next until SE-0177 is complete.

Thanks!

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

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

4 Likes

I’m a bit hesitant to add functions to every Comparable, because it’s quite a common protocol. For example, String is Comparable, and it makes sense to compare them; but does it make sense to clamp a String? I’m not sure that’s an incredibly useful operation, so I’m not sure it’s worth adding this to every Comparable type.

Did you consider a clamp free function?

EDIT: I didn’t read the feedback from the last round of review, but I would expect some summary and rationale of any controversial design aspects to be included in the proposal’s “alternatives considered” section. Otherwise, since it says no alternatives were considered, I’m literally assuming you didn’t consider a free function.

1 Like

Actually I am not against adding a free function at all, max and min work much like this would so a free function is very suitable I think.

The reason why I didn't take that path is that in 2017 some members of the core team suggested not increasing the number of global functions thus the current proposal.

How does everyone feel about clamp as a free function? If there is enough support, could just rewrite everything as a free function version of clamp.

I prefer the extension. It reads more like natural language to me. IMO It fits more with the existing APIs. It doesn't "pollute the global namespace," as they say. I've been using the extension on Comparable in my code for a few years now. I suspect a lot of people have been doing so.

let x = y.clamped(to: z)
let x = clamp(y, to: z)
11 Likes

Speaking of alternatives, and not because I'm particularly fond of this:

There are the existing .init(clamping:) initializers that clamp some value of a type with larger range to the range of the type being initialized, eg:

let someByte = UInt8(clamping: someInt32OrSomething)

Along similar lines, the proposed method(s) could be on ClosedRange, PartialRangeThrough and PartialRangeFrom (instead of on Comparable):

let a = (b...c).clamping(d)
let a = (b...).clamping(d)
let a = (...c).clamping(d)

I do think it feels a bit backwards compared to a = d.clamped(to: b...c) but it could still be among the considered alternatives I guess, and it raises the question of whether the "clampee", limits and result should all be the same type or if the type of the limits and result should be a type parameter, to allow clamping eg an Int32 value to an Int8 value within -10...10.

3 Likes

Yeah extension seems a bit more natural.
What would really be the downsides of adding it onto Comparable?
I don't think it would really pollute the namespace.

1 Like

Maybe we still need to smooth out a few edges but is there still interest in this? I feel like entering back into the review process would stimulate the conversation again and the community could decide the fate of this proposal. Hope everyone is safe and doing well.

2 Likes

I would like to see it move forward.

1 Like

I've created a preview package with an alternative design:

  • The primary API is a generic clamped(to:) method on Comparable.
  • The secondary API is a new RangeClamping protocol.
  • There are (conditional) conformances for the existing range types.
  • The tests use an internal @Clamped(to:) property wrapper.

This addresses the Core Team feedback where "a generic solution is preferred if possible".

I've since discovered that similar designs have already been suggested (in 2017 and 2018).

The protocol requirement has an in-out parameter, which I'm having second thoughts about.

4 Likes

This seems fine to me. I understand the concerns about readability and the more natural expressiveness of an extension.

On the other hand it seems to me a free function has a number of advantages:

  • It follows a precedent that has already been set already with min(_:_:) and max(_:_:) in regards to free functions for this kind of operation. (IIRC there are also no min/max extension equivalents, so you'd expect some symmetry here.)
  • A free function has the advantage of not polluting the types where it doesn't immediately make sense (String), whilst not blocking developers from using it entirely.
  • As most developers are familiar with the max(lowerBound, min(upperBound, x)) dance, a free function is an obvious drop in replacement.
  • Because of this, a free function also has the advantage of doing exactly what the developer expects and prevents the need for designing edge cases for specific types.
2 Likes

This would be a false symmetry, since a primary reason that min and max are free functions is that the two arguments are peers (along with the difficulty of finding a sensible name).

2 Likes

I'm not sure I fully understand your point. Do you mean because you're expecting a clamp function to accept a RangeExpression?

I'm not sure that's necessary as you quickly come up against cases where using a range expression to clamp something doesn't make sense anyway.

I would expect any clamp function to have three arguments (bypassing the various Range types completely) and be a drop-in for max(lowerBound, min(upperBound, x)).

Something like clamp(_:min:max:).

To me, that would fit alongside min(_:_:) and max(_:_:) quite neatly.

I mean that a and b in min(a, b) are peers, neither is more primary than the other, so it doesn't make a lot of sense to spell this a.min(b) or b.min(a), and I can't think of an alternative name that isn't convoluted. This is the same reason why zip is a free function. On the other hand, the clamping operation makes perfect sense as a method on the variable you are clamping (hence why you've left the first argument unlabelled and labelled the other two). Just because one way of implementing clamping composes the min and max functions doesn't mean that it should be spelt like them.

2 Likes

Ahh! I understand your point, but I don't agree.

The primary reason I would avoid adding min/max to Comparable would be the pollution of the API for types where it doesn't make sense (such as String) rather than the naming problem.

Well, this is the crux of it. It depends on the variable. It doesn't make much sense on a String.

I'm not particularly concerned with the String thing. There's no rule that the only extension methods that should be added to protocols are ones that you can think of a use for on every single type that conforms to the protocol, because that would be an impossible bar to clear. If the method is useful on some of those types and can be defined and implemented in terms of the protocol requirements then I have no problem with it not being particularly useful in a few instances.

Edit: This is really a side point anyway, because a free function that is generic over Comparable is going to be exactly as useless for String.

1 Like
  • The original discussion also had support for a median free function.

  • Could the new protocol be improved, to become the primary API?
    (i.e. The generic clamped(to:) method would be removed completely,
    but a generic @Clamped(to:) property wrapper could be proposed later.)

  • Should the clamped(to:) method be on Strideable instead?
    (Foundation.Date has all the requirements of Strideable,
    but it doesn't conform yet: FB9452917.)

I've prepared an implementation of the median free function:

/// Returns the "middle" of three values, when sorted in ascending order.
///
/// For example, the median of the first three prime numbers is:
///
///     median(2, 3, 5)  //-> 3
///     median(2, 5, 3)  //-> 3
///     median(3, 2, 5)  //-> 3
///     median(3, 5, 2)  //-> 3
///     median(5, 2, 3)  //-> 3
///     median(5, 3, 2)  //-> 3
///
/// Uses a stable sorting algorithm, which preserves the relative order
/// of arguments that compare equal. For example, `median(x, y, z)` is
/// always `y` clamped to `x...z`:
///
///     median(+0.0, -0.0,       +0.0)  //-> -0.0
///     median(-0.0, +0.0,       -0.0)  //-> +0.0
///     median(-0.0, -.infinity, +0.0)  //-> -0.0
///     median(+0.0, -.infinity, -0.0)  //-> +0.0
///     median(-0.0, +.infinity, +0.0)  //-> +0.0
///     median(+0.0, +.infinity, -0.0)  //-> -0.0
///
/// - Parameters:
///   - x: A value to compare.
///   - y: Another value to compare.
///   - z: A third value to compare.
public func median<T: Comparable>(_ x: T, _ y: T, _ z: T) -> T {
  var (x, y, z) = (x, y, z)
  // Compare (and swap) each pair of adjacent variables.
  if x > y {
    (x, y) = (y, x)
  }
  if y > z {
    (y, z) = (z, y)
    if x > y {
      (x, y) = (y, x)
    }
  }
  // Now `x` has the least value, and `z` has the greatest value.
  return y
}

@kylemacomber @scanon

  • Should median be part of this proposal, or a separate mini-proposal?
  • Should it be overloaded to also accept a variable number of arguments?

I'm not sure that Comparable is the perfect place to put it, but a free function is not as nice at point of use.

I'd much rather have:

    let result = (/* some calculation */).clamped(0, 1)

than:

    let result = /* some calcuation */
    let clampedResult = clamp(result, 0, 1)

All that said, the free function would also be welcome.

I assume the downside to just adding it to Comparable is one of processing time during compilation? Or is there another aspect I'm not considering?

Thanks!

Does the following make sense to you?

"dog".clamped(to: "bat"..<"cat")
"Sarah".clamped(to: "Betty"..<"Hannah")

Or with an Array/Data/URL/etc in place of String.

I don't think that stuff makes a whole lot of sense -- there are plenty of Comparable types which it does not make sense to clamp, and many of them have large and complex APIs of their own. Putting the clamped function on every Comparable would lead to some bizarre API clutter.

2 Likes