[Mini-pitch for SE-0352 amendment] Allow opening an existential argument to an optional parameter

Hi all,

SE-0352 "Implicitly Opened Existentials" has a limitation that prevents the opening of an existential argument when the corresponding parameter is optional. For example, this will not open:

func cannotOpen6<T: P>(_ x: T?) { }
func test(pOpt: (any P)?, p1: any P) {
  cannotOpen6(pOpt) // does NOT allow opening pOpt
  cannotOpen6(p1) // does NOT allow opening p1
}

The rationale in the proposal is not based on a technical argument, but rather a kind of consistency argument:

The case of optionals is somewhat interesting. It's clear that the call cannotOpen6(pOpt) cannot work because pOpt could be nil, in which case there is no type to bind T to. We could choose to allow opening a non-optional existential argument when the parameter is optional, e.g.,

cannotOpen6(p1) // we *could* open here, binding T to the underlying type of > p1, but choose not to

but this proposal doesn't allow this because it would be odd to allow this call but not the cannotOpen6(pOpt) call.

In practice, this limitation is unfortunate, because it means that there is no way to call a generic function with an optional parameter like this without hopping through a method on a protocol extension. The premise of SE-0352 isn't really sound, either: developers can understand that it doesn't make sense to "open up" an optional argument when there might not be anything there (nil), and have a reasonable workaround when they do have an optional argument (e.g., via if let or guard let).

We should remove this restriction, so that cannotOpen6(p1) opens up p1. The implementation is trivial, but alas, I reverted it while writing SE-0352.

Thoughts?

Doug

6e7fff7e65283ae9b25116fa6b75ba92fd2f2a58

26 Likes

To make it crystal clear, you are proposing only allowing one of the two calls that are currently disallowed? In other words, after this change:

func foo<T>(arg: (any T)?) { }

let nonOptionalValue: any BinaryInteger = 42
foo(arg: nonOptionalValue) // previously disallowed, now allowed

let optionalValue: (any BinaryInteger)? = 42
foo(arg: optionalValue) // still disallowed

I agree with the change, but would love to see a commitment to a clear, context-specific diagnostic in the second case.

3 Likes

Seems sensible, and at a glance, seems harmless.

This remark made me worry about whether there is a larger “opening + wrapping” generality problem lurking here, but the protocol extension method trick apparently isn’t necessary; this also works in Swift 5.7:

func openOpt<T: P>(_ x: T?) { }

func openNonOpt<T: P>(_ x: T) {
  openOpt(x)  // allowed, because T is already bound
}

func test(p1: any P) {
  openNonOpt(p1)  // allowed, because we’re not jumping inside an optional
}

Removing that middle step for optionals does raise the question of whether things like this should also work:

func openWrapped<T: P>(_ x: Result<T,Error>) { }

func test(p1: any P) {
  openWrapped(.success(p1))
}

That might be useful, but optionals do seem like the hot use case here, and special-casing them is in keeping with Swift.

Yes, your understanding is correct. And I agree that this is a good place for a context-specific diagnostic.

One minor issue is that your declaration of foo had an unintended any that could be confusing. It should look like this:

func foo<T>(arg: T?) { }

Right, the "middle step" here isn't dealing with existentials at all, so there's nothing to open. It's an "optional injection", i.e., the equivalent of .some(x).

Yeah, that's an interesting point. The way SE-0352 specifies the semantics here is that we type-erase the result of .success(p1), so we'd a Result<any P, Error>. To generalize here would mean delaying the type erasure of the result to the outer call. I'm not sure what the natural end for this would be... if we had a double-wrapped .some(.success(p1)) would that delay all the way through the chain?

I think it's an interesting direction, but it does take my mini-pitch from being a small correction into a more significant extension.

Doug

3 Likes

This took me a moment, but yes, you sure do have a point: I’m describing a wildly more complicated idea than your proposal. Thinking out loud to check my own understanding of what you’re saying, my two-step “works in 5.7” process up above for opening an existential into an optional param is equivalent to this:

func handleOpt_noOpeningNeeded<T: P>(_ x: T?) { }

func openNonOptional<T: P>(_ x: T) {
  handleOpt_noOpeningNeeded(.some(x))
  //                        ^^^^^^ ^  ← implicitly added by compiler
}

func test(p1: any P) {
  openNonOptional(p1)
}

…and it makes sense to automatically fold that whole openNonOptional step into compiler magic, because Swift already has a special policy about automatically wrapping values in Optional.some. If we conceptualize your proposal as automatically generating my openNonOptional function here, there’s no ambiguity about what to generate.

But there is no equivalent policy about automatically wrapping values in .success, so the direction I’m proposing requires one of the following:

  1. Allowing enums to specify a “default case” of some kind, and then using that to generalize Swift’s optional wrapping to other enum types. Then the middle step above can be well-defined even if we just pass p1 directly for a param of type Result<T,Error>, without wrapping it in .success at the call site.
  2. Doing something sneaky in the type system to defer the type erasure of .success(p1), as you described, so that .success(p1) can move inside the implicit openNonOptional function as it were.

Option 1 seems interesting to me. IIRC, it was already discussed and soft-rejected, though?

In any case, I completely support keeping the scope of the proposal at hand nice and narrow. It’s an entirely worthy and sensible fix as is.

+1. Makes sense. We shouldn't ask developers to write an extension method to do this. Nullable parameters are important for opaque types so they will exist.

I don't think the (1) you're proposing has been discussed on its own. There were discussions on generalized implicit conversions that could have allowed this (but don't seem likely to succeed), and older discussions around letting other types benefit from optional's syntactic sugar. Personally, I'd rather not add a conversion here, but...

... I'd like to keep my mini-pitch mini ;)

Doug

3 Likes

Hey folks,

I wrote up an amendment to SE-0352 and provided an implementation.

Doug

4 Likes

+1 & thank you!