+1. I’ve definitely been hit by this many times. I weirdly almost agree with all the conflicting things everyone has said.
Could we break this into two proposals? The explicit and the implicit part? Seems like getting the explicit part through is a big win and everyone agrees. The implicit part seems hard.
You're correct, it's a typo. I'll push a fix shortly.
Another typo, thanks!
Neither of these cases allows opening, because X<any P> is not a box around X<a-concrete-type-that-conforms-to-P> that can be opened; it's a completely distinct type with a different runtime representation.
You're right, thanks!
We could, but doing so loses a lot of information, which seems worse than not allowing the opening at all. Regarding this...
Swift's type inference intentionally does not do this, because the set of protocols to which a given concrete type conforms is not particularly stable: new protocols and conformances get added often, and the set of conformances is affected by the arguments to a generic type (because of conditional conformances) and even the enclosing @available/if #available context (because conformances can have OS availability).
Yes, it would.
Yes, you're right. I'll push a fix shortly.
Agreed, this is an important place to make sure the error message is informative.
This is a good point. I'll make a note of it in the proposal.
Huh, that's an interesting idea. I'd like to get to a world where the opening behavior is the default here (when there's no type annotation), but I could see some _ as a new spelling to force the issue. It's pretty limiting, though: the right-hand side would have to be of existential type, and you'd get the corresponding some type. If the right-hand side were a concrete type (say, Int), we would have to fail type inference. It seems like a very narrowly-applicable feature.
If it's not implicit, we'll need a more shorthand syntax like @xwu's proposed as some P, because having to declare a new local variable would be unfortunate. I learn toward implicit in part because we already implicitly open existentials when doing member access (someExistential.foo()); would we move that toward something explicit ((someExistential as some P).foo()) or leave them inconsistent?
One thing that concerns me somewhat with the "inline" explicit syntax (like someExistential as some P) is that it's not completely clear to me where to do the type erasure. If I write
let other = someExistential as some P
is this like writing
let other: some P = someExistential as some P
or is it like writing:
let other: any P = someExistential as some P
?
What if I do some function composition?
let other2 = f(g(someExistential as some P))
Do I erase the result of the call to g, the call to f, or not at all? With the implicit opening for arguments, we type-erase the result of the immediate call, always, and don't have to define the other cases.
I feel like the inscrutability of "this can't be opened, but these other things can" is likely to be a problem no matter what we do, here, and we'll be playing whack-a-mole with poor diagnostics for a while. Even with SE-0309 this issue is going to get worse.
The proposal introduces
let ap: any P = ...
let sp: some P = ap
as a way to spell this explicit opening.
As proposed, there's no clean explicit vs. implicit split: there's implicit as a call argument, and implicit when initializing a variable of type some P, both of which are semantic changes.
If the right answer for the explicit spelling were something like as some P, that's a more clean split... but per my comments above, and after thinking about it more, I'm not sure that's actually the formulation we want. I tend to like the implicit better, both because of consistency with member access on existentials and because I think the semantics are more often what one wants.
If we keep implicit, we will need to introduce an explicit syntax to disable the implicit opening, as noted before:
Yes, it has to be, does it not? In all cases where we use the type coercion operator in a statement like let x = foo as Bar, the inferred type of x is Bar. This is, after all, how we specify the type of a literal value (e.g., 42 as Double).
Yes, the real question is when---if ever?---do we type-erase after an explicitly opened existential? Earlier, I phrased it this way:
I'm starting to feel like the answer, for explicitly opened existentials, is either "never" or "only at return statements". In other words, if you explicitly open with something like the as some P syntax, then you'll get entire expressions that involve the opened existential value.
FWIW, "only at return statements" is "just" a special-case of implicit conversions: you can always convert a concrete type XImpl to any X as a pseudo-upcast, and converting some X to any X is the same thing.
I should have elaborated on what I was thinking. With closures and opaque result types, we can end up inferring the result type. I don't think that inferred result type should ever be an opened existential type, so I think we want to type erase:
func identity<T>(_ value: T) -> T { value }
func f(p: any P) -> some P {
return identity(p as some P) // underlying type can't be an opened existential, so we should type-erase to 'any P'
}
Big +1. This gives developers control over when and how opened types need to be erased.
Difference between "never" vs "only at return type" is subtle. In the first case, we erase only if we encounter cast to existential during the typechecking. In the second case we always insert a cast, and then proceed to type-check the return statement using an existential type. Which sounds like more work for the type-checker. And if there are constraints that require even more abstract type, then another cast might be inserted.
Case of "never" should be accompanied by a rule that opened existential types must not escape the scope. In most of the cases it happens naturally. If you assign value to the variable declared outside of the scope, opened type could not been used in variable declaration. Similar for function parameters.
Special care is needed only when type in the outside context is inferred based on nested content. The only two cases of this that I can think of are: 1) inference of closure return type; and 2) opaque return type.
protocol P {}
protocol Q {}
struct X<T: P>: Q {}
func foo<T: P>(_ value: T) -> X<T> { .init() }
func getType<T>(_ dummy: T) -> T.Type { T.self }
func g<T: Q>(x: any P, another: T) -> some Q {
let y = foo(x) // y: X<opened type of x>
// No erasure yet, all methods of X are available
y.foo()
// Ok, opened type remains in scope
// Closure literal has type `() -> X<opened type of x>`
// All types used in the signature exist in the outer scope of the closure.
let f1 = { return y }
let z: any P = x
// Error, type of z is opened inside f2 but escapes into f2’s return type
// Closure lateral has type `() -> X<opened type of z>`
// Type `opened type of z` does not exist in the outer scope.
let f2 = { foo(z) }
// Ok, type of z is opened inside f3 and implicitly casted to existential while type is still in scope
let f3: () -> any Q { foo(z) }
// Ok, metatype value escapes from the function, but that's a different kind of escaping
// Maybe it's not so good idea to use term "escaping" in the context of type checking.
let f4: () -> P.Type = {
let openedZ: some P = z
let w = foo(openedZ)
return getType(w)
}
// Error, opened type of X escapes into opaque type descriptor
return y
// Ok: opaque type descriptor depends on the genetic type, but all generic types used exists in the outer scope of g
return another
}
Case of f2()/f3() shows another difference between "never" and "only at return statements".
With "never" f2 is always invalid, even for trivial cases. With "only at return statements" return type of the f2 will be inferred as erased type, but only for limited cases.
With "never" one can manually choose erased type and get f3 working, even in case of cannotOpen7(). With "only at return statements" f3 will not work for cannotOpen7(), because intermediate erased type cannot be constructed.
In the summary, I think "never" is the right way to go. Being able to support the case of cannotOpen7() is a huge win, IMO. Looks like it will lead to a simpler implementation. A little verbosity at the point of introducing type erasing might be even helpful - to give users a gentlle nudge towards getting things done while type is still open.
Fly by comment to say that this feature resolved yet another case where previously I had to resort to using _openExistential Really excited to see this land.
Specifically, distributed actor system implementations often when implementing the "receive a message" end up in this situation:
let recipient: any DistributedActor = resolveAny(envelope.recipientID)
try await executeDistributedTarget(
on: recipient, // Previously error: 'Type `any DistributedActor cannot conform to DistributedActor'
since the on actor: Act) where Act: DistributedActor;
With the experimental implicit opening, this also just works
Anyway, nothing unexpected here -- but wanted to share how happy I am that nowhere in a real actor system implementation do we need to resort to underscored features anymore