Opaque Result Type

Hi,

I have a question about opaque results types that I'm hoping someone can help me with. There's something I don't understand. My question is perhaps best explained in code:

protocol Bar {}
struct Foo: Bar {}


func test(foo: Foo) {
  print("test(foo:Foo)")
}

func test<B: Bar>(foo: B) {
  print("test(foo:B)")
}

func fooGenerator() -> some Bar { Foo() }

// Calls test(foo: B)
test(foo: fooGenerator())
// Calls test(foo: Foo)
test(foo: Foo())

I had expected that both function calls above would result in test(foo: Foo) being called. Could anyone explain why this is not the case? Is there a way, while still using Opaque result types, to have the behaviour I'm expecting? I thought the underlying type was preserved.

Thanks in advance for any help, and increasing my understanding here!

2 Likes

It's a tricky thing. Let's give some definitions I'll be using first:

  • static type of something is the type known at the compile type. You can check that by hovering over the symbol in sourcekit-lsp, or alt-clicking in Xcode.
  • dynamic type of something is the type of the object in memory, known only at runtime. You can check that one with type(of:) function

Languages like python don't have the static types, so you have only one function with the given name at the time, and methods are always dispatched dynamically.

some preserves the dynamic type, but erases the static type. When you alt-click fooGenerator you will see that it returns some Bar, not Foo. At compile time there's no information that it was Foo that was returned

Function calls are dispatched according to the static type, which isn't what you want. There are a few solutions you can use:

Preserve the static type information all the way through

probably not what you want, but just writing for the sake of completeness

protocol Bar {}
struct Foo: Bar {}

func test(foo: Foo) {
  print("test(foo:Foo)")
}

func test<B: Bar>(foo: B) {
  print("test(foo:B)")
}

func fooGenerator() -> Foo { Foo() } // I changed the return type here

// Calls test(foo: Foo)
test(foo: fooGenerator())
// Calls test(foo: Foo)
test(foo: Foo())

restore the static type with casting

protocol Bar {}
struct Foo: Bar {}

func test(foo: Foo) {
    print("test(foo:Foo)")
}

func test<B: Bar>(foo: B) {
    if let foo = foo as? Foo {
        test(foo: foo)
    } else {
        print("test(foo:B)")
    }
}

func fooGenerator() -> some Bar { Foo() }

// Calls test(foo: B) which calls test(foo: Foo)
test(foo: fooGenerator())
// Calls test(foo: Foo)
test(foo: Foo())

use protocol

methods defined in protocol are dispatched using the dynamic type, not the static type. The problem with that is that you cannot add methods to protocols defined by someone else

protocol Bar {
    func test()
}
extension Bar {
    func test() {
        print("different Bar")
    }
}
struct Foo: Bar {
    func test() {
        print("Foo")
    }
}

func fooGenerator() -> some Bar { Foo() }

// prints Foo
fooGenerator().test() 
// prints Foo
Foo().test()

I like the protocols approach, but I want the syntax from the first post

protocol Bar {
    func _test()
}
extension Bar {
    func _test() {
        print("different Bar")
    }
}
struct Foo: Bar {
    func _test() {
        print("Foo")
    }
}
func test(foo: Bar) {
    foo._test()
}

func fooGenerator() -> some Bar { Foo() }

// prints Foo
test(foo: fooGenerator())
// prints Foo
test(foo: Foo())
6 Likes

I think it makes sense, if you think of:

func fooGenerator() -> some Bar { Foo() }

as:

func fooGenerator() -> <B: Bar> B { Foo() }

and calling test(foo: fooGenerator()) to match the overload:

func test<B: Bar>(foo: B) { ... }

because it is more specific than func test(foo: Foo) { ... }

2 Likes

A while ago I wrote down some thoughts on the subject, which might help:

2 Likes

@pmacro I am new to the concept, I could be wrong. I have explained my understanding.

Explanation for test(foo: fooGenerator()):

Opaque types preserve type identity however they hide the underlying type from the caller.

//When you option click on x you will notice that x is of the type some Bar
let x = fooGenerator()

Explanation:

  • So at compile time, the caller only knows it as some Bar.
  • some Bar can't match up to Foo and hence the test(foo:B) is called
1 Like

Thanks a lot everyone! And as an aside, the high quality answers speak a lot to how good this community is.