Exploring Dynamic Casting and Type Erasure in Swift

Exploring Dynamic Casting and Type Erasure in Swift

Bridging Linked Types in Heterogeneous Arrays

Introduction

The latest WWDC23 introduced many new features in Swift and the broader ecosystem providing developers with opportunities to enhance their skills and stay up-to-date. It's always beneficial to revisit previous sessions to reinforce crucial knowledge like this:

https://developer.apple.com/videos/play/wwdc2022/110353 https://developer.apple.com/videos/play/wwdc2022/110352.

These two sessions were focused on Swift type system: generics, opaque types(some) and extensional meta types(any). They created a farm with animals as an example of usage. I want to remind you of this example and make one step further to establish a bridge between linked types stored in heterogeneous arrays.

Defining protocols

Let's start with AnimalFeed:

protocol AnimalFeed {
}

struct Hay: AnimalFeed {

}
struct Grain: AnimalFeed {}

Here was created bounding protocol AnimalFeed and two concrete feed structs(Hay and Grain) for different kinds of animals.

These mystical animals can produce useful for humanity goods like Milk and Eggs :

protocol AnimalProduce {}

struct Milk: AnimalProduce {}
struct Eggs: AnimalProduce {}

Now, we can define a protocol for such animals:

protocol Animal {
    associatedtype FeedType: AnimalFeed
    associatedtype CommodityType: AnimalProduce

    func produce(byEating food: FeedType) -> CommodityType
}

Each Animal is associated with a specific type of feed and can produce its own specific type of commodity using this feed.

Creating concrete types conforming to protocols

It is time to disclosure these species as Cow and Chicken:

struct Chicken: Animal {
    func produce(byEating feed: Grain) -> some AnimalProduce {
        print("Chicken ate \(feed)")
        return Eggs()
    }
}

struct Cow: Animal {
    func produce(byEating feed: Hay) -> some AnimalProduce {
        print("Cow ate \(feed)")
        return Milk()
    }
}

Each animal struct contains the implementation of the produce(byEating:) method which binds together associated types from Animal protocol. We provide a concrete type of argument: Grain and Hay to define FeedType while the return value defines CommodityType. However, we can mask it with an opaque type some AnimalProduce.

Type erasure problem

Let me explain:

let cow = Cow().produce(byEating: Hay()) // Result is some AnimalProduce

It could be expected that it could work similarly for:

let animal: any Animal = Cow()
let animalProduce = animal.produce(byEating: Hay())

But instead of CommodityType result we will receive:

Member 'produce' cannot be used on value of type 'any Animal'; 
consider using a generic constraint instead

It means we can't use produce(byEating:) type on any Animal because any Animal is a type-erasure container that can't work with a method that requires knowledge of associated types used in produce(byEating:) function. Our usual workaround is to create a type erasure struct AnyAnimal :

struct AnyAnimal<FeedType: AnimalFeed, CommodityType: AnimalProduce>: Animal {
    private let _produce: (FeedType) -> CommodityType

    init<T: Animal>(animal: T) where T.FeedType == FeedType, T.CommodityType == CommodityType {
        self._produce = animal.produce(byEating:)
    }

    func produce(byEating feed: FeedType) -> CommodityType {
        _produce(feed)
    }
}

// so:
let animal = AnyAnimal(animal: Cow())
let animalProduce = animal.produce(byEating: Hay())

// Output:
some AnimalProduce

Now it works and returns the type defined in the Cow struct. It is actually Milk type but hidden from the outer world by opaque type.

Defining Farm struct

Let's continue with farm creation:

struct Farm {
    private(set) var animals: [any Animal]
    private(set) var feeds: [any AnimalFeed]

    func produceCommodities() -> [any AnimalProduce] {
        // Somehow we need to produce all possible commodities 
        // from given animals using given feeds
    }
}

Adding associated type complexity

To justify the usage of any AnimalFeed I'm introducing associated constraints for AnimalFeed as well:

protocol AnimalFeed: Castable {
    associatedtype AnimalType: Animal where AnimalType.FeedType == Self

    func makeCommodity(by animal: AnimalType) -> AnimalType.CommodityType
}

extension AnimalFeed {
    func makeCommodity(by animal: AnimalType) -> AnimalType.CommodityType {
        return animal.produce(byEating: self)
    }
}

struct Hay: AnimalFeed {
    typealias AnimalType = Cow
}
struct Grain: AnimalFeed {
    typealias AnimalType = Chicken
}

Task definition

Here our task takes shape. We store animals in one heterogeneous array as well as all possible feeds on the farm in another. We can try to produce a commodities list calling produce(byEating:) of every animal on the farm. Is it even possible?

// We can return just protocol because he has no associated types
func produceCommodities() -> [AnimalProduce] {
    return animals.compactMap { animal in
    let feed = feeds.first(where: { ??? }) // How to filter correct feed for current animal?
        animal.produce(byEating: feed) // Error: Member 'produce' cannot be used on value of type 'any Animal'; consider using a generic constraint instead
    }
}

Unboxing existential types

We immediately bump into the previous problem with any Animal type box - it can't work with associated types. But instead of the creation of AnyAnimal we can use advice from WWDC videos and unbox it using a function that has some Animal argument:

func produceCommodities() -> [AnimalProduce] {
    // enumarate all animals one by one 
    return animals.compactMap { animal in
        // call produce function for each passing any Animal
        return produce(from: animal)
    }
    // Remove nil values from the list if there no appropriate feed for animal
}

// Passing any Animal value to argument marked as some Animal 
// ultimately unbox the value to opaque type
private func produce(from animal: some Animal) -> AnimalProduce? {
    // get dynamic type of feed stored in animal metatype
    let feedDynamicType = type(of: animal).FeedType
    // get first feed that match FeedType of given animal
    if let feed = feeds.first(where: { type(of: $0) == feedDynamicType }) {
        // create Commodity that match animal by given feed
        return animal.produce(byEating: feed)
    }

    return nil
}

It is almost good but we can't pass feed to animal.produce(byEating: because feed is type erased while function expects concrete type. We need somehow cast feed to feedDynamicType :

  1. let feed: feedDynamicType = ? Nope!

  2. feed as? feedDynamicType ? Still impossible

Dynamic cast

The only way we can use is generic function that can be placed in convenient protocol:

protocol Castable {
    func cast<T>() -> T?
}
extension Castable {
    func cast<T>() -> T? {
        return self as? T
    }
}

Looks good but there is still a problem we can't set T because our type is dynamic while generic expects static declaration.

One more trick:

protocol Castable {
    func cast<T>(to metatype: T.Type) -> T?
}
extension Castable {
    func cast<T>(to metatype: T.Type) -> T? {
        return self as? T
    }
}

protocol AnimalFeed: Castable { ... }

Now we can define our generic type T by providing its metatype from feedDynamicType.

Final solution

Let's combine it all together:

func produceCommodities() -> [AnimalProduce] {
    return animals.compactMap { animal in
        return produce(from: animal)
    }
}

private func produce(from animal: some Animal) -> AnimalProduce? {
    let foodType = type(of: animal).FeedType
    if let feed = feeds.first(where: { type(of: $0) == foodType })?.cast(to: foodType)  {
        return animal.produce(byEating: feed)
    }

    return nil
}

Now everything works as expected:

  1. Extract dynamic type of feed from animal instance

  2. Retrieve the first feed which type matches the animal FeedType

  3. Cast any AnimalFeed to concrete type known in runtime

  4. Call produce(byEating: function with feed cast to (some Animal).FeedType

  5. Call compactMap for all instances of animals to produce a list of possible commodities

Output:

Cow ate Hay()
Cow ate Hay()
Chicken ate Grain()
[Milk, Milk, Eggs]

Wrap-up

  • opaque type (marked with some) is the same as generic, defined at compile time and contains one known static type

  • existential type (marked with any) is a container constrained with protocol, type can be known only in runtime

  • both types provide type erasure that allows you to hide associated types which means you can't talk to them directly

  • you can unbox existential type to opaque type by passing it as an argument to function with an opaque type parameter

  • it is possible to extract a specific type from any type erasure container in runtime using dynamic type(type(of:)) provided as metatype for casting function