Have you ever wondered what is the meaning of some View
in SwiftUI views? do you understand what Opaque Types and Existential Types are, and how they differ in terms of performance and usability?
In this article I’ll attempt to shed some light on these intriguing topics.
Opaque Types
There are certain scenarios where you want to return a specific type, but not disclose the exact type you’re returning to the consumers. This is specially true when you design a module or library and you don’t want to expose unnecessary types to consumers. Either because you don’t want to force changes on the consumer side if you alter your module or library, or you want to hide complex joint types.
In such cases Opaque Types offer an excellent solution. They allow you to return a specific and well-known type (at least from the compiler’s perspective) while obscuring the actual type. So, when you return an Opaque Type, the compiler knows the exact type being returned at compile time, and the consumer only knows that it receives a certain type that conforms to a given protocol. This lets the compiler perform some optimizations.
When you do something like this in SwiftUI, you are computing an Opaque Type that conforms to the View
Protocol. During the compilation the compiler knows the exact type of View, which in this case is a VStack
.
struct TodoListView: View {
var body: some View {
VStack { }
}
}
Existential Types
Existential types, also known as ‘Boxed Protocol Types,’ provide a method to abstract away concrete types. They emphasize the existence of a type that conforms to a certain set of requirements (protocols in the case of Swift) without revealing the specific identity of that type.
When you write a function that returns a protocol you are returning an existential type.
A concrete (some) example for better understanding
Firstly, let’s define the following Swift protocol:
protocol Vehicle {
var fuelType: FuelType { get }
func start()
}
enum FuelType {
case electric
case gas
case diesel
}
Next, let’s implement the Vehicle protocol with two structs: VWVento
and Tesla
. Both of these structs conform to the Vehicle
protocol.
struct VWVento: Vehicle {
var fuelType: FuelType = .gas
func start() {
print("Starting \(fuelType) VW car")
}
}
struct Tesla: Vehicle {
var fuelType: FuelType = .electric
func start() {
print("I am a \(fuelType) tesla car")
}
}
Opaque Type example
So far so good, now comes the interesting part: we will create a Factory to build some vehicle implementations.
struct VehicleFactory {
static func buildACar() -> some Vehicle {
return VWVento()
}
}
In this example, the buildACar
function returns an Opaque Type (VWVento
) by using the some
keyword before Vehicle
in the return type. By doing so, we signal to the compiler a specific return type at compile time, while effectively concealing the concrete implementation from the consumer. This flexibility allows us to modify the implementation later without impacting the clients or API consumers
Existential Type example
Now let’s complicate things a bit more and create a Ferry
Vehicle implementation, which is a type of Vehicle that can transport other Vehicles:
struct Ferry<T: Vehicle>: Vehicle {
let containedVehicles: [T]
var fuelType: FuelType = .diesel
func start() {
print("Ferry is starting with \(containedVehicles.count) vehicles")
}
}
The next step is to create a new method within our Factory
struct that returns a Vehicle
given a type of fuel:
static func buildCarOf(fuelType: FuelType) -> any Vehicle {
switch fuelType {
case .electric:
return Tesla()
case .gas:
return VWVento()
case .diesel:
return Ferry<VWVento>(containedVehicles: [VWVento()])
}
}
What is the concrete type returned by this function?
The answer is that we can’t know unless we are aware of the given parameter. Thus buildCarOf(fuelType:
) is a function that returns an Existential Type, this gives us the flexibility of returning different concrete implementations, but with a cost that we will review later.
If for any reason we change the any
keyword for some
we will get a compile error saying that “Function declares an opaque return type ‘some Vehicle’, but the return statements in its body do not have matching underlying types”. This is because the compiler doesn’t know which is the concrete type
As a side note, while the any
keyword is not currently mandatory when returning an Existential Type, there are indications that Apple may enforce its use in future Swift 6 versions. The reasoning behind this decision is to make programmers more aware that they are working with an Existential Type
Opaque Types to the rescue
I mentioned that Opaque Types are an excellent way of hiding complex joint types. Let’s illustrate what I meant by adding a new factory method that returns a Ferry
vehicle capable of transporting other Ferrys
static func buildFerryContainer() -> Ferry<Ferry<Tesla>> {
let ferry = Ferry<Tesla>(containedVehicles: [Tesla()])
return Ferry<Ferry<Tesla>>(containedVehicles: [ferry])
}
I think we can agree that this return type is super annoying and verbose to deal with, and this could get even worse if we continue composing different types of Vehicle
implementations.
So in the following example it is easy to see the great benefit of returning an Opaque Type:
static func buildFerryContainer() -> some Vehicle {
let ferry = Ferry<Tesla>(containedVehicles: [Tesla()])
return Ferry<Ferry<Tesla>>(containedVehicles: [ferry])
}
And that’s it! we have hidden all the type complexity from the consumers.
Performance impact
Whenever possible, you should use Opaque Types instead of Existential types. Why? the primary reason lies in performance.
When we use Opaque Types, the compiler can do some optimizations and make use of static method dispatch, which is the most performant way of method dispatching, the compiler can determine, at compile time, which code should run.
On the other hand, when we make use of Existential Types, Swift has to use what is known as dynamic dispatch, meaning that the actual code to be run is only known at runtime, and Swift needs to locate the code that must run when the method is called.
It is easy to see what I am saying by looking at the following code:
var vehicle = VehicleFactory.buildCarOf(fuelType: .electric)
vehicle = VWVento()
vehicle.start()
In this example, the change in the referenced vehicle from a Tesla
to a VWVento
highlights the dynamic nature of Existential Types, where the actual method to run (start()
) can only be determined at runtime.
Conclusion
Just to summarize, let’s recap what we have learnt:
- Opaque Types are a great way of hiding the real type, very useful for designing modules or libraries
- Inside a method that returns an Opaque Type, you can’t return different implementation of the same protocol, in that case you must return an Existential Type
- Opaque Types have a better performance than Existential Types. Thus, you should use Opaque Types whenever possible
- Another very useful case for Opaque Types is when you have complex types like
Ferry<Ferry<Tesla>>