You are currently viewing How to Achieve Dynamic Dispatch Using Generic Protocols in Swift 5.7

How to Achieve Dynamic Dispatch Using Generic Protocols in Swift 5.7

Dynamic dispatch is one of the most important mechanisms in Object-Oriented Programming (OOP). It is the core mechanism that makes run-time polymorphism possible, enabling developers to write code that decides their execution path during run-time rather than compile-time.

As easy as it seems to achieve dynamic dispatch in OOP, it is not the case when it comes to Protocol-Oriented Programming (POP). Trying to accomplish dynamic dispatch using protocols always comes with unpredicted difficulties due to various limitations in the Swift compiler.

With the release of Swift 5.7, all these have become history! Achieving dynamic dispatch in the realm of POP has never been easier. In this article, let’s explore what kind of improvements we get from Swift 5.7 and what it takes to accomplish dynamic dispatch using protocol with associated types.

So without further ado, let’s get right into it!

Note:

If you’re unfamiliar with the some and any keyword in Swift, I highly encourage you to first read my blog post called “Understanding the “some” and “any” keywords in Swift 5.7“.


First Things First

Before I can start showing you the improvements in Swift 5.7, let’s define the protocols and structs that we need for our sample code throughout this article.

struct Gasoline {
    let name = "gasoline"
}

struct Diesel {
    let name = "diesel"
}

protocol Vehicle {

    associatedtype FuelType
    
    var name: String { get }

    func startEngin()
    func fillGasTank(with fuel: FuelType)
}

struct Car: Vehicle {

    let name: String

    func startEngin() {
        print("\(name) enjin started!")
    }

    func fillGasTank(with fuel: Gasoline) {
        print("Fill \(name) with \(fuel.name)")
    }
}

struct Bus: Vehicle {

    let name: String

    func startEngin() {
        print("\(name) enjin started!")
    }

    func fillGasTank(with fuel: Diesel) {
        print("Fill \(name) with \(fuel.name)")
    }

}

The definitions we have above are similar to what we are using in my previous article, but with a little bit of a twist. Here in our Vehicle protocol, we have 2 function requirements, startEngin() and fillGasTank(with:). For the sake of demonstration, We will try to achieve dynamic dispatch using these 2 functions in both Car and Bus structs.


The Limitation of Generic Protocols in Swift 5.6 and Below

Now, let’s say we want to create a startAllEngin() function that accepts a heterogeneous array as shown below:

// Heterogeneous array with `Car` and `Bus` elements
// 🔴 Compile error: Protocol ‘Vehicle’ can only be used as a generic constraint because it has Self or associated type requirements
let vehicles: [Vehicle] = [
    Car(name: "Car_1"),
    Car(name: "Car_2"),
    Bus(name: "Bus_1"),
    Car(name: "Car_3"),
]

func startAllEngin(for vehicles: [Vehicle]) {
    for vehicle in vehicles {
        vehicle.startEngin()
    }
}

// Execution
startAllEngin(for: vehicles)

You will notice that this is literally impossible in Swift 5.6 as you will be prompted with an error saying: “Protocol ‘Vehicle’ can only be used as a generic constraint because it has Self or associated type requirements“. The Swift compiler is prohibiting us to create a heterogeneous array with Vehicle as its element type due to the fact that Vehicle has an associated type (FuelType).

Pro Tip:

If you would like to learn more about the error, and how you can work around it prior to Swift 5.7, check out my article published on Medium: “Swift: Accomplishing Dynamic Dispatch on PATs (Protocol with Associated Types)

Thanks to the upgrade Apple made to the Swift compiler, this limitation no longer exists in Swift 5.7. We can finally use a protocol just like how we use a superclass in OOP. Let me show you how.


Performing Dynamic Dispatch on Simple Function

In Swift 5.7, creating a heterogeneous array is no longer prohibited by the compiler. All we need to do is to use the any keyword.

// Use `any` to indicate that the array will hold existential type
let vehicles: [any Vehicle] = [
    Car(name: "Car_1"),
    Car(name: "Car_2"),
    Bus(name: "Bus_1"),
    Car(name: "Car_3"),
]

func startAllEngin(for vehicles: [any Vehicle]) {
    for vehicle in vehicles {
        vehicle.startEngin()
    }
}

By using the any keyword, we are telling the compiler that the array will contain existential types and that their underlying concrete type will always conform to the Vehicle protocol.

With that, calling startAllEngin(for:) will give us the dynamic dispatch that we want.

startAllEngin(for: vehicles)

// Output:
// Car_1 enjin started!
// Car_2 enjin started!
// Bus_1 enjin started!
// Car_3 enjin started!

Performing Dynamic Dispatch on Function with Generic Parameters

Now let’s take a look at another more complicated example. Let’s say we want to create a function named fillAllGasTank(for:). This function will perform dynamic dispatch to the vehicle’s fillGasTank(with:) function based on the given vehicles array.

Define a Generic Parameter Type

What we trying to achieve might seems straightforward at first, but when we start coding, we will bump into our first problem:

func fillAllGasTank(for vehicles: [any Vehicle]) {

    for vehicle in vehicles {
        // 🤔 What to pass in here?
        vehicle.fillGasTank(with: ????)
    }
}

Since different types of vehicles will require different kinds of fuel, we will have to create a generic protocol to represent both Gasoline and Diesel. Let’s go ahead and do that.

protocol Fuel {
   
    // Constrain `FuelType` to always equal to the type that conforms to the `Fuel` protocol
    associatedtype FuelType where FuelType == Self

    static func purchase() -> FuelType
}

The Fuel protocol is just a simple protocol consisting of an associated type named FuelType, and a static purchase() function. Notice how we constrain FuelType to always equal to the type that conforms to the Fuel protocol. This constraint is very important in order for the compiler to determine the concrete type returned by the static purchase() function.

Next up, let’s conform both Gasoline and Diesel to the Fuel protocol.

struct Gasoline: Fuel {
    
    let name = "gasoline"
    
    static func purchase() -> Gasoline {
        print("Purchase gasoline from gas station.")
        return Gasoline()
    }
}

struct Diesel: Fuel {
    
    let name = "diesel"
    
    static func purchase() -> Diesel {
        print("Purchase diesel from gas station.")
        return Diesel()
    }
}

On top of that, we also need to ensure that the Vehicle protocol’s FuelType is a type that conforms to the Fuel protocol.

protocol Vehicle {

    // `FuelType` must be type that conform to the `Fuel` protocol
    associatedtype FuelType: Fuel

    // ...
    // ...
}

“any” to “some” Conversion

With the Fuel protocol and all other related changes in place, we can now revisit the fillAllGasTank(for:) function and update it accordingly.

func fillAllGasTank(for vehicles: [any Vehicle]) {

    for vehicle in vehicles {

        // Get the instance of `Fuel` concrete type based on the vehicle's fuel type
        let fuel = type(of: vehicle).FuelType.purchase()

        // 🔴 Compile error: Member 'fillGasTank' cannot be used on value of type 'any Vehicle'; consider using a generic constraint instead
        vehicle.fillGasTank(with: fuel)
    }
}

In the above code, notice how we leverage the vehicle’s fuel type to get an instance of the Fuel concrete type, so that we can pass it into the fillGasTank(with:) function.

Unfortunately, if we try to compile our code, we will bump into our 2nd problem: “Member ‘fillGasTank’ cannot be used on value of type ‘any Vehicle’; consider using a generic constraint instead“. What does that mean?

In order to understand the error that we are getting, let’s have a quick recap on what are the differences between the some and any keyword.

Compare the differences between the some and any keyword in Swift
Comparing the some and any keyword

As illustrated in the above image, the underlying concrete type of an existential type is being wrapped within a box. Therefore, the compiler is prohibiting us from accessing the fillGasTank(with:) function. To go about this, we must first convert (unbox) the existential type to an opaque type before accessing the fillGasTank(with:) function.

Fortunately, Apple has made the conversion (unboxing) process extremely easy in Swift 5.7. All we need to do is to pass the existential type to a function that accepts an opaque type and the conversion will happen automatically.

func fillAllGasTank(for vehicles: [any Vehicle]) {

    for vehicle in vehicles {
        // Pass in `any Vehicle` to convert it to `some Vehicle`
        fillGasTank(for: vehicle)
    }
}

// Create a function that accept `some Vehicle` (opaque type)
func fillGasTank(for vehicle: some Vehicle) {

    let fuel = type(of: vehicle).FuelType.purchase()
    vehicle.fillGasTank(with: fuel)
}

With that, we can now compile and execute our code without any error.

fillAllGasTank(for: vehicles)

// Output:
// Purchase gasoline from gas station.
// Fill Car_1 with gasoline.
// Purchase gasoline from gas station.
// Fill Car_2 with gasoline.
// Purchase diesel from gas station.
// Fill Bus_1 with diesel.
// Purchase gasoline from gas station.
// Fill Car_3 with gasoline.

Feel free to grab the full sample code here if you want to try it out yourself.


Further Readings


Wrapping Up

There you have it! That’s how we can achieve dynamic dispatch on protocol with associated types. To sum everything up, here are the improvements in Swift 5.7 that makes all these possible:

  1. Remove the limitation of creating a heterogeneous array using a protocol with associated types.
  2. Enable the use of any and some keyword in the function’s parameter position.
  3. Automatic conversion from existential type to opaque type and vice versa.

If you like this article, feel free to follow me on Twitter and subscribe to my monthly newsletter so that you won’t miss out on any of my upcoming articles.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.