SE-0244: Opaque Result Types

Very interesting use case, I didn't thought about it! I, too, have too many public types and APIs I wish were hidden.

1 Like

I keep waffling. In principle, I think I like it. I probably like what it could be after several iterations. I worry that in this first iteration, due to the things discussed in Future Directions, it will be an attractive nuisance that will bite people when they try to use it for any but the most basic cases. That said, the same could be said of PATs in the early days of Swift (and arguably still to some extent...), but you have to start somewhere.

I would oppose this change if it were an isolated feature, but as part of a long-term strategy, I believe it's a good first step.

In this iteration, no. I think its use cases are very stdlib-specific, and not even very useful in stdlib (I'd like more discussion of how stdlib will improve with this change in the Implementation Strategy section). But eventually, yes, I think it will grow into an important feature.

By "not even very useful in stdlib," I mean that places that feel like obvious uses, I believe are unsupported. For example, my immediate expectation was that this could be used to re-signature map:

protocol Sequence {
    ...
    func map<T>(_ transform: (Self.Element) throws -> T) rethrows -> some Sequence
    ...
}

But this is currently forbidden, and would require a lot more features to allow it. I'm guessing will allow getting rid of some of the tedious structs like FlattenSequence and Zip2Sequence? But I assume those could also be eliminated with AnySequence, right? (With possibly a runtime cost?)

The impression I get, particularly from the Shape discussion, is that the driving issue here is the ability to avoid runtime overhead. If it weren't for runtime overhead, then just using Shape (or creating an AnyShape eraser), would be a perfectly fine solution. Is that fair?

Generally yes.

Nope.

3-4 hours on the proposal and writing this up, but I haven't read the evolution thread for background.

Some more comments:

Proposed solution

The motivating example I think is confusing, or else I'm actually confused about how this works:

func makeMeACollection<T>(with element: T)
     -> some MutableCollection & RangeReplaceableCollection

As a top-level function, how does the compiler know what the associated types are? Or is this really a method inside of some other collection? (The example code really looks like a top-level function.)

Are those knowable to the caller? In particular, is this legal:

var c = makeMeACollection(with: 17)
c.append(1)

It feels like it wouldn't be, but that makes me question what I could do with this.

Properties and subscripts

let strings: some Collection = ["hello", "world"]

This is legal, but are you saying this isn't?

let strings: some Collection
init() { self.strings =  ["hello", "world"] }

That feels like a difficult restriction because it prevents calculating the value of strings during init. Is this a necessary restriction? Can strings be lazy?

A core limitation of this proposal is how types are return-only and unnamed, and I think it's going to lead to a lot of people getting themselves in a type corner as their program evolves. In particular, the subscript feature is exactly the kind of thing that is likely to raise a lot of StackOverflow questions with "opaque types are the wrong tool" being the answer. Consider an additional method:

public append(p: /* some? */ P) {
    storage.append(p)
}

This isn't legal, because there's no way to know that P is Impl. I think it's impossible to write an implementation the allows vendor.append(vendor[0]), and I think that's going to surprise a lot of people who try to use opaque types.

Associated type inference

When you say "there is no direct way to name the opaque result type," I want to confirm that this is legal:

let vf1: some P = f1()

That's important because sometimes it's required to add unrequired type decorations in order for the type checker to function.

Opaque result types vs. existentials

I believe there's a typo in this section. The function declared is isEqualGeneric, but the example code then calls isEqual. I think the point was for those two to be the same function.

Type identity

I assume that type(of: c) will also return the concrete type at runtime, correct?

Grammar of opaque result types

"...or base classes..." Does this mean that final classes are not permitted (that would make sense; I'm just trying to be sure I understand).

I believe that the exclusion of (some P)? needs more explanation, here or in Future Directions. Is this expected to be a temporary restriction or a key part of how the feature works? If a key part, it feels like a fairly major restriction and deserves some discussion. Is the intent that you just use P? in that case? Are generalized existentials the better answer?

I assume this also extends to forbidding [some P]. Is it possible to return some Collection where Element == some P? Or do we just go back to [P]?

There seems a big tension between what you can and cannot do with a P and what you can and cannot do with a some P, and I do worry about developers churning their types back and forth trying to get the set of features they need, since each has its own significant restrictions (much like PATs, generic structs, and classes today).

9 Likes

This is not a fundamental limitation. It could be added later. I'll include this in the "future directions".

8 Likes

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