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

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

Yeah I think that if possible we should avoid min max style free functions but adding clamped(to:) to all types that conform to Comparable seems a bit extreme.

Yes, limiting clamped(to:) to only being able to be used on types conforming to Comparable makes sense but... IDK maybe its ok but it seems like there is a better option somewhere.

Considering the continuing discussion about Comparable types for which clamp is less sensible, I think putting the primary API on the range types is better, because it seems to me that ranges are more related to clamping than the comparable values directly.
However, as a bit of bike-shedding, I think the verb form (b...c).clamp(d) may be preferable to the gerund form (b...c).clamping(d)

Hi folks, would love to see this feature but agreed on the drawbacks of the aforementioned designs. Here's one option that I don't believe has been mentioned, create a new protocol specifically for "clampable" types that inherits from Comparable. The name needs work but here's an example:

protocol Clampable: Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self
}

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

From there we can extend existing numerical types to opt-in to this functionality without affecting strings or other comparable values where it doesn't make sense.

extension Int: Clampable { }

Adding a new protocol can make backwards-deployment tricky, but even then Swift doesn’t usually have single-operation protocols if there’s some underlying structure. So what does make Int and Float and Bignum different from String and Array and Data? The main thing I can think of is ā€œlengthā€, but I’m not convinced that’s the fundamental thing we’re looking for.

Other languages don’t seem to separate clamped from Comparable.

9 Likes

Thanks! Thinking more about it, since clamping is really a simple composition of min and max it seems natural to allow clamping on any type that is allowed by min and max, so Comparable.

From a previous example:

"Sarah".clamped(to: "Betty"..<"Hannah")

Agreed this doesn't seem to be useful, at least not any more useful than:

max("Betty", "Sarah")

but we do allow usage of max on any Comparable type today so perhaps we're ok with applying the same thinking to clamped.

8 Likes

Are we maybe simply reaching for Strideable—that is, those types "representing continuous, one-dimensional values that can be offset and measured"?

That's a good point, I forgot floating-point types conform to Strideable. Let's look at types that conform to Comparable but not Strideable to see if they make sense to clamp:

  • String - as discussed before
  • Array and Foundation.Data - not actually Comparable!
  • Character - maybe reasonable to clamp? within certain ranges?
  • DispatchTime, DispatchWallTime - these do make sense to clamp, and can't comform to Strideable because their stride, DispatchTimeInterval, doesn't conform to Numeric. Maybe it could, though?
  • Foundation.Date - does make sense to clamp (it's a timestamp), could be Strideable with a stride of Foundation.TimeInterval
  • Foundation.DateInterval - this is a range, which definitely doesn't make sense to clamp, at least not using clamped, but can still be reasonably ordered
  • Foundation.Decimal, Foundation.Measurement - can be clamped, should be Strideable
  • Subscribers.Demand - does make sense to clamp, could be Strideable with Self as the stride
  • SwiftUI.DynamicTypeSize - does make sense to clamp, definitely isn't Strideable
  • TaskPriority - does make sense to clamp, arguably isn't Strideable
  • All sorts of Collection indexes - clamping isn't sufficient to validate them, so I guess not

So Strideable is close, but doesn't cover all the use cases (particularly ordered enums), and we'd have to decide if that's okay. It's still useful to observe that Strideable is sufficient, i.e. there are no Strideable types that should not be clampable.

6 Likes

I don’t understand the pushback against Comparable.

The operation of clamping a value within some bounds is inherently in the domain of Comparable.

We compare the value to the bounds, and choose the result accordingly.

• • •

Furthermore, I also do not understand the pushback against the spelling value.clamped(to: bounds).

To me, that is the clearest and most obvious spelling, which reads the best at call-sites.

There is no other possible meaning that value.clamped(to: bounds) could plausibly have.

23 Likes

My reservations are due to there being lots of types which conform to Comparable so they can be sorted, but which don't necessarily want a .clamped function as part of their API. If there was a separate proposal for String.clamped, would it be accepted? I think not. So why accept this member because it entered String's API through another path?

It isn't that Comparable is the wrong constraint (I think it's the correct constraint), or that the operation is ambiguous. There is an argument that a Collection may want to clamp its contents (like numpy's .clip), and that this more useful operation could be confused with .clamped if you can't follow the types of the arguments. I think that argument is a bit weak but not not entirely meritless.

Still, protocols add members to their conforming types. That means that when we add members to a protocol, we need to consider carefully what the impact will be on all conforming types, and this is especially true of basic protocols like Comparable, Equatable, Hashable, etc.

My preference is for a clamp free function. I don't think it will limit discoverability - after all, people don't seem to have a problem with existing free functions like min, max, zip, etc., I don't think it is any less obvious than the member function, and it avoids having every Comparable type inheriting a possibly-unwelcome new member. So I see one benefit and no harm by making this a free function.

Wow, this seems like an oversight. We even have an accepted proposal for tuples to conform to Comparable.

1 Like

I think it’s correct for Data to not be Comparable; I can’t think of a need to order or sort it, ever. Array could probably be lexicographic like tuples, though.

The free functions min and max are mathematical functions that most people learn with free syntax in school. I don’t think clamp is the same.

2 Likes

@jrose So I guess it makes sense for clamped(to:) to have it's home inside of Strideable if its better to undershoot rather than overshoot by placing the method in Comparable where we will get some types where having a Clamped(to:) is not very useful.

Maybe if we go with Strideable we should also do an audit of all types that don't currently conform to Strideable but maybe should.

String.clamped would probably not be accepted, simply because there's not a sufficiently compelling use case to add it specifically to String. But it's not harmful for it to be there. .clamped() can be implemented just with Comparable-ness, so I don't see a reason why we should arbitrarily restrict it to Strideable.

More importantly, in a generic context, I think Comparable is the right protocol. It would feel natural for a SemanticVersionNumber to be Comparable, for example, and it would also make sense for it to have a .clamped() function. But it doesn't make sense for it to be Strideable, and it seems silly to add a separate protocol just to exclude this function from appearing on String even though it would work just fine on strings.

12 Likes

I think putting it on Comparable is fine. Not ideal but fine — and not worth making a separate protocol just for it. The fact that max and min work on String kinda seals the deal, at least in my view.

7 Likes

So having a bunch of non-compelling APIs on String (and other Comparable types) is not harmful?

I think the point has been made that clamped doesn't make sense for every Comparable type, but the argument seems to be: "we don't care".

It's not an unreasonable perspective (really, it isn't), but I'd like to be explicit about it - we're saying that we don't mind if a bunch of types gain an API that people will never use, because the member function is so much better than the alternative spelling? If that's where we as a community think the best balance lies, then so be it, but IMO we should be thinking about it in terms of that balance.

I agree. The very next sentence after the one you quoted says as much.

2 Likes

Yes, I think explicitly that the member spelling is much better than the free function spelling, and strongly prefer it. I think the downside of having additional API also available on String is minimal, so that tradeoff seems fine to me.

6 Likes

This echoes my thoughts exactly. The ability to use clamped in a chain of methods, possibly together with ?, is one of the reasons it is so useful. The free function is both less discoverable and less readable.

Compare, e.g.

maybeNumber?.clamped(to: 0..<1)

with

maybeNumber.map { clamp($0, to: 0..<1) }
5 Likes