Unlock Existential Types for All Protocols

Opaque types.


Swift already provides path-dependent types when working with opaque types. This works today:

protocol P {
  associatedtype B
  var b: B { get set }
  func takesB(_: B)
}

struct S1: P {
    typealias B = Double
    var b = 5.3
    func takesB(_: B) {}
}
struct S2: P {
    typealias B = String
    var b = "swift"
    func takesB(_: B) {}
}

var p1: some P = S1()
var p2: some P = S2()

let b1 = p1.b
let b2 = p2.b
p1.takesB(b1) // ok
p2.takesB(b1) // error
p2.takesB(b2) // ok
p1.takesB(b2) // error

var p1too = p1 // 'p1too' has the same type of 'p1'
p1.takesB(p1too.b) // ok
p1too.takesB(p1.b) // ok

What we are missing are:

  • a way to refer to p1's concrete type explicitly;
  • some type level expressions to be used in typealias declarations (instead of value level expressions) such as concreteType(of: instance) to get the concrete type of an instance or concreteType(commonTo: instance1, instance2) to get the least common concrete super type of a list of instances.

The underlying structure is partially already there because of the behavior of opaque types. Some syntaxes have already been proposed for opening existentials (Generics Manifesto: Opening existentials):

// from Generics Manifesto
if let p1asT = p1 openas T {
  // you can use T as a typealias in this scope
}

// from Alejandro in this thread
let <T: P> p1: T = S1()

// with type level functions
let p1: some P = S1()
typealias T = concreteType(of: p1)
3 Likes

Yeah, that’s what I meant by “work.” Okay, that could potentially be designed. Another monster in the box named…

I'm using any _ to refer to the existential protocol type for simplicity. If both any P0 and any P1 do not conform to P0 and P1 respectively, using opaque types in methods and properties having Self or associated types involved in them (SoAT) will let you use those protocols as existential types and will let you access all the non-SoAT properties and methods like you are already do with non-PATs. With opaque types you can even access SoAT properties and methods, but with their types being hidden.

protocol P1 {
  associatedtype One
  var one: One { get set }
}
protocol P0 {
  associatedtype A: P1
  var a: A { get set }
}

var pzeroes: [any P0] = [pzero1, pzero2, pzero3]
var x = pzeroes[0]  // x: any P0
var xa = x.a        // xa: (any P0).A,        i.e. (some P0).A
var xaone = xa.one  // xaone: (any P0).A.One, i.e. (some P0).A.One

var xas = pzeroes.map(\.a)  // xas: [any P1]
pzeroes[0].a = xas[0]  // error: instance of type 'any P1' cannot be
                       // assigned to an instance of type '(some P0).A'
xas[0] = pzeroes[0].a  // valid: instance of type '(some P0).A' is 'any P1'

We have:

(any P0).A.self == (some P0).A.self  // because .A is path-dependent
(some P0).A.self == some P1.self     // because .A conforms to P1

pzeroes[0].a = xas[0]  // invalid, 'any P1' unassignable to 'some P1'
xas[0] = pzeroes[0].a  // valid, 'some P1' assignable to 'any P1'

However, if you explicitly conform the existential type any P0 to P0:

// for simplicity, in existential extensions, `self` refers to
// the concrete instance, `Self` refers to its concrete type
// thus here we have: self: some P1, Self = some P1
extension any P1: P1 {
  typealias One = Any
  var one: One {
    get { self.one }
    set { self = newValue }
  }
}
extension any P0: P0 {
  typealias A = any P1
  var a: A {
    get { self.a }
    set { self = newValue }
  }
}

// now we can use '(any P0).A' which is 'any P1'
var pzeroes: [any P0] = [pzero1, pzero2, pzero3]
var x = pzeroes[0]  // x: any P0
var xa = x.a        // xa: (any P0).A,        i.e. any P1
var xaone = xa.one  // xaone: (any P0).A.One, i.e. Any

var xas = pzeroes.map(\.a)  // xas: [any P1]
pzeroes[0].a = xas[0]       // valid: same type assignment
xas[0] = pzeroes[0].a       // valid: same type assignment

We have:

(any P0).A.self == (any P1).self  // .A is not path-dependent now

pzeroes[0].a = xas[0]  // valid, 'any P1' assignable to 'any P1'
xas[0] = pzeroes[0].a  // valid, 'any P1' assignable to 'any P1'
A concrete example: any Hashable
extension any Hashable: Hashable {
  var hashValue: Int { self.hashValue }

  func hash(into hasher: inout Hasher) {
    self.hash(into: &hasher)
  }
}

extension any Hashable: Equatable {
  static func == (lhs: any Hashable, rhs: any Hashable) -> Bool {
    // least common concrete super type
    typealias T = concreteType(commonTo: lhs, rhs)

    guard T.self is Equatable.Type else { return false }
    return lhs as! T == rhs as! T
  }
}

I don't understand what you mean by this. The underlying type of an opaque type is ultimately known statically to the compiler because it works like a generic parameter. This is not the case for an existential type, and that is indeed much of the point. How could the compiler statically know the type of something that's not known, by construction?

I really only meant separating the notions of the protocol and the type would make that possible to explain, but yes, you still got me in a way, so I would like to take this opportunity to ask: why can an existential not simply conform abstractly to its protocol like an archetype does? Is the following a reason for that?

func firstIndex<C: Collection>(c: C) -> C.Index { c.startIndex }

var c: Any<Collection where .Element == Int> = ...

let idx = firstIndex(c: c) // What's the type of idx?

Explained here in the thread referenced previously. [Edit:] and I'm not trying to “get you.”

The only interesting part of Hashable when it comes to existentials is the Equatable conformance, and as I pointed out in the Protocol Oriented Programming talk, that's a special case for which we already have an answer. If you want to prove something about how a feature like path dependent types will allow us to do new things, I suggest picking a case that we don't know how to solve today.

I was only referring to the behavior of (any P0).A. In my opinion it should behave like an opaque type: you can get x.a of type concreteType(of: x).A and, since A conforms to P1, you don't know anything more than the fact that it will be "some P1" at runtime, i.e. a concrete unknown type conforming to P1.

This proposal has been scheduled as SE-0309, and the review will run from April 17 through May 1.

15 Likes

Glad to hear it!

…what’s the etiquette for non-authors to fix typos (misspelled words and/or grammatical errors) in a proposal document?

I suggest submitting a PR directly into the proposal to fix typos etc. in other threads people did way too many single posts to point out single typos which added noise to the review :)

5 Likes

All right, done.

There’s also a line in the proposal which reads, “Tuple types in either of their element types” where I’m pretty sure that “either” should be “any”, but it’s not per se a typo so I didn’t include it in my PR so as not to overstep.

2 Likes

Hi, isn't the review thread coming today?

Yes, the review thread is now open.

3 Likes

Asking here because I just don’t understand and don’t want to muddy the review thread. In the AnyFoo example:

struct AnyFoo<Bar>: Foo {
  private var _value: Foo

  init<F: Foo>(_ value: F) where F.Bar == Bar {
    self._value = value
  }
  
  func foo(_ bar: Bar) -> Bar {
    return self._value._fooThunk(bar)
  }
}

Why can’t _value have its Bar constrained? Like:

private var _value: Foo Where _value.Bar == Bar

Is that just a missing feature that can be added later? Or is there a technical reason that can’t happen?

Thanks!

I think your request relates to the following future direction mentioned at the end of the proposal:

3 Likes

Thank you, I'll fix that up.

P.S. I believe everyone should feel free to submit these PRs (and request a review if necessary).

2 Likes

Today it is not possible to cast to an existential when the protocol of that existential type has a Self or associated type requirement.

protocol A {
  associatedtype A
}

object as? A // error: Protocol 'A' can only be used as a generic constraint because it has Self or associated type requirements

If this proposal is adopted and integrated into this language, would the above cast compile?

My understanding is that the answer is "yes" but I haven't been able to find explicit confirmation in the proposal or in the discussion on the pitch posts.

2 Likes

Yes. as? expects a type on the right-hand side, and this change will allow A to be used as a type.

10 Likes

Let me provide some examples from the projects I was working on:

  1. AnyEncodable
extension URLRequest {
  mutating func encoded(encodable: Encodable, encoder: JSONEncoder = JSONEncoder()) throws -> URLRequest {
    httpBody = try encoder.encode(encodable) // Error: Protocol 'Encodable' as a type cannot conform to the protocol itself
  }
}

Here we need AnyEncodable type erasure struct. It will be good if existentials become self conforming, as Error protocol do.

  1. AnalyticsSender
public protocol StatisticsSender: AnyObject {
  func sendEvent(_ event: StatisticsEvent)

  ... other functions and properties
}

public protocol StatisticsSDKSender: StatisticsSender {
  // Different SDKs use different types as value Type of event payload: [String: NSObject] / [String: Any] / [String: String]. So NativeValue becomes NSObject, Any or String in implementation.
  associatedtype NativeValue

  func stringRepresentation(ofEventName eventName: StatisticsEventName) -> String?
  ... other functions and properties
}

Currently we have to split protocol in two different, because we can not create Array<StatisticsSDKSender>.
Another option is to create type erasure struct. Honestly, I like none of them. Array<StatisticsSDKSender> would be great. What we need is:

  • keep strong references to senders in array.
  • be able to call methods, that don't depend on associated value (NativeValue in provided example). Such as func stringRepresentation(ofEventName:) -> String
  1. AnyCollection<StatisticsEventParam>
    Analytics event has a property with params. Now its type is AnyCollection.
    We don't use Array, because it is not suitable. Fo example, several weeks ago we began to use OrderedSet from SwiftCollections library.
    Opaque types don't help here, because the don't provide element type. At least in the way they work now

Here we need something like this:

  var params: Collection where Element == StatisticsEventParam { get }

  // or ideal variant
  var params: some Collection where Element == StatisticsEventParam { get }
  1. AnyRouter
    Situation is equal to StatisticsSDKSender. Several protocols instead of one.

  2. AnyObserver
    public struct AnyObserver: ObserverType {}

Good if we are able to create collection of ObserverType:

class MainViewModel {
    var observers: Set<ObserverType> where Element == Response
}

  1. AnyViewModelConfigurable
protocol AnyViewModelConfigurable: ViewModelConfigurable {
  associatedtype ViewModel

  func configure(with viewModel: ViewModel)
}

Situation is equal to StatisticsSDKSender. Several protocols instead of one.

  1. AnyEquatable
    It is interesting case.

@Joe_Groff says: "different dynamic types can be compared by wrapping both in AnyHashable"
From my point of view comparing two instances of different types via wrapping them by AnyHashable is language defect.
Opaque types provide this in type safe manner.
If we create Array, then put there String, Int and say, CLLocation, then pass it somewhere and become to compare, it is very strange.

"Equatable and Hashable existentials ought to be something we handle as a special case" - I can hardly imagine reasons for this. It will be interesting to know them.

Seems we can make both variants possible.

  1. Equatable and Hashable behave as all other PAT protocols
  2. AnyHashable struct continues to live in Standard Library. Those, who want to compare different types, can wrap them in AnyHashable.