SE-0244: Opaque Result Types

It isn't solely about performance. As you note, existentials and type erasers have fundamental limitations of their own by their nature. AnySequence could achieve some of the benefit of hiding the implementation type, but it wouldn't be possible for an existential or type eraser to ever provide conditional conformances to fully express the interface of collections. Granted, this initial revision of opaque result types doesn't either, but it could get there in time.

Even for a relatively trivial protocol example like Shape, where type erasure could be a workable approach, there are benefits to preserving and enforcing type structure beyond performance. Using AnyShape in the example would allow an implementation of GameObject to return arbitrarily different shape compositions in different conditions:

struct Spinner: GameObject {
  var shape: /*any*/ Shape {
    if angle == 0 { return Rectangle() }
    return Transform(Rectangle(), by: Rotation(angle))
  }
}

If the consuming framework does animation interpolation or anything like that, the interpolator could be extremely confused if it got very different shape graphs between ticks. By encouraging objects to return a consistent shape type, the interface also encourages a consistent shape structure, which makes higher-level reasoning about objects easier.

Without a constraint, the associated types aren't known, beyond being specific types related to the opaque return type. This is like accepting a generic argument constrained to Collection without further elaboration; you don't know exactly what its Element is, but you know it is a specific type. That means you can take elements out of c and put them back in, but you wouldn't be able to do this:

without being able to express that the return type's Element == Int.

You're right, the latter wouldn't be accepted, because there's nothing in the declaration to bind the underlying type from. I personally suspect that opaque types would be most useful with "function-like" get-only computed properties and subscripts, and that you're right that they wouldn't be terribly useful otherwise with more traditional storage-based properties.

This would work by giving vf1 its own opaque type.

Thanks!

Yeah.

It wouldn't be very useful to use a final class constraint since it's effectively a same-type constraint, but I don't think we ban that in general.

This is a temporary restriction; we could generalize to allow opaque types in structural positions in types in the future. I've updated the proposal to include this. Note that some Collection where Element == some P would effectively be equivalent to some Collection, though, since you're not revealing anything about the Element to callers.

That's definitely a concern with introducing this feature. The evolution of traits and existentials in Rust is interesting to consider; like Swift, Rust originally gave trait existentials pride of place in the type grammar by making Trait spell the existential type, but they quickly found that their version of opaque result types was a better solution to many library design problems, and they demoted trait existentials by making them take on a keyword.

9 Likes

Opaque types require additional runtime support, so you'll need to be deploying to an OS that comes with a Swift 5.1 runtime to use them. Adding where clauses in the future, however, should not require any additional runtime support, since the implementation is being designed to accommodate them.

5 Likes

I was afraid you're going to say that. I don't know if there will be any convenient solution presented at WWDC this year or not, but right now it feels like opaque result types are designed for non-ABI locked platforms or far feature usage as many projects for Apple platforms are stuck to older minimal deployment targets which locks them down to the Swift 5 feature set. Anyways, thank you for making this clear to me.

There are enough hooks in the Swift 5.0 runtime that it ought to be technically possible to patch in support. I don't want to promise that up front since someone would still have to do the work to implement that patch (and in attempting to do so, they could in fact discover that it's not possible for some reason.)

I'm fine with no promises here and atm., but at least that gives me last spark of hope.

What is your evaluation of the proposal?
+1

My remaining concerns are mainly around anonymous vs named opaque types and the interaction with where clauses and constraints (i.e. Protocol<.AssocType == T> shorthand for combined protocol and associated type constraints without naming the constrained type). I understand the desire for shorthands, but personally would prefer always having named opaque types, so that where clauses et al would be natural and just would follow the already existing syntax. But more importantly, even the initial limited implementation in SE-0244 should try best to avoid getting into syntactical deadlock when the implementation is later extended. For example the idea in the proposal about extensions to typealiases as a way to solve conditional constraints feels like natural solution.

some -keyword feels appropriate for this feature. Also, no underscores please.

Is the problem being addressed significant enough to warrant a change to Swift?
Yes. It's needed for Collection in standard library and @nuclearace also pointed out the need for it in 3rd party frameworks.

Does this proposal fit well with the feel and direction of Swift?
Yes, as long as the details in the syntax fit well into Swift.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
Have been following and participating to discussion since Opaque result types - #350 by Ben_Cohen, have read the proposal, tried out the experimental toolchain.

Thanks! In that case this proposal has my support. Even if there are some cases we can't handle yet, it's still a step in the right direction for protocol-based design patterns.

1 Like

I have no vested interest in the proposal in either direction, but as a linguist, the discussion of the understandability of the term some caught my interest.

It seems to me everyone here innately understands it: “something that meets an abstract constraint but is otherwise unspecific”. Almost everyone has used it that way, whether they thought about it consciously or not:

In the subject line, but the precise position does not matter.

An unspecific sampling of questions.

Undescribed, abstract circumstances.

An unspecified subset of available design patterns.

A convenience method, but no particular kind.

I vote not just for some keyword that happens to work, but specifically for one that is concise and intuitive: some.

13 Likes

There are actually two ways that "some" is used in your examples. The first is as a "specific some" and the other is as an "arbitrary some". For example, "it's not possible for some reason" says that there is a specific reason it's not possible, we just don't know what it is. OTOH, "someone would still have to do the work" means that we aren't specifying the person who will have to do the work, but a person will have to do it.

Further examples:

Put it anywhere in the subject line, location doesn't matter.

In certain circumstances, which I have not spelled out here.

2 Likes

Yes, they can be sorted more by looking more closely. Regardless, in each case there is some form of specifying‐but‐not‐specifying going on.

But the precise nature of that specifying-but-not-specifying is meaningful. In one case it is "anything which happens to satisfy these requirements" and in another it is "a specific thing which I have neglected to specify fully". To me, these roughly translate to the concepts of generalized existentials and opaque types, respectively, and this confusion makes me shy away from some. I maintain that opaque is the better name.

5 Likes

That is reasonable. I was not thinking about which computing concept is the best fit for the word, only about whether people are likely to know what it means. It has not yet occurred to anyone to use opaque in its general English sense in this thread.

Opaque normally means you cannot see through it, and it confused me at first with the thought that opaque was deliberately preventing optimization like inlining or specialization. But the whole point is that the compiler can see through it in order to do those things without needing an explicit name (except across ABI boundaries). I would have actually found transparent P more intuitive. But then I have not been using the equivalents in other computing languages or their terminologies before.

I’ve also been somewhat wavering on this proposal. I’ve settled on being weakly in favour; I do think the added complexity is a problem, but on the other hand I can also see how I could use this within my own code.

In particular, in many cases I use concrete return types where an existential return is not possible, or, in other cases, to avoid the potential overhead of existential returns. With opaque types, I’m given a way to restructure the underlying types used within my programs without needing to change the type signatures in other functions. It would also encourage writing those other functions in generic form.

I do have concerns, though, that writing code in that generic manner may lead to poor runtime performance in debug mode due to unspecialised generics; since I work in real-time 3D applications, debug performance is important and I fear may preclude me from using this feature in the most natural, protocol/generic-oriented way.

I also worry about the experience for those learning the language, since it’s more language surface area that will appear relatively early in using stdlib types/functions. Having very good diagnostics and relevant errors would be enough to mitigate this; however, Swift currently cannot also manage those. For example, what would a user be expected to do if they encounter a “complex return type” error which they have to manually disambiguate in a closure when they’re processing opaque result types?

1 Like

When all the necessary language features are baked things become somewhat easier for beginners. For example, a library method returning some Collection<.Element == Int> could be used in contexts where the type is Collection<.Element == Int> (i.e. a generalized existential).

2 Likes

It's not exactly a witness table. When a function with an opaque result type is compiled, the public version of it will return a pointer to the result, and the compiler will generate thunks (tiny helper functions) for each protocol requirement which take that pointer and call through to the implementation for the concrete result type. Clients of the module will statically call those functions rather than trying to dispatch through a witness table.

That makes opaque types faster than a generalized existential could ever be. The call from the client to the thunk is resolved through the dynamic linker, which is a resolved-once form of dynamism that is much cheaper than a witness table dispatch; the call from the thunk to the actual implementation is totally static and optimizable.

This sounds like a big subsystem, and it is, but that subsystem already exists. Resilient types use the same pointer-return-plus-thunks setup to allow the implementation of a type to evolve. This proposal basically just applies that existing machinery to a new feature.

Edit: Joe points out below that it's only fully static in the ideal case—sometimes witness tables are still involved. However, there are other performance improvements compared to existentials such as a smaller return value that you'll get every time.

15 Likes

Ah! That makes sense — an extra indirection, but a statically dispatched one.

That also explains several remarks about API future-proofing (e.g. here): a module can emit a thunk for every past API state as long as the present types are compatible with the past ones.

2 Likes

Yes, this is one of the strategies we can use to introduce features without breaking ABI. It has a cost in code size and you need to be careful about backwards deployment (not generating calls to the new thing when you're only guaranteed to have the old thing), but it's one of the tools in our toolbox.

2 Likes

The implementation for an opaque type in another module does use witness tables just like a generic parameter; it has to in order to accommodate protocol resilience. This wouldn’t be any more expensive than a stub, since calling a function in another dynamic library is also an indirect jump (and an easily predicted one in either case, since it always jumps to the same place).

8 Likes

Huh, I’m reconfused. What then is the performance advantage of an opaque type over an existential when called from another module? Is it just the consistency of that branch prediction? Or is there another indirection? Or…?

1 Like

@Joe_Groff one thing I have not seen yet mentioned anywhere is the associated type default.

As opaque types can be inferenced as associated types there must be a way to provide a default associated type that already is an opaque type, no? I think we cannot use it directly after the associated type constraint as it will require two = which reads very strangely. An opaque type alias could help, I think. Can you clarify if this is also a future direction of opaque types?

Consider the following example:

public protocol CaseIterable {
  typealias OpaqueAllCases: some Collection<.Element == Self> = [Self]

  associatedtype AllCases: Collection<.Element == Self> = OpaqueAllCases
  static var allCases: AllCases { get }
}