Additional type constraint for the `where` clause

In the "SE-0235 - Add Result to the Standard Library" review process @Slava_Pestov pitched an exclusive exception for the behavior of protocols in case of the empty Error protocol. While it might solve the general limitations for Error, it does not for any other protocol that has no associated types.

Moreover I'm concerned that it might create more harm than good in the long run. This change must be carefully crafted and tested before including it into the language. Besides that, such behavior for a protocol becomes very special when you want to create custom constraints in regard to the Error protocol. Should we use conformance-requirement or same-type-requirement? The latter is obviously confusing, at least it is confusing to me.

I would like to pitch a different approach that comes to my mind which is trying to solve the more general problem.

  • Add a new type constraint operator :== (or maybe :=)
  • Extend the grammar of a generic parameter clause:
    • generic-where-clause -> where requirement-list
    • requirement-list -> requirement | requirement , requirement-list
    • requirement -> conformance-requirement | same-type-requirement | conformance-or-same-type-requirement
    • conformance-requirement -> type-identifier : type-identifier
    • conformance-requirement -> type-identifier : protocol-composition-type
    • same-type-requirement -> type-identifier == type
    • conformance-or-same-type-requirement -> type-identifier :== type-identifier
    • conformance-or-same-type-requirement -> type-identifier :== protocol-composition-type
    • conformance-or-same-type-requirement -> type-identifier :== type

The new constraint will allow to describe that one would like to either have a concrete existential / superclass, a concrete type that conforms to the existential / protocol or is a subclass of a specific superclass (in cases of classes the : operator already allows the pitched behavior).

In case of Result we could limit the generic Error parameter to be either Swift.Error or a concrete type that conforms to that protocol.

public enum Result<Value, Error> where Error :== Swift.Error { ... }

In other cases where people struggled with that limitation and were forced to create overloads for the same function to have the same type requirement to an existential can be merged into single generic representation by using the new :== operator.

Feedback is very much welcome.

Doesn't this fall under the umbrella of "disjunctions in constraints are bad"?

Why do think it would be?

This pitch really isn't the same as Int | String, obviously. All I'm trying to solve with this is to express a constraint that allows you to say I want the generic type to be either of type A or it should be a subtype of type A. Then the compiler is always free to treat the type as A.

For functions we can create overloads to workaround that issue, but for type declarations there is simply no way to do so which leaves as like in a corner of unconstrained types like the proposed generic Error type of Result which in nearly any case should be either Swift.Error or a type conforming to Swift.Error. The pitched functionality would generalize that and allow more flexibility on type declaration and potentially fold quite a lot of functions.

I cannot tell for sure, but wouldn't this even simplify the bridging to Objective-C? Since the error is always correlated to Swift.Error it can simply transform the error to Swift.Error in case of Result<T, Error : Swift.Error>.

Besides that we don't have to introduce hacks for weirdly self conforming protocols, and I also think this would play really well with your generalized supertype constraint.

or is a disjunction and you are putting it in a constraint. The core team has spoken against disjunctions in constraints, not just in types (i.e. unions).

Isn't the plan to eventually lift the limitation of self conformance in some way? I view the discussion of doing that for Error as jumping ahead, similar to AnyHashable jumping ahead of generalized existentials. I don't view it as inconsistent with the direction of the language.

As I mentioned in my original post, this type of constraint already exists for classes (obviously because they're subtypes of themselves, but the point is that B with B : A constraint can be A with the : operator for classes). :== would be the same as : for classes but also lift the limitations of expressibility for existentials and protocols.

Well I might be short-sighted, because I cannot even imagine how they will eventually lift that restriction. It starts to hurt my brain if I think about self conforming protocols with associated types or inits.

That said, it's a pitch, so we can see where it goes. If it gets rejected, so be it. ;)

I think what you are proposing is substantially different than superclass constraints but I'll let those more knowledgable than I am speak to it. :slight_smile:

By "in some way" I meant in some contexts. It isn't possible to lift the limitation across the board. I think generalized existentials will help in some contexts.

1 Like

I'll note that unlike general disjunctions this one is probably more well-behaved, and not too difficult to implement. But I think it's dodging the real problem, which is that Error-the-protocol-value-type doesn't conform to Error-the-protocol (see SE-55). If and when we are able to make that work—which is similar to generalized existentials—then we should be able to use a normal : constraint here without modification.

6 Likes

I tried to understand the issue-ticket, but I failed to see your point in it. I learned that protocols do not conform to themselves, nor do (composed) existentials which is totally fine by me to this date. However it does not mean I don't know the limitations it creates, I've been there myself. Can you clarify for me what you meant by Error-the-protocol-value-type?

Here is a simple example:

protocol P { func bar() }

// Before (2 functions):
func foo<T>(_ value: T) -> T where T : P {
  value.bar()
  return value
}
func foo(_ value: P) -> P { ... }

// After:
func foo<T>(_ value: T) -> T where T :== P {
  value.bar()
  return value
}

struct S : P { 
  func bar() {
    print(type(of: self))
  }
}
let s = S()
let p: P = s
foo(s) // prints "S" returns `S`
foo(p) // prints "S" returns `P`

I just think trying to find a solution to that is a better long-term answer to this problem, rather than a special kind of generic to work around it. The work to implement this special case seems like it'd be nearly the same as the general thing.

That said, if we end up not doing self-conforming protocols (because they have problems too, like how init requirements should work), this or something else might be necessary to say "no, it's okay, I promise to only use this like an existential even though I want to be able to preserve the type when I have it".

I was trying very hard not to say "existential". The distinction between the Error in let x: Error and the error in enum Foo: Error.

If I got your point now correctly, then basically you meant to say this approach or maybe another can be considered as an alternative if self conforming protocols / existentials will fail or impossible to achieve?!

That is totally fine by me, because I understand it. :)

Thank you for your feedback.

1 Like

I think it's simpler because the representational issue is not there. Error is a single retainable pointer, just like @objc protocol existentials, so it can self-conform.

It's simpler for Error but how far does that generalize? Aren't we talking about a general feature here and not something specific to Error?

8 years into the marvellous innovation of Protocol Oriented Programming and protocols still do not conform to self .. I know .. pride and all .. but you have to be able to laugh about it sometimes, no?!? :slightly_smiling_face: for the 10y anniversary, perhaps? :slightly_smiling_face:
(self irony is healthy)

1 Like