Opaque result types

Hi Douglas, first of all I'd like to thank you for this really interesting pitch. I read your proposal and the comments before my post and here is my feedback. I really like the vision of opaque types presented in the proposal, especially the part which adds more flexibility to work with generics. However I have also a few concerns - most syntax-wise though.

To begin with I noticed that this feature overlaps with some parts of the existential world quite heavily, especially when there is a where clause applied. From my perspective this whole idea can be treated as a constraint on existentials in general. The only thing that I could observe in your proposal is that an opaque type is constrained to be of the same type and the dynamic type is not exposed anymore, compared to an existential. Since I'm no compiler developer, I'm in no position to judge the type system, but this is my vision as a daily Swift developer.

// Assuming that `P` and `R` are existentials with possible 
// associated types iff any of them is a protocol or a class

// The following function can return any type that conforms
// to the required protocol or is a subclass of a required
// superclass constraint
func foo() -> P
func bar() -> P & Q

// In the future we should be able to further constrain
// existentials with a `where` clause, which also does 
// not prevent us from combining them even further.
typealias MyP = P where AssociatedType == Int
typealias MyPQ = MyP & Q

// Now I see the `opaque` keyword as a possible constraint
// on existentials.
typealias OpaqueMyP = opaque MyP

// These functions can only return the same type that satisfies
// the requirement.
func baz() -> opaque P
func buz() -> OpaqueMyP
func bum() -> opaque MyPQ

Furthermore I'm not a fan of the proposed _.AssociatedType syntax nor do I like the dozens of where clauses as shown in the original post. Instead I would like you to consider a slightly different approach. David (cc @hartbit) and I were talking about the where clause on existentials, considering the previous work from Austin Zheng, and came to the conclusion that when introducing this feature it should be restricted to be declared on a typealias only. In short: If you want to create an existential that is constrained with a where clause you have use a typealias for that matter. This removes a little of the flexibility but we think that the tradeoff is worth it because:

  • it makes you reason more about your type names
  • it reduces possible ambiguity of multiple where clauses in a single delcaration
  • it forces the developer to a re-usable approach
  • it removes the need of prefix dot .AssociatedType or even the _.AssociatedType of this proposal (which is consistent to protocol MySequence : Sequence where Element == Int { ... } syntax)
  • var something: GoodTypeName is preferred over var something: A & B & C where AssociatedType == SomethingElse

In that sense I think we should force the declaration of a constrained existential and an opaque type into the typealias.

// Instead of this:
var strings: opaque MutableCollection where _.Element == String = ["hello", "world"]

// We would have this
typealias OpaqueMutableStringCollection = opaque MutableCollection where Element == String
var strings: OpaqueMutableStringCollection = ["hello", "world"]

I mentioned before that this pitch feels to me like a constraint over existentials, which raises another question: Why can we introduce opaque types with a where clause before we even have existentials with a where clause?

I have re-written a few of your examples by using a few other missing features to see how I'd prefer the syntax to look like visually and declaratively compared to the original pitch.

extension BidirectionalCollection {
  // In this example the returned type cannot be a `RandomAccessCollection`
  public func reversed() -> OpaqueBidirectionalCollection<Element> { ... }
}

// This existential would potentially eliminate the current struct in the sdlib
typealias AnyBidirectionalCollection<T> = BidirectionalCollection where Element == T

// Creating a new `Opaque*` family of types similar to `Any*` family
typealias OpaqueBidirectionalCollection<T> = opaque AnyBidirectionalCollection<T>

// Missing feature: extending existentials
extension AnyBidirectionalCollection where Element == String {
  public func joined(separator: String) -> String
}

In case of ambiguity of the where clause I had a discussion a while ago where I pitched a different keyword for constraining conditionally a type directly from the declaration.

extension BidirectionalCollection {
  public func reversed() -> OpaqueBidirectionalCollection<Element>
    constraints 
    OpaqueBidirectionalCollection : RandomAccessCollection where Self : RandomAccessCollection,
    OpaqueBidirectionalCollection : MutableCollection where Self : MutableCollection {
    return ReversedCollection<Self>(...)
  }
}

// If there are no other generic parameters to constrain,
//  we could omit the reference to the type and just write
extension BidirectionalCollection {
  public func reversed() -> OpaqueBidirectionalCollection<Element>
    constraints 
    RandomAccessCollection where Self : RandomAccessCollection,
    MutableCollection where Self : MutableCollection {
    return ReversedCollection<Self>(...)
  }
}

One thing that we have to consider is that we lose the ability to conditionally conform the currently exposed types or the returned opaque type to custom protocols. There is no way to inject any further constraints into the above reverse function. It would be a major breaking change and removing some of the current flexibilities until we can extend opaque types ourselves.

extension ReversedCollection : MyProtocol where C : MyProtocol { ... }

// This is a required alternative that must exist!
extension opaque BidirectionalCollection : MyProtocol where C : MyProtocol { ... }

How about something like this?

extension Int : P {}
struct T : R {
  // Uses the same extra keyword `constraints` to avoid ambiguity 
  // with possible `where` clause
  typealias OpaqueP = opaque P constraints OpaqueP == Int
  func someValue() -> OpaqueP { ... }
  func someOtherValue() -> OpaqueP { ... }
}

To sum up I would like to see the evolution in the following order:

  1. where clause for existentials (and opaque types later) allowed only on a typealias
  2. adding opaque types as a constraint over existentials
  3. adding a constraints keyword (or similar) to not create confusion or ambiguity with the where clause
  4. allow extending existentials and opaque types

There are still some questions left:

  • Why is is it not possible to have opaque constants? A result of an query function that returns an opaque type does not need to be always mutable, what do I miss here?

  • Can we combine multiple opaque types?

typealias OpaqueP = opaque P
typealias OpaqueQ = opaque Q

// Is this possible?
typealias OpaquePQ = OpaqueP & OpaqueQ // means `opaque P & Q`
4 Likes