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).
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…?
@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 }
}
The performance benefit is largely dependent on generic specialization, much like other generics features. Specialization of an opaque return type is more likely than specialization through an existential return. In the former case, if the implementation is visible to the compiler, then the opaque type can be completely substituted away with the underlying type. Specializing an existential return requires more analysis, to determine that the function body in practice only returns one concrete type so its return type can be narrowed, and this analysis could fail.
Across modules, the performance considerations are similar to those for generic vs existential arguments; an opaque result value is never boxed, but otherwise requires indirection for all its operations. The performance benefit over existentials would come from the lack of boxing and more predictable branching.
So this has to be specified yet? (I guess it should be ;-)
Afaics, methods with opaque result types are the main selling point, but what about properties?
Observers would be blocked by final
, although they shouldn't interfere with some
… maybe final
isn't needed at all, as long as return super.
is enforced?
As I recently complained about adding a new keyword:
How about recycling something we already have?
func makeMeACollection<T>(_: T.Type) -> [T] as MutableCollection & RangeReplaceableCollection where Element == T {
return [T]()
}
This would also resolve another nuisance I have with some
:
When declaring a property, its actual type is inferred in an imho rather intransparent way.
Good idea, but the type we aim at hiding is now... visible :-)
To be honest, it would be OK if we just had to write code. But we also have to, say, write documentation, and display the "canonical signature" of the method:
// OK
func makeMeAShape() -> some Shape
// What do I write here???
func makeMeAShape() -> <redacted> as Shape
To be honest (2), we lived with a similar situation when the Swift doc would display default
instead of the actual parameter default values:
func foo(bar: Int, baz: String = default)
This was painful :-) Do we want that again?
We could use the _
to indicate that the type should be inferred. Then, @Tino's syntax works for both the "want to specify" and "want to infer" cases:
func makeMeACollection<T>(_: T.Type) -> _ as MutableCollection & RangeReplaceableCollection where Element == T {
return [T]()
}
Doug
And would it also be a solution to the naming of opaque types?
func makeMeACollection<T>(_: T.Type) -> C as MutableCollection & RangeReplaceableCollection where C.Element == T {
return [T]()
}
That is possible, but it is a different meaning from what @Tino proposed: here, you are introducing the name C
for the not-yet-known underlying type, so that you can refer to it in the where
clause. In @Tino's proposal, the type before the as
is the underlying type. The same code, below, would have different interpretations in your approach vs. @Tino's approach:
func makeMeAStringThing() -> Substring as MutableCollection & RangeReplaceableCollection where Substring.Element == Character {
return "foo"
}
I personally don't feel that we need to re-use existing keywords for this feature: it's a new idea and should get it's own keyword, and our implementation is capable of treating this as a contextual keyword to avoid breaking code.
Doug
Yes, this was a little ping pong game between @Tino you and me. Apologies if it went off too quickly :-)
I agree with this sentiment. More generally, I don't think Swift should be overly afraid to introduce new keywords for things that existing keywords don't logically extend to as well as a new, better descriptive keyword would. That being said, I think we should also look, when we're thinking about adding new keywords, how conducive they would be for extension in other future contexts.
I also think this is a much better syntax (although for a feature that I dislike either way).
No, I think you’re just misreading the proposal.
I thought the proposal was clear on this point:
Similarly to the restriction on protocols, opaque result types cannot be used for a non-final declaration within a class:
class C { func f() -> some P { /* ... */ } // error: cannot use opaque result type with a non-final >method final func g() -> some P { /* ... */ } // ok }
I'm a bit concerned as well that we paint ourselves into a corner with the proposed syntax. I'd be in favor of having named opaque types and using existing syntax to constrain them. After some brainstorming I came up with the following:
- Use a symbol to declare type erased output types (I hope that is the correct term here). Type erased output types can be opaque or existential types.
- For now I chose to use ^ for declaring type erased output types (bikeshed). The ^ is prefixed to the type name where it is declared as output type. After that, the type can be used as any other type, so the ^ is not required in any type constraints.
- By default, a type erased type is an opaque type.
- In the future a keyword could be used to declare a type erased type existential.
I'm not sure what will actually be possible to implement in the language, but here are some examples:
// Simple opaque type:
func makeOpaqueCollection<T>(with element: T) -> ^C: Collection {
return [element]
}
// For more complicated constraints, a where clause can be used:
func makeOpaqueCollection<T>(with element: T) -> ^C where C: Collection, C.Element == T {
return [element]
}
// An optional opaque return value:
func makeOptionalOpaqueCollection<T: Numeric>(with element: T) -> ^C? where C: Collection {
guard element > 0 else { return nil }
return [element]
}
// A tuple of two opaque collections guaranteed to be the same type:
func makeTwoTheSameOpaqueCollections<T>(with element: T) -> (^C, ^C) where C: Collection {
return ([element], [element])
// Two calls to makeOpaqueCollection(with:) return the same type,
// so the following is also possible:
// return (makeOpaqueCollection(with: element), makeOpaqueCollection(with: element))
}
// A tuple of two opaque collections not guaranteed to be the same type:
func makeTwoOpaqueCollections<T>(with element: T) -> (^C, ^D) where C: Collection, D: Collection {
return ([element], [element] as Set)
}
// A tuple of a collection and array of collections. The single collection is
// guaranteed to be the same type as the collections in the array:
func makeSomething<T>(with element: T) -> (^C, [^C]) where C: Collection {
return ([element] as Set, [[element] as Set])
}
// It works the same for properties:
struct EightPointedStar: GameObject {
var shape: ^S where S: Shape {
return Union(Rectangle(), Transformed(Rectangle(), by: .fortyFiveDegrees)
}
}
Existentials
In the future, existential could be created with the 'existential' (bikeshed) keyword:
// A simple existential:
func makeExistentialCollection<T>(with element: T) -> ^C where existential C: Collection, C.Element == T {
if Bool.random() {
return [element]
} else {
return [element] as Set
}
}
// A tuple of two existential collections guaranteed to be the same type:
func makeTwoTheSameExistentialCollections<T>(with element: T) -> (^C, ^C) where existential C: Collection {
if Bool.random() {
return ([element], [element])
} else {
// The opaque type will be returned as an existential
return makeTwoTheSameOpaqueCollections(with: element)
}
// Two calls to makeOpaqueCollection(with:) return the same type,
// so the following is also possible:
// return (makeOpaqueCollection(with: element), makeOpaqueCollection(with: element))
// The following is not possible, because the two types are not guaranteed to be the same:
// return (makeExistentialCollection(with: element), makeExistentialCollection(with: element))
}
// A tuple of two existential collections not guaranteed to be the same type:
func makeTwoExistentialCollections<T>(with element: T) -> (^C, ^D) where existential C: Collection, existential D: Collection {
// In this case it is possible to use makeExistentialCollection(with:),
// because the two types don't have to be the same.
return (makeExistentialCollection(with: element), makeExistentialCollection(with: element))
}
// A tuple of an opaque and an existential collection:
func makeOpaqueAndExistentialCollection<T>(with element: T) -> (^C, ^D) where C: Collection, existential D: Collection {
return (makeOpaqueCollection(with: element), makeExistentialCollection(with: element))
}
As I said before, I don't know what the language can support, but if at least a few of these examples will be possible in the future, a syntax like this to me seems more flexible and has the advantage that it reuses a lot of what already exists.
Edit: Thinking about it some more, I guess type erased output types is not the correct terminology. Perhaps all types declared with ^ are opaque types. After all, you don't know what you're getting in any case. Then we have the following 2 variants:
- fixed (formerly opaque)
- variable (formerly existential)
I hope I'm making some sense here
I came up with the ^ prefix because I wanted a way to declare the output types that wouldn't break up the space between the -> and the return types with an extra sort of generic type parameter list <C,D>. However, perhaps a good alternative is to declare the output types in the existing generic type parameter list, but with a separation to make it clear that these are not generic parameters, but output types. A pipe could be used for example. If we do that, we don't have the visual noise of ^, but everything else stays the same:
func makeTwoOpaqueCollections<T | C, D>(with element: T) -> (C, D) where C: Collection, D: Collection {
return ([element], [element] as Set)
}
Both have advantages I guess. The ^ prefix makes it very clear that those types are opaque, but without the ^ it looks cleaner.
- What is your evaluation of the proposal?
+1.
I like the keyword some
because it seems natural that some Foo
type and other some Foo
type mean different types.
var a: some Foo = foo1()
let b: some Foo = foo2()
a = b // Error: `some Foo` and `some Foo` are different types
I think that it is less obvious if we adopt the keyword opaque
.
Although I think the proposed syntax works well enough, I am not sure if it is the best one. I think generic arguments and opaque result types are dual. For example, for the following two functions,
func useFoo<F: Foo>(_ foo: F) { /* ... */ }
func makeFoo() -> some Foo { /* ... */ }
- users of
useFoo
decide a concreteFoo
type and implementers of the function use an abstractFoo
type - implementers of
makeFoo
decide a concreteFoo
type and users of the function use an abstractFoo
type
Both of them are counterparts of existential types. They have similar roles for arguments and return values respectively.
However they don't have syntactic relations. It is possible to imagine some syntactic relations between them. For example, as implementers of functions give temporary type names for arguments by type parameters, it may be also possible that users of functions which return an opaque result type give temporary type names to return types. Such syntax makes it possible to avoid errors caused by assignments between (same-looking) some Foo
types as shown above by giving them different names.
- Is the problem being addressed significant enough to warrant a change to Swift?
Yes.
- Does this proposal fit well with the feel and direction of Swift?
Yes.
- How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
Just read the proposal.
You're correct that they're duals. I touched on this briefly in "future directions". It would be great to eventually support some Foo
as an argument type as well, which would let you write:
func useFoo(_ foo: some Foo) { /* ... */ }
func makeFoo() -> some Foo { /* ... */ }
making the duality more apparent.
I do like the direction this is going, but I feel strongly that the word some
is cutesy and unnecessary.
The feature is clearly called "opaque types" as evident by how we talk about the feature in this very thread. Why not use a very precise term of art opaque
? It seems easier to google and better matches how we talk about it orally. The counter argument that it may be confusing in cases such as
func translucentRectangle() -> opaque Shape { /* ... */ }
… seems very contrived and rare. However users will probably discover some
every day, and have a hard time dealing with the fact that it is hard to google and hard to talk about.
Cute, but unnecessary.