Does it make sense to allow an existential to be returned as an opaque value?

We have _openExistential in the language by default and automatic promotion because of the great work done here (swift-evolution/proposals/0352-implicit-open-existentials.md at main · apple/swift-evolution · GitHub) it raises the question if we could support the other way around - the existential returned inside of a property or function with an opaque return value.

protocol Foo {
    associatedtype Bar: Foo
    var bar: Bar { get }
}

struct Baz {

    let foo: any Foo

    var qux: some Foo {
        _open(existential: foo)
    }

    func _open(existential: any Foo) -> some Foo {
        func _open<F: Foo>(_ foo: F) -> some Foo { foo.bar }
        return _open(existential) // Type 'any Foo' cannot conform to 'Foo'
    }
}

I appreciate this may not be possible due to covariant type erasure, since the type has been lost statically ... just wanted to broach the topic in aid to help others searching for an answer to the same question.

FWIW This would be a very useful aid in many different scenarios, including in SwiftUI to create erasure types to match API's like LabelStyle.Configuration.Icon/Title.

Opaque types have identity based on the generic context of the function that returns them.

In other words, if you have a function:

func giveMeAValue<T>(_: T) -> some Foo

Then if you call it twice, with the same type substituted in for T, the return values are known to have the same underlying type. If you call it with different T, the return values are not known to have the same type.

In keeping with this, opaque types only represent a single underlying type. You can't return different types based on other conditions; it must only depend on the function's generic context:

func giveMeAValue<T>(_: T) -> some Foo {
  if someCondition {
    return MyFoo()
  } else {
    return OtherFoo() // ❌
  }
}

An existential function parameter does not change the generic context of the function, so it can't be used to establish any type identity for the return value.

In your example specifically, what is the underlying type of Baz.qux? It's impossible to say -- it depends on whatever happens to be stored in Baz.foo. If I have two values of type Baz, and I access their .qux properties, one may return an Int and another a String.

You should just keep it as an existential. We've been trying to make them more usable.

5 Likes

One more thing (I left this out for simplicity's sake, but for completeness...), there is an exception to this: availability conditions.

func giveMeAValue<T>(_: T) -> some Foo {
  if #available(someOS, *) {
    return MyFoo()
  } else {
    return OtherFoo() // OK.
  }
}

The OS version is guaranteed to be constant for the lifetime of your process, and to always resolve the same way in every generic environment, so it doesn't interfere with establishing type identity. We still know that every call to giveMeAValue<Int> returns the same type, etc., even if we can't say at compile-time precisely which type that is.

4 Likes

Ah, you're right - the variability in the underlying type of Baz.foo indeed makes it impossible to guarantee a consistent type for .qux , which I didn't fully appreciate before... thank you for putting it so simply.

The reason behind my query was an attempt to write code that could support an API analogous to LabelStyle in SwiftUI. I'm exploring ways to create erasure types similar to it for styling custom-built components. And, as you rightly pointed out, the current constraints around existential and opaque types make this challengin - there do exist solutions outside the box (Engine/Sources/Engine/Sources/ViewStyle.swift at main · nathantannar4/Engine · GitHub) for this - or sometimes it's easier to just to give up and use AnyView.

Thank you for sharing your expertise

Is this still a fixed in stone limitation? Let's say the compiler gains the ability to preserve static type information based on values. Something along these lines:

protocol P {
  associatedtype Assoc 
}

struct G<T>: P {
  typealias Assoc = T
}

let g = G<Int>()
let num: g.Assoc = 42

Couldn't then the compiler not verify the following situation somehow? I'm not 100% sure this is valid, so I apologize up front if something in my thinking is completely wrong.

func group<T: View>(_ t: T) -> some View {
  Group { t }
}

The above code is valid for regular generics, but it becomes somewhat interesting when opening existential.

let color: any View = Text("swift")
group(color)

Isn't the compiler opening the particular existential and binding the concert T type, but it doesn't really return it back to the caller of the group method. Instead it binds it to the opaque result type. The caller still doesn't know the concrete type, only the compiler knows. Theoretically T becomes Text and the result would be Group<Text>. However in regular generics func f<T: View>(_: T) -> T the result would be wrapped back to any View, but since we're not using regular generics for the return type, but the opaque one, we clearly hiding that information again.

This gets somewhat confusing to me.

group(Color.clear) // okay because `some View` hides `Group<Color>`
group(Text("swift")) // also okay

group(Text("swift") as any View) // no longer okay?! 

The naive idea here is to re-pack the existential into an opaque result type, which then cannot change (contextually).

Again, I might talk nonsense here, but this is a very interesting topic, as this really feels intuitive when transforming any P to some P (as an opaque return type, not generic parameter sugar).

To further showcase my thinking, here's another example:

protocol P {}

func g<T: P>(_ t: T) -> some P { t }

struct S: P {}
struct S2: P {}

let someP_1: some P = g(S())
let someP_2: some P = g(S2())

var mutable_someP_1 = someP_1
mutable_someP_1 = someP_1 // okay
mutable_someP_1 = someP_2 // expected error

var another_mutable_someP_1: some P = someP_1
// error: cannot assign value of type 'some P' (type of 'someP_1') to type 'some P' (type of 'another_mutable_someP_1')
another_mutable_someP_1 = someP_1 // bug, or expected??

let someP_3: some P = S() as any P // is this really strictly illegal?
1 Like

Each some P essentially has a tautological identity as 'the type of this declaration'. That is to say, var another_mutable_someP_1: some P = someP_1, you're saying 'another_mutable_someP_1 has some opaque concrete type which conforms to P'. It may be that at the moment the underlying concrete type is identified as 'the opaque type of someP_1, but the entire point of opaque types is that you may want to change the underlying type later. If users could observe that another_mutable_someP_1 and someP_1 shared a type, that would defeat a future attempt to change the type of another_mutable_someP_1 in a source compatible manner.

4 Likes

I'm so confused right now. I thought opaque types were there with the restriction that the underlying type can't be changed unlike in an existential for example. Furthermore how is this any different:

var a = returnSomeP // no explicit `some P`
a = returnSomeP // this assignment is okay

var b: some P = returnSomeP // explicit `some P`
b = returnSomeP // error

This part confuses me quite a bit.

2 Likes

Sorry, yeah, there's two types of change being talked about here:

  • The underlying type of an opaquely-typed declaration cannot change for the duration of a programs execution. Swift knows that if I call returnSomeP now, and then call it again later, the types of the two values obtained will be the same.
  • Codebases can still evolve and a future version of the program may return a different type from returnSomeP, but it is still guaranteed to be the same type during any given execution.

Yeah, this is kind of unique behavior due to the fact that some P implicitly hides information about its type identity. If we were to write out the full type in terms of how the compiler actually allows you to observe them, it would look something like:

func returnSomeP() -> some(returnSomeP) P { ... }
var a = returnSomeP() // implicitly, 'a' is of type 'some(returnSomeP) P'
a = returnSomeP() // assigning 'some(returnSomeP) P' to 'some(returnSomeP) P', no issue
var b: some(b) P = returnSomeP() // this is a separate 'someP' declaration and so gets imbued with it's own identity!
b = returnSomeP() // 'some(b) P' and 'some(returnSomeP) P' are different types!
10 Likes

Thank you, there's a great bit of information in your explanation I either forgot or never knew. :slight_smile: