SE-0244: Opaque Result Types (reopened)

As was mentioned in Improving the UI of generics - #46 by Joe_Groff thread, I'd much prefer using syntax of

func makeAnimals() -> (some Animal T, some Animal U) { ... }

for naming the types, applying to both some and any types. And in both parameter and return type positions.

3 Likes

Overall +1 on the Opaque Result Types proposal. I truly appreciate the "MVP" nature of this proposal. Not trying to cram in too much in the first round of this feature. Well done!

I'm guessing you didn't read any of the follow-up posts, nor the revised proposal. A method for resolving this difficulty is explicitly mentioned as a future direction. It does no good to make a snarky comment that contains a blatantly untrue statement.

2 Likes

Please keep your comments constructive. You are welcome to criticize the proposal for the reasons you give, but the tone needs to be consistent with the code of conduct. This means not attacking the authors of proposals, and not using hyperbolic (and politically charged) analogies.

11 Likes

As long as that difficulty isn't resolved, the proposal isn't useful at all. So having it in "future directions" wouldn't be enough.

Also, I don't see anything in "future directions" that would actually resolve it, do you mind pointing me towards what you were talking about?

Sure, silence me if you need to, but everybody that takes an actual look at this will see that in terms of what would happen if this proposal would be accepted, the only difference is that they replaced the opaque keyword with some. It's exactly the same otherwise. Exactly as bad as it used to be.

One of the future directions (at the very bottom of the proposal) is introducing any as a dual to some for explicitly spelling existential types.

Unless I am mistaken about the proposal (which I may well be), this would allow you to write something like

let d: [any A] = [b(), c()]

But any would be an existential type. If I need existential types to use my opaque types properly, then opaque types aren't useful alone. And if that's the case – if I'm going to end up using existentials anyways eventually in the code – why not just use existentials to begin with?

func b() -> any A {
    return ...
}

That's a pretty large generalization; opaque types are useful "alone" (without existential types), as the proposal demonstrates with many different use-cases. If you closely read the proposal, you will find that some is actually intended to be implemented as syntactic sugar for "reverse generics," as they are referred to in the proposal. Using some is not the same thing as using an existential type.

3 Likes

There are some holes, but I still think the feature is useful by itself. The important thing is that there is a path to eventually allow arrays and optionals of these types in the future.

We definitely should double check that we aren't painting ourselves into a corner...

I would personally like to see the ability to define one of these Opaque types in a typealias to be used within the entire type, and it would be known to be the same type throughout the type. Not sure how hard that would be on the type checker though.

If it was possible, it would also eventually allow these opaque types to be passed in parameters, which would open up a bunch more use-cases. Basically, a collection could say: I am going to give you an index, but all I am going to guarantee about it is that it has properties X,Y, & Z (e.g. it is Strideable). Then I could pass it back to a function with the guarantee that it is the same type (even though I don't know type that was).

struct MyType {
    typealias PartId = some CustomStringConvertable

    func part(under point: CGPoint) -> PartId
    func selectPart(_ id:PartId)
}

Doesn't need to be in this version, but would be nice to know if that would eventually be possible. I can imagine that this might put strain on the type checker though if we don't have a way to give it a hint about what that type should be. Maybe you have to define the actual type as part of the typealias, but all that is exposed outside of the typealias is the opaque type...

Regarding composability, I suggested earlier that we need a way to name the opaque type to fix this. Apparently it "would be easy to add". Beside solving the comparability problem, I believe it would also be a good teaching tool.

It'd still be a nice gesture to see this acknowledged in future directions. It would indicate the author of the proposal understands there is a potential issue there and that it can be solved, even if only in a hypothetical future. It was a concern for more than one people in the previous review thread after all, so I too would have expected to see a mention of it.

I also note the "UI for generics" document is silent on this topic. I find it's a bit like looking very far ahead while missing the immediate issue right under our nose.

That said, adding a way to express the type name is something that can be fixed later. If it takes some pain to discover we need a way to name the type, then it's unfortunate but so be it.

The section on Opaque type aliases has this to say:

This would be a great feature to explore as a future direction, since it has some important benefits relative to opaque result types.

One can argue that this should be a part of Future Directions, but one cannot make the case that it's not addressed in the proposal.

1 Like

If you can change the original function and replace the anonymous opaque type with an opaque typealias, then great! But returning an anonymous opaque type you can't refer to (by name or otherwise) is still a composability problem the API writer can inflige on its users.

I don't see how this is functionally any different than a function which returns a custom type. It can't compose with other functions because no other function returns the same type. You're arguing that when the compiler knows (or can be made to know) that 2 functions do return the same type, it should allow composition. I don't think anyone disagrees with this.

One may also argue that opaque signatures which are spelled the same are going to appear to be the same, even though they aren't. That may also be a concern, but I don't think you can get away from that, as it's a fundamental aspect of the feature.

From my comment in the previous review thread:

If you can't name it, it's difficult to store as a variable in a struct or elsewhere, and it's difficult to write wrapper functions without each of them creating a new distinct opaque type.

In other words, when using functions returning opaque types, you now have an inability to write convenience wrapper functions that return the same type:

// module A, maintained by X
func makeShape(params: [Any: Any]) -> some Shape { ... }
// module B, maintained by Y
func makeCircle() -> some Shape {
  return makeObject(params: ["type": "circle"])
}
func makeSquare() -> some Shape {
  return makeObject(params: ["type": "circle"])
}
makeCircle() == makeSquare() // error: two distinct types
[makeCircle(), makeSquare()] // error: two distinct types

All maintainer Y can do is beg maintainer X to not use an anonymous type and use a named type (opaque or not). Either that or Y has to avoid writing helper functions of this sort.

There is no syntax to express the name and that makes the type more difficult to deal with in ways that didn't exist before in the language. So it is definitely different than a function which returns a custom type today.

2 Likes

I agree. But this proposal is adding a feature that doesn't exist today. No one will force developers to use opaque types before they allow more convenience for downstream developers. It lays the groundwork for such improvements, while also giving a feature that is useful in limited contexts. One could make the case that having a half-baked feature will cause short-term harm, but that's very different than Vogel's assertion that such harm is endemic to the feature.

"No one will force developers to use it" is not exactly a convincing argument in favor of this (or anything). And the problem is that even if I don't use it, if any code that was written by someone else and will eventually be consumed by me uses it – let's say a framework – then it is limiting how I can make use of it.

Currently, no matter how a framework is written, I can

  • Choose some function f in that framework
  • Create my own function g that ultimately calls f and propagates its return value
  • Do something like this: [f(), g()].

And that's a strong guarantee: I can do that with any function. I don't require cooperation from whoever created the framework. I don't need to care who wrote that function.

This would no longer be possible with opaque return values. Any code that utilizes it would place strong constraints around how much further processing is possible.

2 Likes

Your complaint is valid. The tone of your first comment was not. We can debate whether a half-baked feature should be added, but it's not valid to claim that there's no path to fully-baking the feature.

5 Likes

Then it should become a proposal again when it's not half-baked anymore, and not now. Then I won't be able to say that it's the same as before, because it won't be.

I see where you’re coming from here, and I also think it would be preferable to solve the naming issue. That said, I think this example handily demonstrates that the need will be less common than you expect.

This API seems to suggest that makeShape can represent any shape as a concrete type. If there is such a universal concrete shape type, an opaque return type is probably the wrong abstraction. makeShape should return the concrete type, possibly wrapped in a struct to hide internal details (a zero-cost abstraction as long as all proxying accessors and methods are inlineable).

On the other hand, let’s consider this alternative API:

func makeCircle() -> some Shape { ... }
func makeRegularPolygon(sides: Int) -> some Shape { ... }
func makeSquare() -> some Shape { return makeRegularPolygon(sides: 4) }
func makeHexagon() -> some Shape { return makeRegularPolygon(sides: 6) }

In this situation, you might argue that you “know” squares and hexagons are both instances of an underlying polygon type. However, it’s not clear that this information should be exposed to the client. If you’re using opaque types to express that circles and polygons are not (necessarily) the same, it’s consistent and logical to also express that squares and hexagons are not necessarily the same, even if they happen to be implemented the same today. One day you might want to implement squares as a kind of rectangle instead.

I suspect this ties into @Joe_Groff’s finding that this problem doesn’t arise much in Rust: in many cases, same-type-constraints on opaque returns are probably a code smell. That said, reasoning about appropriate use of opaque types is unpleasantly subtle, which brings me back to my concerns about how to teach this stuff.

1 Like