SE-0341: Opaque Parameter Declarations

These are very helpful! I actually have a fourth potential interpretation, which is (I think) more useful in practice and IIRC was part of the motivation for SE-0328 prohibiting the use of some in consuming positions. The observation (based on the SE-0328 discussion) is that opaque types in consuming positions are hard to use because the wrong code (caller vs. callee) gets to choose the parameter. So, this approach "flips" the position of the <T> across the ->. It looks like this:

func f0() -> some P
func f0() -> <T: P> T

func f1(value: some P) -> Void 
func f1<T: P>(value: T) -> Void

func f2(closure: (some P) -> (some P)) -> (some P) -> (some P)
func f2<B: P, C: P>(closure: (A) -> B)) -> <A: P, D: P> (C) -> D

This way, the caller gets to choose the values it will provide (B, returned by closure; and C, passed to the function returned from f2), and the callee gets to choose the values it will provide (A, when fn2 calls closure; and D, the result from calling the function returned from f2).

I do not think SE-0341 should go this far. Rather, I suggest that we prohibit opaque types in consuming positions, mirroring the restrictions put in place as part of the acceptance of SE-0328. If we want to lift the restriction later, we would do so in one proposal that applies to opaque parameter and result types consistently. I have put up a pull request that amends SE-0341 in this manner, with this "flip" approach in Future Directions.

@xwu 's argument would be for a greater restriction, which would prevent opaque types in effectively any function type, to prevent a future ambiguity (or oddity) if Swift gains first-class generic functions. It would apply equally to this proposal and SE-0328. My inclination is to disagree for a few reasons:

  • I don't think Swift is all that likely to gain first-class generic functions,
  • I think that having opaque types create generic parameters in the innermost declaration (as proposal) vs. the innermost function type(@xwu's suggestion) is more likely what one would want, and
  • Even if both of those are wrong, it's only syntactic sugar and one can write out the full form.

That said, I lost a similar argument years ago about suffix ... and now it's causing the trouble I predicted with variadic generics, so I sympathize :slight_smile:.

Doug

10 Likes

I brought this up in the pitch thread for SE-0328 and there were some comments in the replies about this being potentially confusing. I agree that itā€™s probably best to rule out opaque types in contravariant positions for now.

If anything, the argument for it to work this way is probably clearer for contravariant positions in function arguments than it is in return types, due to the way type inference works

  1. Function arguments:
protocol P { 
  func foo() -> Int
}
struct TheP : P {
  func foo() -> Int {
    return 1
  } 
}
// equivalent to func actOnP(closure: (A) -> Int) -> <A: P> Int
func actOnP(closure: (some P) -> Int) -> Int {
   return closure(TheP())
}

actOnP { p in p.foo() } // 1
  1. Function return
protocol Q {
  func bar(i: Int) -> Int
}
struct TheQ : Q {
  func bar(i: Int) -> Int {
    return i
  }
}
// equivalent to func takeAQ<B : Q>() -> (B) -> Int
func takeAQ() -> (some Q) -> Int {
  return { q in q.bar(1) }
}
// so far so good... now how to use `takeAQ`?
let a = takeAQ() // Error? haven't specified the type of 'some Q'
// Need to use inference from use site
func actOnQ(f: (TheQ) -> Int) -> Int {
 return f(TheQ())
}
actOnQ(f: takeAQ())
1 Like

Thank you, I agree with @Douglas_Gregor's 'flipping' interpretation is semantically easy to understand. I still think it's extremely complex when we have deep nest for function types, but such cases are really rare, so it can be acceptable...

My (maybe) last concern is that, whether 'flipping' interpretation and other interpretations can correspond with any. In the discussion of SE-0335, I think people were considering that any is symmetrical to some because some is going to be generic in parameter position and 'reverse generic' in result position. But now, we found that there is room for interpretation of some which is large enough to have at least four different interpretations. I wonder if some as explained by flipping and not by the original interpretation is really such that it corresponds to any. This review thread is not the right place for discussion, but I think some rethinking of the SE-0335 is needed.

1 Like

I'm currently -1 on this proposal for a few reasons.

Firstly, I don't think it eliminates a meaningful amount of boilerplate. For example, the example using the proposed syntax:

func horizontal(_ v1: some View, _ v2: some View) -> some View //62 chars

is only 6 characters shorter than the declaration with the current syntax:

func horizontal<V1: View, V2: View>(_ v1: V1, _ v2: V2) -> some View //68 chars

Sure, you don't have to declare V1 and V2, so there's a bit less "ceremony" here, but are folks really struggling with that? And how often are folks using totally unconstrained generic types vs constrained ones that we really need to optimize for this use case? Maybe this is common in SwiftUI, but we should not be optimizing the language so that you can save a few keystrokes in using one particular library.

Secondly, we're now introducing a second way to do something that already exists, which I think only serves to increase the learning curve, not decrease it. Users are already required to learn the angle bracket syntax for generic functions. Now they've got to learn a second syntax and know that these two approaches are actually equivalent, even though they look different (note: I don't see that having an opaque return type makes a function generic, it just has a hidden return type). We're not enabling any new functionality here, just creating multiple ways to do the same thing. I think that makes generics less approachable, not more.

It used to be that you could immediately tell if a function is generic by looking for the <...> after the function name. Now you've got to read the entire signature and inspect the type of each parameter to figure that out. I think that hurts the readability and clarity of the function declaration.

Lastly, it seems that even folks in this forum (which is not a great sampling of Swift users) needed additional explanation why some now means different things in different context. It's becoming heavily overloaded and will be more difficult to learn for new users.

All of that being considered, my opinion is that this change would be a net negative for the experience of learning and reading Swift. Do we really feel that it meets the high bar required for new syntax?

5 Likes

If you never read the parameters or return type, why would you care if a function's generic? If you do read the parameters or return type, I think most would be find it easier not to have to piece together the type signatures manually, especially when the default Swift style puts the generic constraints after everything else in the definition.

Yes, people struggle with this syntax. In my experience the unique syntax of generic declarations has a steep learning curve for new and junior developers. Easing the ramp into that feature by reusing the opaque syntax should make it more understandable for users who don't yet fully grok the capabilities of the current generics feature. If you tell someone to "save an encodable value to disk", which is the more natural signature? func save<Value: Encodable>(_ value: Value) throws or func save(_ value: some Encodable) throws? Which is easier to teach? Which is easier to remember? For most people it would be the version that' looks more like English and which reuses syntax they've see used elsewhere, like body: some View.

3 Likes

Sure, the some might be simpler your first time encountering it. But now you've created two things they need to learn and remember, so there's more conceptual overhead overall.

1 Like

Not really, given they'll need to learn the some syntax anyway. And most users never progress beyond simple generics, so it's not like they have to learn the more complex version in the first place. Or if they do, they simply need to recognize it in other signatures, not write it themselves. And once they need the more advanced syntax they're at a much better place to understand both it and the fact that the some syntax sugars over it, due to the more gradual introduction they received.

2 Likes

Learning about some in the return position doesn't teach you anything about generic functions though. It can't be re-written with a type variable inside of angle brackets, where as some in parameter positions can. It's just a single, concrete type that's hidden from the caller; there's no conceptual overlap with functions that are actually generic (for this reason I think "reverse generics" is a misnomer, opaque return types is a much more apt name).

This goes back to my point that some is becoming overloaded to mean different things in different contexts, which only increases the learning curve even more. We're conflating things that are not at all similar and expecting the difference to be intuitive.

2 Likes

By the way, this is a good example that doesn't involve SwiftUI so I redact my statement about it mainly benefiting those use cases. I was also incorrect to say that this feature serves unconstrained generics. The generic type here is constrained to Encodable. The proposed syntax just can't constrain against associated types, which admittedly is less common but I do think Swift users will need to understand them.

For example, I think the average Swift user will need to be able understand append(contentsOf:), even if they don't write such functions themselves. This is a really commonly used method; associated type constraints are not limited to advanced or archaic programming patterns.

1 Like

Along with the lightweight constraint syntax, this proposal brings append(contentsOf:) to:

extension RangeReplaceableCollection {
  mutating func append(contentsOf newElements: some Sequence<Element>) 
}

... and it's pretty hard for me to imagine something more clear or minimal than that. We just want to take some sequence of our own element type.

Doug

13 Likes

Thanks Doug. Yes, if both proposals are accepted then the need to declare type variables up front is drastically reduced and many users can get by just knowing about some.

If the lightweight syntax is not accepted then I think this pitch is less valuable, as it will increase the learning curve of generics by having multiple ways to do the same thing. In your opinion, is this proposal contingent on the lightweight syntax or should they be considered in isolation?

Pat

Users don't need to know how the signature of append work to use the API. Their natural understanding of append is enough to understand what the method is supposed to do and what the result will be. Usually the only part they're thrown off by is the different between append and `appending. Beginners, and probably most users in general, will never look at the generic signature of a function. They just expect it to work. So I don't think your concern here is based on how users actually behave and rather overstated.

1 Like

Sure, I think it's fair to say that append is intuitive enough and maybe folks won't need to read the function signature. And to Doug's point, if the light weight syntax is also adopted and if the entire standard lib API is migrated, that wouldn't be an issue. If.

However, let's also think about every generic function in every Swift codebase to date that currently use angle bracket syntax. Many codebases contain functions like your example:

func save<Value: Encodable>(_ value: Value) throws

Refactoring these functions to use some will be a gradual process. Some function signatures may never be migrated, primarily because the function already works and there's not some immediate and overwhelming benefit to changing it. Even if the standard lib is migrated to some params on day 1, Swift users will be encountering type variables in angle brackets for years to come in both proprietary and open source codebases, and so they'll still need to learn both. Everyone updating all of their generic functions creates churn, and the mixed state that will exist in the interim (or forever) will be confusing and require more learning for these beginners that we claim to be helping.

Let's also keep in mind that not every programmer new to Swift is a beginner programmer. Many are coming from languages that make heavy use of type variables in angle brackets, Java, C++, C#, Kotlin, etc. some will make Swift generics less familiar and intuitive to this crowd, without any benefit to expressiveness or performance. It doesn't make Swift generics more powerful, just less approachable.

The point also remains that some now means different things in different contexts. My overarching point here is that it's not obvious, to me at least, that the learning curve is being improved at all. It's break even at best.

4 Likes

The protocol requirement and default implementation are written as:

  mutating func append<S: Sequence>(contentsOf newElements: __owned S)
    where S.Element == Element

The generated interface and documentation are translated into:

  mutating func append<S>(contentsOf newElements: S)
    where S : Sequence, Self.Element == S.Element

The constraint written in angle brackets has moved to the where clause.

Could the tooling automatically generate the simplified interface you posted?

(We would still want to gradually refactor code to use the improved syntax.)

4 Likes

Recall that Xcode already fibs in autocomplete lists, showing methods like this (misleadingly IMO) as append(contentsOf: Sequence). Having an actual abbreviated syntax would allow this to be spelled correctly and succinctly.

8 Likes

I'd like to drill into this point with a specific example of how this will be confusing. With this proposal, some P takes on a new meaning that does not overlap with opaque return types and is now context dependent. Imagine the following two function declarations:

// opaque return type, known only to the callee
func makeSomething() -> some P
// sugar for a type variable that you'd normally find in angle brackets
func takeSomething(value: some P) 

Depending on which of these functions a user encounters first, they'll decide on what they think some P means, but later they'll bump their head and realize that it actually depends on the context.

If they encounter makeSomething first, they'll decide that some P means a single, concrete, callee-specified type. When they later encounter takeSomething, they'll realize this can't be true as the callee doesn't construct argument values, so can't possibly be the one to specify their types. some P must mean something different here, more learning is required.

If they encounter takeSomething first, they'll decide that some P de-sugars to a type variable in the function declaration, as if it were written func takeSomething<T: P>(value: T). Later when they encounter makeSomething, they might assume that they could write something like:

let value: MyType = makeSomething()

Which won't compile because some P in the return position does not de-sugar to a type variable at all. It's not the same as writing func makeSomething<T: P>() -> T. Head == bonked and queue more learning.

So the idea that users "will need to learn the some syntax anyways" is false. They'll need to learn two different features which, very unfortunately, share the same keyword.

Putting myself in the shoes of someone who is new to Swift, I think my head would be swimming at this point. Then I inevitably encounter angle bracket type variables (see previous post, they're not going away nor are they "advanced") and I've got to learn that too?? I don't think we're doing them any favors with this proposal.

Side note about the name of the proposal

I think that "Opaque Parameter Declarations" is a misnomer. Generic arguments types are already opaque to the callee, sugaring from <T: P>(value: T) to value: some P does nothing to make the type of value more opaque than it already was. A more appropriate name might be Anonymous Argument Type Variables, because all we're doing here is obviating the need to name a generic type before you can use it (but only for arguments, not for a return type, because that would clash with opaque return types and will never be possible using the word some).

5 Likes

+1. I don't have anything to add to the discussion except for my comment on "Light-weight same-type requirement syntax" pitch.

Regarding this comment from your other post:

ā€œI believe Swift will become the language that introduces the majority of new programmers to generic programming in a few years.ā€

Iā€™m genuinely curious, whatā€™s the justification for this belief?

There's something in this reasoning. Should we use different names for different concepts? For example this or a better alternative:

func makeSomething() -> some P
func takeSomething(value: generic P) 
3 Likes

I don't feel like these are two separate concepts at all. In both cases, some P means some specific kind of P; you just don't know what it is. That's true in the parameter position, and it's true in the return position.

You can describe some P in return position as "reverse generics" if it helps you understand the concept, but that is not the official description of the feature. It's an "opaque type"; some specific type that conforms to P, you just don't know exactly what it is.

You can describe some P in parameter position as "generic" if it helps you understand the concept, but you don't have to understand generics to use it. It's exactly the same concept as in return position; when you receive it, it's some specific type of P; you just don't know exactly what it is.

I don't see why the "callee-constructed"-ness of the type would be understood as essential to some here. The name some doesn't suggest "callee-constructed". I would think that the callee-constructed-ness of it would be inferred not by the keyword some, but rather by the fact that the value is presented as a return type, and the return type comes from the callee, so the callee must have constructed it.

They'll only decide this if they're already familiar with generics. I view this feature as a nice step in Swift's philosophy of progressive disclosure. Most users a few years from now will likely encounter func f1(p: some P) long before they encounter func f2<T: P>(t: T). If that's the case, then users wouldn't immediately start by seeing some P as a sugar for genericsā€”rather, generics would be seen as a more advanced version of opaque parameter types, to be learned later.

In short, I like this proposal. +1.

10 Likes