Inception
I came to remind you of what you once knew
Bar Ziony was asking about using type erasure to create a property that can contain any possible adopter of a protocol, but without using generics. I posted a gist but wanted to go over it in more detail, plus answer some follow-up questions.
Prerequisites
We'll start with a protocol and two types that adopt the protocol:
protocol FancyProtocol {
associatedtype Thing
func holdPinkyUp(x: Thing)
}
struct Dashing: FancyProtocol {
func holdPinkyUp(x: String) { print("Dashing: \(x)") }
}
struct Spiffy: FancyProtocol {
func holdPinkyUp(x: String) { print("Spiffy: \(x)") }
}
We want to end up with properties like var x: AnyFancy<T>
or var y: AnyFancyString
that can hold instances of Dashing
or Spiffy
. How do we get there?
Base Box
First we need an abstract base class that adopts FancyProtocol
.
class _AnyFancyBoxBase<T>: FancyProtocol {
// Compiler can infer this but explicitly
// specified for demonstration purposes
typealias Thing = T
func holdPinkyUp(x: T) { fatalError() }
}
This type is just a template that we can subclass to bind a type to the associated type of the protocol. All of its properties and methods can fatal error; None of them will ever be called.
Because _AnyFancyBoxBase
adopts FancyProtocol
it automatically gains an abstract type member the same way it gains a property or function. It can't stay abstract though, we need to bind it to something, either a specific type or a generic type parameter.
In the same way you pass values to functions via parameters, this is what I mean when I say pass a type to a protocol's abstract type member using a generic type parameter.
The Box
Now we can define a subclass of the base box; it inherits the protocol conformance and we provide trampoline functions that forward everything to the instance in var base: Base
. This property is where the actual concrete implementation will live once we kick out of the nested dream protocol layering. base
is our reality.
final class _FancyBox<Base: FancyProtocol>: AnyFancyBoxBase<Base.Thing> {
var base: Base
init(_ base: Base) {
self.base = base
}
override func holdPinkyUp(x: Base.Thing) {
base.holdPinkyUp(x: x)
}
}
This type's raison d'être is in the very first line where we link Base.Thing
(the associated type we got from the protocol) to AnyFancyBoxBase.T
(the generic parameter on our base class).
By definition our base class can't know or care about this whatsoever. That will be important shortly.
Erase all the types
Now we can create our type erasing wrapper. The type it is erasing is the concrete type that adopted FancyProtocol
in the first place. (tl;dr: we don't have to care whether Dashing
or Spiffy
is underlying our AnyFancy
.)
struct AnyFancy<T>: FancyProtocol {
var _box: _AnyFancyBoxBase<T>
func holdPinkyUp(x: T) {
_box.holdPinkyUp(x: x)
}
init<U: FancyProtocol>(_ base: U) where U.Thing == T {
_box = _FancyBox(base)
}
}
We declare the _box
as our base box class. Remember from the previous section that only our subclass is concerned with the concrete type.
If we tried to pull this trick with one less type we would end up adding a generic parameter somewhere. Think about what happens if you try changing _box
to _FancyBox<❓>
. Someone has to fill in the ❓.
Doing it this way lets us move the ❓ into the initializer, instead of being part of the definition of AnyFancy
. The initializer needs a second type parameter U
so it can construct the box subclass. Once the initializer is done the concrete type information has disappeared down a black hole, hidden behind the box subclass never again to wake. Hurray for type erasure.
Basic Example
var anyFancy = AnyFancy(Dashing())
anyFancy.holdPinkyUp(x: "ok")
The type of anyFancy
is AnyFancy<String>
because Dashing binds FancyProtocol.Thing
to String
. We've successfully erased all mention of Dashing
.
Because Spiffy
also binds FancyProtocol.Thing
to String
it is fully substitutable:
anyFancy = AnyFancy(spiffy)
anyFancy.holdPinkyUp(x: "woo")
Thus, magic.
Further Erasure
All problems can be solved with another layer of abstraction... right? If you know you want to bind a type parameter to a specific type, just fall asleep in the dream:
struct AnyFancyString: FancyProtocol {
var _inception: AnyFancy<String>
init<U: FancyProtocol>(_ dreamWithinADream: U) where U.Thing == String {
_inception = AnyFancy(dreamWithinADream)
}
func holdPinkyUp(x: String) {
_inception.holdPinkyUp(x: x)
}
}
We use the same initializer trick to bind AnyFancy.T
to String
and completely erase all traces of associated types and generic type parameters :
struct Kick {
var anyFancyString: AnyFancyString
init(any: AnyFancyString) {
self.anyFancyString = any
}
}
let kick1 = Kick(any: AnyFancyString(dashing))
let kick2 = Kick(any: AnyFancyString(spiffy))
You can create a new protocol that extends FancyProtocol
and constrains or binds the associated type directly:
protocol FancyStringProtocol: FancyProtocol {
associatedtype Thing = String
}
Now no type can adopt FancyStringProtocol
without FancyProtocol.Thing == String
but you'll still need a type-erasing wrapper if you want to use it outside of generic constraints (as of Swift 3).
Limbo
The standard library takes extra steps to make sure if you call a method like drop(first:)
repeatedly it doesn't double-wrap the sequence. If you ever wondered why then I have something very important to show you:
let limbo = Kick(any: AnyFancyString(
AnyFancyString(
AnyFancyString(
AnyFancyString(
AnyFancyString(
AnyFancyString(
AnyFancyString(
AnyFancyString(anyFancy))
)
)
)
)
)
)
)
Wake up.
Type Erasure
Purple's talk
Type Erasure Magic
Natasha's post
This blog represents my own personal opinion and is not endorsed by my employer.