SE-0341: Opaque Parameter Declarations

Hello, Swift community.

The review of SE-0341: Opaque Parameter Declarations begins now and runs through February 14, 2022.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. If you do email me directly, please put "SE-0328" somewhere in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

As always, thank you for contributing to Swift.

Ben Cohen
Review Manager

20 Likes

Fantastic, a long-discussed next step finally come to pass. As such fits well with the direction of Swift. Similar to Rust’s use of impl, to my understanding, and the same arguments in favor apply to this change in Swift. I have participated in discussion of this feature at length in the past.

It bears mentioning, on the topic of fitting with the feel and direction of Swift, that while there are no current legal uses of (some T) -> U in source, @hborla has pointed out in the past that there are some uses in diagnostics where it’s meant a callee-chosen opaque type—these diagnostic messages should probably be rephrased as part of this proposal implementation.

7 Likes

I'm not really qualified to comment, but it feels a bit surprising to use the some P syntax for generics, when we are used to using it for "reverse generics". In my mind these are almost opposite things. Normally some P denotes a specific, but unknown type that conforms to P, but here it would mean "any T you like that conforms to P", so it definitely seems more natural to call it any P.

I like the future direction, but I also wish we had that for the existing some!

1 Like

What is your evaluation of the proposal?

The outlined syntax is great but the devil is in the details. The general concept of being able to pass some types as parameters is amazingly useful but there are some holes in how some types work; specifically the area I still have great concerns about their usefulness is the function sub-typing of conformances when it comes to effects such as throws.

We are advocating with this proposal the proliferation of some to be the right solution for generics where it is reasonable however the places that it is distinctly reasonable for some types do not encapsulate the entire use case of generics. Where generics ferry the effects, some types presume the worst case scenario that all effects are in play.

Particularly for new framework API design this poses an adoptability problem that, unless solved, degrades the usefulness of this proposal.

In short: if we can address the effects more than just promoting them to the worst case scenario then this proposal is amazingly great. If not, I worry that it will be as useful as it really could be.

Is the problem being addressed significant enough to warrant a change to Swift?

Without a doubt; this makes generics much more approachable in Swift. In addition to some of the other associated pitches this feels natural and will make the language more approachable (provided the implementation detail of effects is resolved).

Does this proposal fit well with the feel and direction of Swift?

See the above statement; making generics easier to use is perhaps one of the more meaningful directions we can take to improve the existing aims of the language.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

If you count objective-c's id<SomeProtocol> sure (but perhaps that is stretching it a bit) that is perhaps the closest analog.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I have done an in-depth study, trying out the feature with current work that I have been doing. I have filed bugs against the implementations and raised some concerns particularly with how this falls out for effects.

2 Likes

Whether it is "generics" or "reverse generics" depends on whether the some P is on the left or the right of the ->. On the left, it's generics---the caller gets to choose which type is provided here, just like the caller supplies the value for the parameter. On the right, it's "reverse generics"---the callee gets to choose which type is returned, just like the callee gets to choose which value is returned.

Regarding some P vs. any P, the distinction is important and becomes apparent when it shows up in a nested position within the parameter's type, e.g., consider these two:

func f(_ array: [some Equatable]) { }
func g(_ array: [any Equatable]) { }|

The function f accepts an array of values of some type T, where T is Equatable. All elements in the array always have the same type, so you can meaningfully write array[0] == array[1].

The function g accepts an array of values, each of which is known to be Equatable, but the types of each value can be different. So your array could contain an Int, a String, and a Double. You cannot compare array[0] and array[1] because the values have different types.

Doug

14 Likes

That's a good point. When we refer to these types, we use some P but we don't sufficient context to know which some P it is. There might be both wording changes to diagnostics and also teaching the diagnostics machinery to point to which some P it's talking about (e.g., by highlighting the source range)

Doug

1 Like

"some type" means "same type" ?
some == same ?

Thanks for the informative reply.

Sure, I do understand that, but to me that is the same as saying "the some notation can be used both for generics (caller choses) and reverse generics (callee choses). (Btw I didn't realise that existential any was already accepted and implemented, I do understand the difference from this.)

But I mean in my mind we have three cases that could potentially warrant different notation, the existing some, the proposed some and the upcoming any, but the idea is apparently to lump the first two together.

To me it seems very significant that the current some refers to a specific but unknown type. That is what some means to me, "unnamed but specific", so when I see some P as a parameter, I'm thinking "how am I supposed to know what type to supply here?".

You can of course argue, that the meaning of some is not quite this, but rather something like "unnamed and chosen by the relevant party". The relevant party in a parameter being the caller, and in the return type it's the callee.

But if this is the argument, then I would say that this is confusing, since in Swift we do allow a situation where the caller determines the return type, in normal generics. So presumably this is not the line of reasoning behind the naming, so I wonder: what exactly does some signify?

4 Likes

Not really. Here it's just the case that we have some specific type that is the element type of the array, and since an array can only contain values of exactly this element type, they all must be the same type.
On the other hand, Array<any Equatable> can contain values of any type that conforms to equatable, so we cannot know if they are the same or not and we can therefore not write array[0] == array[1], because those two values could be of different types.

One bit of awkwardness I noticed, unless I'm missing something, is that to start using a generic, you have to start with a placeholder, as the opaque syntax doesn't sugar unqualified types. func do<T>(with value: T) doesn't seem to be helped by this proposal, so the placeholder -> opaque parameter -> placeholder evolution feels a bit awkward to me. If this is the case, the proposal doesn't actually seem to lessen the learning curve for generics by much. Or perhaps this is just a rare case.

I'm neutral on adding syntax sugar for generics, but I opposed to this proposal in the use of some.

First, as some of other people reffered, the use of some in parameter position is confusing. It depends on how to learn, but if learners will see some in parameter position at first, and find the notion of generics. After that, the learners will consider that func foo() -> some P is also a generic function, due to the use of some.

In the future, full-power reverse generics syntax (often reffered as func foo() -> <T> T or func<^T> foo() -> T) can be introduced. Swift then has three different features at that point; 1: generics, 2: some, 3: reverse generics. Among these three features, some only seems a chimera of generics and reverse generics. On this point I cannot believe using some is semantically natural.

Second, the proposed some syntax introduces contradiction. In the pitch thread, @Douglas_Gregor explained that (some P) -> () is allowed in parameter position (link), though SE-0328 prohibits introducing it with let statement. As far as I read the proposal, I couldn't find any change on this point. Based on the proposal, (some P) -> () in parameter position behaves as the following.

// proposed syntax
func foo(f: (some Numeric) -> ()) {
    f(42)   
}
// equal to current Swift
func foo<T: Numeric>(f: (T) -> ()) {
    f(42)   
}

I agree that this is natural. However, considering the next code, it seems quite strange because function f and closure f both have type (some Numeric) -> () but behave totally different.

// proposed syntax
func foo(f: (some Numeric) -> ()) {
    f(42) // f is not a generic function
    f(42 as Int) // error
    f(42 as Float) // error
}
// proposed syntax
func f(_ value: some Numeric) {}
f(42)     // ok, f is a generic function here!
f(42 as Int) // ok
f(42 as Float) // ok

SE-0328 rejected adding some P in 'consuming' position of closure, because at that point we were afraid that adding such a closure contradicts with this SE-0341 == 'generalized some syntax'. But this SE-0341 introduces the some P in 'consuming' position of closure in the parameter position of function. At least, please explain, why? This problem cannot be resolved if you prohibit the use of (some P) -> () in parameter position. The proposed behavior of it is quite natural considering it as a sugar of generics. Prohibiting that introduces a new strange exception into Swift.

After all, I think these problems came from the trial to distinguish some as both generics and 'reverse generics' based on the position. I strongly suggest that we should use other keyword for the proposed feature.

3 Likes

Big +1 from me, as someone who has written out generic signatures many, many times.

I actually find the use of some in parameters to be clear and helpful for understanding opaque return values, assuming I'm understanding the proposal right.

If I'm writing a function that takes some P, I don't know what type I'm being given, just that I can use P's interfaces with it. If I call a function that returns some P, I don't know what type I'm being given, just that I can use P's interfaces with it.

If I'm writing a function that returns some P, I get to hide what type I'm actually returning, so long as it supports P's interfaces. If I'm calling a function that takes some P, I get to hide what type I'm actually passing, so long as it supports P's interfaces.

To the recipient of some P, the story is the same. To the provider of some P, it's again the same story.

21 Likes

What the some in a parameter is saying is that whatever type the caller passes as the parameter, it must be a consistent concrete type at a given call site (not existential).

To me it’s the same type of requirement as a variable of type some P. Any expression that you can assign to a some P variable is valid to pass as a parameter of type some P and vice versa. They’re equivalent.

I know how it works in this case, and in the existing case, I’m just trying to figure what the common meaning is. It seems to me to be sufficiently different things.

Also, we have learnt to think of it as opaque return types, but in this new usage it’s not really opaque, since it’s up to the caller. In fact, it’s just normal generics but without naming the parameter, which makes it especially confusing to me to reuse the marker for opaque return types.

To be honest I don’t quite see the need. It doesn’t seem to add any new functionality, and it only sugars a subset of existing generics, or perhaps I’m missing something?

Perhaps the key is in your second clause, I hadn’t thought of it that way. Since we already allow it for variables, this usage is perhaps natural.

2 Likes

Given an example function like:

func eagerConcatenate(
    _ sequence1: some Sequence, _ sequence2: some Sequence
) -> [T]

(Edit: I just realized T in the return type is undefined, ignore that for now)

Would it be possible to reference the type variables of sequence1 and sequence2 in a where clause? E.g. something like:

func eagerConcatenate(
    _ sequence1: some Sequence, _ sequence2: some Sequence
) -> [T] where type(of: sequence1).Element == type(of: sequence2).Element

I know this will eventually be possible with the "future direction", which would support the syntax below, but I'd like to confirm what's possible with just this immediate proposal.

func eagerConcatenate<T>(
    _ sequence1: some Sequence<T>, _ sequence2: some Sequence<T>
) -> [T]
1 Like

I actually view this as establishing a symmetry that greatly clarifies generic parameters and opaque return types.

Setting aside the fact that generic parameters came first with generic-parameter syntax, and opaque return types came first with this syntax, I feel like your paragraph could have been paraphrased either way, depending on the timeline.

In a world where we are pitching opaque return types given pre-existing opaque parameter types:

"We have learnt to think of it as opaque parameter types, but in this new usage it’s not really opaque, since it’s up to the callee. In fact, it’s just normal generics but without naming the return type, which makes it especially confusing to me to reuse the marker for opaque parameter types."

and in a world where we are pitching opaque parameter types give pre-existing opaque return types:

"We have learnt to think of it as opaque return types, but in this new usage it’s not really opaque, since it’s up to the caller. In fact, it’s just normal generics but without naming the parameter type, which makes it especially confusing to me to reuse the marker for opaque return types."

Opacity is observed by the recipient of the value. Parameters are received by the callee and return values are received by the caller.

4 Likes

There is a crucial difference though:

Opaque return types have a single, albeit secret, type. Generic parameters can have any conforming type.

1 Like

Opaque parameter types have a single, albeit secret, type. Opaque result types have a single, albeit secret, type. Generic parameters can have any conforming type. Generic returns can have any conforming type.

We're talking about two different frames of reference for the same thing. A slope is both an incline and decline, depending on your frame of reference. A type is opaque or a specific type depending on the frame of reference, i.e. if you're the provider or recipient of the type.

8 Likes

Though I am in favor of this proposal, I sort of get what @GreatApe is getting at. Viewed from the opaque side of the type, there is an invariant enforced for callee-side generics that is not enforced for caller-side generics—namely, that the identity of the unknown type remains the same across different invocations.

For instance, in the following example:

var usedTypesF: Set<ObjectIdentifier> = []
var usedTypesG: Set<ObjectIdentifier> = []

func f(_ t: some Collection) {
    usedTypesF.insert(ObjectIdentifier(type(of: t)))
}

func g() -> some Collection {
    return [Int]()
}

usedTypesG.insert(ObjectIdentifier(type(of: g())))
usedTypesG.insert(ObjectIdentifier(type(of: g())))

we can say that the count of usedTypesG will always be 1 no matter how many times we invoke useG (modulo, I suppose, subclasses :sweat_smile:), but we can't make a similar statement about usedTypesF other than that its count will be less than or equal to the number of invocations. Importantly, in both cases we are on the side of the generics where we could ostensibly be dealing with any conforming type, but we still know more about the behavior of g's return value than we know about f's parameter.

3 Likes

I understand your point, I'm just not sure it's true.

Given a particular function:

func test(viewParam: some View) -> some View

We then consider this function, over time:

let x = test(viewParam: a)

From the outside, we don't know what x is, but we know it will always be the same type.

But we can vary a, it can be anything we want it to be (as long as it conforms).

func test(viewParam: some View) -> some View {
    let y = viewParam
    // ...
    return a
}

Seen from the inside, y can be different every time, and we won't know the type. a on the other is known and under our control, but it can't change.

So we have:

Return type seen from outside: unknown/uncontrollable + fixed
Return type seen from inside: known/controllable + fixed
Parameter seen from outside: known/controllable + non-fixed
Parameter seen from inside: unknown/uncontrollable + non-fixed

And this doesn't seem to be analogous, or dual.

So this was a specific function seen over time, but perhaps if we instead consider a specific signature seen in different functions:

func testA(viewParam: some View) -> some View
func testB(viewParam: some View) -> some View

Now we have:

Return type seen from outside: unknown + non-fixed
Return type seen from inside: known + non-fixed
Parameter seen from outside: known + non-fixed
Parameter seen from inside: unknown + non-fixed

So perhaps this is the perspective we should have, in order to make sense of this feature?

2 Likes