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?
type(of:)returns the underlying runtime type of the value, including if the value is in an existential container (such as ananycontainer). As you note,sometypes 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
At compile-time, this creates a new, specialized function:
This function is then called, exactly as if it took
Stringas 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).
someparameter typesThis 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
This is an opaque type. The compile-time type of
xhere 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:But this is not:
The left-hand type is "the return type of
returnOpaque" and the right-hand type is "the return type ofotherReturnOpaque". 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:
This returns a
ReversedCollection<T>, which makes the caller dependent on the implementation detail. If I rewrote this as: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 byf()specialized with the type parameterT."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:
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
anytypes. 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:
Existentials do not conform to the protocol they wrap.
any Pdoes not itself conform toP. 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 useanytypes, try to keep their usage simple to avoid these surprises.