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
:
let feed: feedDynamicType =
? Nope!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:
Extract dynamic type of feed from
animal
instanceRetrieve the first feed which type matches the animal
FeedType
Cast
any AnimalFeed
to concrete type known in runtimeCall
produce(byEating:
function with feed cast to(some Animal).FeedType
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 typeexistential type (marked with
any
) is a container constrained with protocol, type can be known only in runtimeboth 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