Can the compiler be more helpful when we need to explicitly, dynamically specialize existentials?

Swift 5.7 greatly improved ergonomics of existentials, until you don't need a type that was erased.

Sometimes it is unavoidable to use existentials. And sometimes one need to go from an existential to a specific type. I suppose that with Swift 5.7 we've got as much as we can with the static type system (everything non-specific is as known as upper bounds). Thus, I suppose that if we need to go beyond implicitly opened existential, we need to do get those types (specialize) dynamically, with casting.

This typically results in if-elses or switch control flows. Which is a boilerplate, or at least could be somehow generalized. But my biggest concern here is that it is prone to miss when trying to handle all possible specific types, resulting in a runtime error that I suppose the compiler (to some extent) could prevent from.

protocol P { /* has associated types that get erased to upper bounds */ }
struct P1: P {}
struct P2: P {}

let anyP: any P = P1()

switch anyP {
    case let p1 as P1: // work with P1
    case let p2 as P2: // work with P2
    default: fatalError()
}

Is it any reasonable to want from the compiler to safeguard the dynamic specialization of an existential?

  • To avoid unnecessary default case.
  • Which implies static error if another conforming type (e.g. P3: P) is added.
  • Maybe ergonomics could be improved.

There may be some limitations like perhaps generic types conforming to P, or conformances defined outside of the module where the dynamic specialization occurs. I'm just wondering if there is anything that the compiler could offer.

2 Likes

If you have a closed set of types conforming to the protocol, could you use an enum instead? There have been pitches about "sealed protocols" in the past, but there are challenges in determining the limits of where types conforming to a protocol can be added. An enum by contrast only ever contains one of its cases.

Alternatively, if you really want to preserve the protocol, it may be more appropriate to turn your switch into a new method requirement on the protocol. That will ensure that every conforming type has to implement the method in order to provide its branch of the switch.

7 Likes

i expect that many people would stop asking for sealed protocols if only it were possible to overload the as operator.

1 Like

Could you elaborate? In the case of an enum, I think folks would be able to pattern match the case of the type they’re interested in without going through type casting. Would the overload be sugar for that?

if case pattern matching doesn’t really compose well with the rest of the language’s syntax, since it is not an operator, whereas as? chains very nicely. so APIs that rely on if case matching feel really clunky, which drives people towards using protocol existentials (which is obviously bad).

2 Likes

This was supposed to be a case where a requirement contains associated type that was erased by existential.

protocol P {
    associatedtype A
    func takeA(_ a: A)
}

struct P1: P {
    func takeA(_ a: String) { ... }
}

for anyP in anyPs {
    switch anyP {
        case let p1 as P1:
            p1.takeA("hello")
        default:
            fatalError()
    }
}

At some point the existential needs to be dynamically specialized in order to use the member. In certain cases this is just unavoidable.


I was confident that my protocol cannot be turned into enum. So I moved on, to search for "sealed protocols" you mentioned and during browsing I stumbled on Sealed protocols - #63 by Joe_Groff and it hit me. I'll need to explore this more. But I can imagine this could easily turn into a topic where enums are lacking features that protocols provide with associated types (without lots of boilerplate, payload and awful ergonomics). Anyways, using just internal protocols with just definite set of conforming types over enum may be formally correct and naturally ideal, not worthy to trade for enums with exhaustive switching.

Regarding "sealed protocols", they attempt to solve a much more complex problem, like conceptually with access control that may even clash with Scoped Conformances, and doesn't even have exhaustive switching as a priority. Exhaustive switching over a sealed protocol is more like an opportunity there.


Now the question is if exhaustive switching over a "sealed" protocol should be a thing, or it is ill, or there may be something better. If the exhaustive switch could eventually be the problem solver, is it worthy to discover it on internal ("implicitly sealed") protocols as a starter?

In this case, you can still add requirements using the associated type to describe what to pass:

protocol P {
  associatedtype A
  func takeA(_ a: A)

  var foo: A { get }
}

extension P {
  func takeFoo() { self.takeA(self.foo) }
}

struct P1: P {
  func takeA(_ a: String) { ... }
  var foo: String { return "hello" }
}

for anyP in anyPs {
  anyP.takeFoo()
}
1 Like

I hoped you won't go for this. Sorry, bad example. Let's assume a case where we really need that casting. Like moving from erased to generics (which is actually my real case).

protocol P {
    associatedtype A: Q
    func produce() -> A
}
struct G<T: Q> {}

And produce G<A> from any P.

That too can be done by writing a generic function that takes a T: P or extension on P itself:

protocol P {
    associatedtype A: Q
    func produce() -> A
}

struct G<T: Q> { var value: T }

func consume<T: Q>(g: G<T>) {}

extension P {
  func produceAndConsume() {
    consume(G(value: self.produce()))
  }
}

Let's leave the generic value to be consumed by an external consumer, unrelated to P.

Or are you suggesting that ending up with casting implies bad design?

Casting isn't necessarily bad design, but it shouldn't be necessary in order to carry forward generic information from the underlying type that's already captured in the protocol, such as associated types. As of Swift 5.7, you can open an existential into any generic function, so the logic doesn't have to be tied directly to P. You could also write:

func independentFunction(ps: [any P]) {
  for p in ps {
    func handle<T: P>(p: T) {
      consume(G<T>(value: p.produce()))
    }
    handle(p: p)
  }
}
3 Likes

Well the problem is that my case isn't to carry forward but to return upward. Do you know any neat acrobatics for this?

protocol P {
    associatedtype A: View
    func produce() -> A
}

@ViewBuilder var body: some View {
    p.produce()
}

Not ideal (I think), but in this particular case, you could add this extension to View:

extension View {
    var asAny: AnyView { self as? AnyView ?? AnyView(self) }
}

and then call p.produce().asAny.

I prefer avoid AnyView here. Like it doesn't exist.

The problem trying to turn any View into some View (without the use of AnyView somewhere) is, there's no compile-time guarantee every value of erased type any View will hold the same opaque type some View.

Well that is what this topic was supposed to be about.
I'm willing to do some manual work for the compiler to turn any P into "OneOf<P1, P2, P3, LookingForwardForGenericVariadics>" and was hoping that the compiler could meet me halfway.

That sounds a lot like a union type. TypeScript has them. This topic also popped out in the discussion of the typed throws. Having union types would allow compiler to deduce error type of the function as a union of the error types of different sources.

If this ever gets implemented, I’d expect union types be just an Any on the ABI level and flatten existentials:

Any | String == Any

protocol P {}
protocol Q {}
struct S: P, Q {}
var x: any P | any Q = S()
switch x {
case let p as any P:
   print(“P”) // Matches
case let q as any Q:
   print(“Q”) // Doest not reach after first match
}

I think if case fits well once you get used to it. I have several complaints, however:

  • When you type the if case let statement, the sourcekit is not able to make an autocomplete suggestion, as it does with switch case, because the variable you want to match is typed last. One solution from my point of view, would be to allow the matched variable on left side, like this:
enum A { case a(Int), b(Int) }

let a = A.a(1); let b = A.b(2)

if a = case let .a(anInt), case let .b(anInt2) = b { print(anInt + anInt2) }
// or 
if case a = let .a(anInt), case let .b(anInt2) = b { print(anInt + anInt2) }
// or even requirement to use pattern mathing operator would be fine fmpov
if a ~= let .a(anInt), case let .b(anInt2) = b { print(anInt + anInt2) }
  • The other factor that makes me less enthusiastic about enums (again, fmpov) is, that In-place mutation of an enum associated value is still not possible. If this was possible, enums would get huge advantage over any "sealed" protocol based solution.

Currently probably the only way is to make a content view generic

struct ContentView<Content: View>: View {
   var produce: () -> Content

   var body: some View {
      produce()
   }
}

struct Producer: P {
   func produce() { EmptyView() }
}

ContentView(produce: Producer().produce)

or

struct ContentView<T: P>: View {
  let p: T

  var body: some View {
    p.produce()
  }
}

let contentView = ContentView(p: Producer())

I believe this has been pitched in the form of if x is case .a(let value), but I can’t find the thread.

There have been a few times recently when I’ve wished that enum cases acted as full types. (IIRC it would have been useful to use a specific case as an associated type.) If enum cases were elevated to this stature, then they would be isomorphic to a sealed protocol.