Search code examples
swiftgenericsopaque-types

Exposing the underlying type of an opaque


I am trying to have a deeper understanding of the difference between a generic type and an opaque type. The one thing that is clear about opaque type is that the compiler does not expose the underlying type. I wanted to check it and ran this little code:

func exposeGeneric<T: Equatable>(t: T) {
    print(type(of: t))
}

func exposeOpaque(t: some Equatable) {
    print(type(of: t))
}

func returnOpaque() -> some Equatable {
    2
}

exposeGeneric(t: "asdf")
exposeOpaque(t: 1)
print(type(of: returnOpaque()))

And the output was

String
Int
Int

What is the difference then?


Solution

  • type(of:) returns the underlying runtime type of the value, including if the value is in an existential container (such as an any container). As you note, some types are hidden from you by the compiler (the compiler knows what they are, but does not expose it to calling code). But the type can still be interrogated at runtime.

    Generics

    func exposeGeneric<T: Equatable>(t: T)
    ...
    exposeGeneric(t: "asdf")
    

    At compile-time, this creates a new, specialized function:

    func exposeGeneric(t: String)
    

    This function is then called, exactly as if it took String as its parameter. This can lead to copying the entire function multiple times if there are many different callers with different types. (There are some optimizations to reduce the actual copying, but in principle, that's what's happening.)

    This tool lets you write generic code, applying algorithms to types that conform to a set of requirements (i.e. a protocol).

    some parameter types

    func exposeOpaque(t: some Equatable)
    ...
    exposeOpaque(t: 1)
    

    This is not an opaque type. This is just a more friendly syntax for the first example. It works exactly the same; you just don't have to define a type parameter (T). This syntax is preferred when possible because it's a bit easier to read.

    This tool is just to make it nicer to write generic code.

    Opaque Return Values

    func returnOpaque() -> some Equatable
    ...
    let x = returnOpaque()
    

    This is an opaque type. The compile-time type of x here is "the return type of `returnOpaque(), which is known to be Equatable." That is all you know about this type at compile-time. It is a specific type (in this case Int), and the compiler knows what it is, but the caller does not.

    It is known to be the same type every time you call returnOpaque(), so this is valid:

    returnOpaque() == returnOpaque()
    

    But this is not:

    func returnOpaque() -> some Equatable { 2 }
    func otherReturnOpaque() -> some Equatable { 2 }
    
    returnOpaque() == otherReturnOpaque() // Invalid. Type-mismatch
    

    The left-hand type is "the return type of returnOpaque" and the right-hand type is "the return type of otherReturnOpaque". Those are not the same nominal type, even though they are the same structural type (i.e. they both resolve to Int).

    This tool lets you hide implementation details in cases where the returned type may be complex or implementation dependent. For example, consider:

    func f<T>(_ array: [T]) -> some Collection<T> {
        array.reversed()
    }
    

    This returns a ReversedCollection<T>, which makes the caller dependent on the implementation detail. If I rewrote this as:

    func f<T>(_ array: [T]) -> some Collection<T> {
        var array = array
        array.reverse()
        return array
    }
    

    then the returned type would change to [T]. With an opaque return type, the caller doesn't have to change anything. It's still "type type returned by f() specialized with the type parameter T."

    In the past, we might have used something like AnyCollection<T> to deal with this, but that has performance impacts, both in making copies and in preventing some inlining optimizations. some Collection<T> gets the advantages of hiding the type information without the cost of wrapping it in a type eraser. (See .eraseToAnyPublisher() for a classic example of this in Combine.)

    Existentials

    Just to throw one more example into the mix:

    func exposeExistential(t: any Equatable) {
        print(type(of: t))
    }
    
    exposeExistential(t: 1)  // prints "Int" 
    

    This function takes a value that conforms to Equatable and wraps it into an existential container. This container has a different memory layout, and may require heap allocations. The function than operates on the existential container, which forwards methods to the wrapped value.

    This is slower, but in some cases can be more flexible. For example, if you had many types that all conform to some protocol, you could put them all in an Array of type [any MyProtocol]. You can't do that with generics, because the Array needs to be of a single type. [some MyProtocol] would have to be all Ints or all Strings for example (assuming Int and String conform.)

    When possible, avoid any types. They are expensive and have some surprising corner cases (though fewer in recent versions of Swift). But in cases where you really need a heterogeneous Collection, they're available.

    As an example of a corner case that still exists, consider:

    func f(_ p: [some P]) {}
    
    func g(_ p: any P) {
        f([p])  // Error: Type 'any P' cannot conform to 'P'
    }
    

    Existentials do not conform to the protocol they wrap. any P does not itself conform to P. This can bite you at surprising times, especially since Swift has added features (called "opening") to make this "just work anyway" in some simple cases. So you can get a long way down the road and suddenly discover that you're stuck and need to redesign. If you do need to use any types, try to keep their usage simple to avoid these surprises.