SE-0328: Structural opaque result types

Hello, Swift community.

The review of SE-0328: Structural opaque result types begins now and runs through November 11, 2021.

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/master/process.md

As always, thank you for contributing to Swift.

Ben Cohen
Review Manager

23 Likes

+1 on a quick read: makes sense, and addresses a prominent generalizability gap. Named opaque types are the obvious missing next step, and the way the future directions address is entirely satisfactory wrt what’s proposed here. Nice work.

1 Like

+1. Makes sense. I want to commend the authors for writing an excellent proposal, which addressed all the vague concerns I had after reading the motivation.

Great set of improvements, excellent writeup. +1!

+1 for sure. I have wanted these kinds of things before so it will be very useful to enable this functionality.

+1. I agree with the design of structural opaque result types.

I have unresolved question in the pitch thread.

1 Like

Yes, returning the metatype of an opaque type is enabled by this proposal (although it looks like there's a bug in the implementation that reports an error for your example after correctly inferring that the underlying type is Int).

5 Likes

All great. :smiley_cat:

But as "more APIs [are] expressed using opaque result types", you'll find more cases where being explicit is necessary. I'd rather this not be undertaken without as some support, so that the new APIs can match the ergonomics of their existential variants.

E.g. given:

protocol P1 { }
protocol P2 { }

struct S: P1, P2 { }

Explicitness with existentials is supported in-line.

func ƒ(_: P1) { }
func ƒ(_: P2) { }

ƒ(S() as P1)

But with generics, it is not.

func ƒ<T: P1>(_: T) { }
func ƒ<T: P2>(_: T) { }

let p1: some P1 = S()
ƒ(p1)

So, please add that capability in:

ƒ(S() as some P1)
func ƒ() -> (some P1)? { S() }
func ƒ() -> (some P2)? { S() }

ƒ() as (some P2)?

The last example requires overloading based on opaque return type to be supported. Hopefully that's coming along with this proposal too?

func ƒ() -> some P1 { S() }
func ƒ() -> some P2 { S() } // Invalid redeclaration of 'ƒ()'
1 Like

I’m struggling to understand what this would enable. An argument of type some P1 is strictly less useful than an argument of (existential) type P1. Values of subtypes can already witness supertypes, so a value of type S can already be passed as an argument of type P1. Therefore there is no reason to ever create a temporary value of an opaque type.

I am pleased to see this proposal come to review. It lifts some restrictions in the use of opaque types and, as such, fits very well with the direction of Swift. As I'm not a Rustacean, I can't say I have extensive experience with the corresponding feature in Rust, but based on my limited understanding I think it compares favorably. I have thought about this topic at some length and studied both the original pitch and this revision.

Looking at this with fresh eyes after some time (and having not re-examined my prior opinions in the pitch phase), I do have some thoughts about the detailed design which may have evolved since prior--

Syntax for optionals

I very much appreciate the rationale behind requiring the parens in spelling (some P)?. Indeed, it is unimpeachable that this is the clearest possible spelling. However, the following points (which I agree with--and I think would be generally agreed upon) give me pause:

...a user's first instinct might be to write some P? . This latter syntax is moderately less verbose, and is, in fact, unambiguous...

...since P? is never a correct constraint, it would be possible to (and in fact this proposal's implementation does) provide a "fix it" to the user which suggests that they change some P? to (some P)?.

True, the fix-it certainly minimizes the burden to users. However, I'd argue that whenever there's a spelling for something that's both intuitive and always unambiguous (a rarity), anything required on top of that surely has to be, by the same token, unnecessary ceremony.

I'm not sold on the argument that some has to bind less tightly than ? just because -> and & do: Swift already goes through contortions for operators such as try for user ergonomics. And indeed, we admit that users' intuition here doesn't require some to bind less tightly.

That said, it is easier to add a special rule than to take one away, so I would be content with leaving the rules as-is for now and circling back to this at a later time if our expectations are proved out that users will widely expect to be able to write some P? without parens.

Higher order functions

This idea of features being easier to add later than to take away leads me to the proposed syntax as it applies here:

Consider the function func f() -> (some P) -> () . The closure value produced by calling f has type (some P) -> () , meaning it takes an opaque result type as an argument. That argument has some concrete type, T , determined by the body of the closure. [Emphasis mine.]

This decision should be considered in the context of generalized some syntax, which we are likely to implement in the future.

I think it's important to spell out that consideration. It took me some time in another conversation (thanks to @ensan-hcl, as I recall) to appreciate the issue here:

The "generalized some syntax" which is mentioned specifically in Future Directions would have (some P) -> () as a shorthand for a generic constraint, equivalent to <T: P>(T) -> ()—that is, the caller of the closure would determine the concrete type.

However, in the case of the closure returned by f discussed above, (some P) -> () actually means (T) -> <T: P>() (to use the "fully generalized reverse generics" syntax also mentioned in Future Directions)—which is to say, the callee (i.e., the closure) determines the concrete type.

There has been some disagreement in that prior conversation about which of these scenarios should be endowed with the spelling of (some P) -> (), but for certain they cannot both be, since they mean very different things. For this reason, if this proposal were to be accepted as written allowing higher order functions declared func f() -> (some P) -> (), it would make the proposal's own future direction impossible due to ambiguity.

I don't think we have to resolve the disagreement here (although I happen to agree with the proposal's future direction). It would suffice to disallow such higher order functions with opaque types, just as the proposal suggests as an alternative, since it is difficult (usually impossible) to actually call the returned closure anyway and it would be of limited use. We could then resolve the question regarding "generalized some syntax" at a later time, and indeed we could still allow the use of higher order functions returning possibly uncallable closures at a later time when "fully generalized reverse generics" are made possible.

This course of action would be consistent with that line of thinking that it is easier to add features later than to remove them, allowing currently unsettled questions to be settled later rather than foreclosing them for the limited purpose of allowing higher order functions to return usually uncallable closures.

14 Likes

Generally +1 except for the same concern that @xwu expressed above, about higher-order functions. I don't usually comment on swift evolution threads but this one jumped out at me when reading the proposal.

"This decision should be considered in the context of generalized some syntax, which we are likely to implement in the future" sounds good to me (that's in "Alternatives considered"), but it seems like that should mean we don't allow (some P) -> () yet, to avoid that later discussion being overly constrained by source breakage.

I see the argument (paraphrased) "we shouldn't prevent stuff just because we think it's mostly useless" from the same section, but I don't see explicitly the counterargument, "perhaps we should prevent this particular stuff to reserve space we know we need for later decision-making." The proposal should certainly make that decision-making visible in "Alternatives considered", and I hope it can be reversed.

  • 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?

Yes, both in itself and as part of the larger effort to broaden and smoothen all the generic-related type features.

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

n/a

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

Read carefully

Dropping by to share that I just had someone looking to adopt swift list this as one of the blockers in a design they were trying out and they very really excited to see this pitched :slight_smile:

Read through myself as well and looks great, looking forward to this limitation be lifted!

+1

That is certainly an important point re the usual relative precedence of unary operators.

On the other hand, one could make a persuasive counterargument that this doesn’t have to hold—and shouldn’t—for non-symbolic unary operators:

Obviously, Swift isn’t a natural human language, nor do its operators exactly parallel math operators, but where possible we prefer fluent APIs and operators with behaviors that don’t run counter to their elementary school usages, for readability, intuitiveness, etc.

Therefore, consider, given the following natural language phrase:

How’s it going?

The only natural parsing is “(How’s it going)?” and not “How’s it (going?)”. By the same token, some P? reads naturally as (some P)?, and would not read naturally as some (P?) even if it were supportable. Perhaps we just need to adopt the Spanish punctuation rules: ¡some P! :upside_down_face:

Thank you for the precise summary of previous conversation (here). I appreciate it :bowing_man:

I think we should consider that the addition of T<some P> will increase the number of functions that takes reverse generic argument. For example, consider Array<some Numeric>. Calling append method must require reverse generic argument.

var array: Array<some Numeric> = [0, 1, 2, 3]
// it requires reverse generic argument
array.append(...)

With more examples of reverse generic argument, this proposal will strengthen the argument that some P should be used for reverse generic arguments, even if we rule out to use bare some P in argument position.

Again, I don’t think this is the thread to discuss that topic, but for clarity, I’m not sure I understand why this is a “reverse generic argument”:

The underlying element type is fully determined by the type of array, which is passed in as self by the caller.

2 Likes

FWIW, I disagree. The whitespace disparity to me strongly indicates that the ? binds to the P rather than to some P. I suspect I could eventually train myself to read it otherwise, and yes I can reason through why the some (P?) reading is nonsense, but it would introduce a slight cognitive load when parsing that form, and I don't see a great reason why it needs to be allowed simply because we can parse it unambiguously in the compiler.

11 Likes

I don't aim to discuss that, neither.

About the word 'reverse generic argument' is a bit confusing, sorry. I wanted to express 'it behaves as if taking reverse generic argument (or, opaque argument type)'.

I just mentioned that, if the number of reverse generic argument like behaviors increases, people will found it more strange that func foo(value: some P) takes generic argument. I tried to show that forbidding (some P) -> ABC and adopting this proposal isn't 'keeping unsettled' but makes situation changed a bit.


Maybe the simplest example is the next code. No one but the callee determines the argument type.

typealias Closure<T> = (T) -> ()
let closure: Closure<some P> = { ... }
1 Like

I‘m very much +1 on this proposal, as it closes a gap in the current language.

However I want to add something about functions with a signature of (some P) -> (). Maybe I‘m wrong, but the current discussion seems to forget that there are actually already such functions in the current version of Swift (and btw they’re quite awkward to work with, as many simple things don’t work like they normally do).
Consider the following example:

func foo() -> some BinaryInteger { 5 }

let bar = foo() // type of bar is 'some BinaryInteger'

let baz = bar.isMultiple(of:) // type of baz is '(some BinaryInteger) -> Bool

print(baz(7)) // prints '2'

These function types however are not like normal function types. For example we cannot write them in our code (e.g. as a type annotation):

let baz2: (some BinaryInteger) -> Bool = baz
// Error: 'some' types are only implemented for the declared type of properties and subscripts and the return type of functions

But this type clearly exists:

let baz3: (Int) -> Bool = baz
// Error: Cannot convert value of type '(some BinaryInteger) -> Bool' to specified type '(Int) -> Bool'

I think we should sort out relatively quickly, what some P in a parameter position actually means, because it basically already exists but isn't fully usable yet...

3 Likes

Thanks for this insight; it prompted me to realize that the distinction between a type parameter and an opaque type already exists today with regular type parameters, and it has made me more confident in the design choices made in this proposal.

Writing (some P) -> Bool in a type annotation is exactly what the proposal unlocks, and your example compiles with the flag to enable this feature. As you rightly point out, opaque types in structural positions are not really new with this proposal; this proposal merely allows you to spell them in source.

Today, and with this proposal, a value that contains an opaque type some P always means the underlying type has already been inferred using the callee's return value (or an initializer expression in the case of stored properties and local variables). You don't know what that underlying type is, and you can only interact with the type through the interface promised by the protocol constraint (hence the terminology opaque).

Allowing the use of some P in the types of parameter declarations to mean an implicit type parameter conforming to the protocol that gets substituted by the caller doesn't muddle that rule; it only changes where the type parameter is opened and substituted. It is still the case that when you have a value whose type contains some P, that is an opaque type that already has an underlying type. When you reference or call a function whose signature contains some P in parameter position, some P is a type parameter that is immediately substituted with the underlying type (which could be inferred from an argument provided to a call, a contextual type for a function reference, etc).

The rules here are exactly the same as explicit type parameters, e.g. <T>. When you have a value whose type contains T, that type has an underlying type that is opaque to you. You may have seen this type referred to on the forums or in the compiler codebase as an "archetype", but it's the same concept as an opaque type. It is only in the interface of a generic type or function that T means a type parameter. In expressions, T is always an opaque type/archetype.

4 Likes

I'm positive to the feature and how it has been proposed in its entirety.

+1 to the spelling (some P)?. Users should be able to naively replace T? with its unsugared counterpart Optional<T> without incurring in non functioning code. some P? wouldn't be able to be replaced with some Optional<P>.

+1 to having (some P) -> A to always follow the "type gets chosen by the callee" rule, even if it implies the existence of almost non-callable functions.

Isn't the quoted sentence in direct disagreement with the rule? Why would opaque parameter types be substituted by the caller, resulting in having (some P) -> A to be equivalent to <T: P>(T) -> A? Did I misunderstand?

1 Like