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

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

I think this is totally reasonable.

String's Comparable conformance doesn't seem particularly useful to me in this context -- but "Sarah" < "Betty" is a valid use of String's existing Comparable conformance. "Sarah".clamped(to: "Betty"..<"Hannah") is a natural extension/composition of the existing capabilities.

It may be debatable whether or not String should be Comparable -- but "this API isn't strictly useful on String" probably shouldn't be a reason to avoid adding additional functionality to the Comparable protocol.

9 Likes

Exactly, this should not be the criterion by which an extension method on a protocol should be judged. i.e.

String conforms to Comparable, so you can easily understand the behaviour of clamped, even if you think that behaviour is not particularly useful. For the same reason, we don't regularly refuse to add new methods to Collection even though they may not be particularly useful on Set or Dictionary.

4 Likes

You can compose the operations to define how clamping works, but IMO the number of types which it is useful to clamp are a small fraction of the types which conform to Comparable. It is an especially important protocol and we must be careful if we add APIs to the types which conform to it.

It isn't just String, it's also Array<String>, Data, URL, etc. I don't think it really is debatable that those types should be Comparable, but I think .clamped would be an unwelcome addition for most of them. That favours an alternate spelling IMO.

1 Like

I'm not sure “small fraction” is fair given that there are a lot of numeric types, just to start with. And the potential harm here is very limited. You haven't made it clear if by “alternate spelling” you mean a free function with arguments constrained to Comparable (equally useless/useful, just perhaps less discoverable depending on your current IDE), a new marker protocol derived from Comparable that only a subset of types conform to, or just defining clamp directly on a subset of types/protocols. I personally don't like any of those alternatives.

2 Likes