Ahh Swift protocols and generics. When I first started learning about them, my organizational impulses went wild. All the possibilities for meticulously crafted hierarchies ... it was an OCD dreamworld.
That is, until I ran into this:
Protocol can only be used as a generic constraint because it has Self or associated type requirements
With one error, my dreams came crashing to a halt. I could not have collections of protocols with associated types!
Initially in disbelief, I went down every possible rabbit hole (sometimes more than once) to work around this problem, often with none of the solutions out there really meeting my needs.
Here's what I found, and what I eventually decided to do instead.
The core problem
Once upon a time I was experimenting with a personal finance app, and I had a protocol that modeled a debt:
/// A debt owed to a lender
protocol Debt {
/// The amount borrowed
var principal: Decimal { get }
...
}
The idea was to model debts abstractly, then make different types of debts that conform to the Debt
protocol, for example:
/// A debt owed on a credit card
protocol CreditCardDebt: Debt {
...
}
Then I could store all my debts as an array on my User
object:
struct User {
/// The user's debts
var debts: [Debt]
}
Super. Works great so far.
The problem arose when I tried to add interest. You see, different kinds of debts calculate interest differently, so I thought it would be cute to encapsulate this logic into it's own protocol and set of concrete implementations:
protocol Interest {
...
}
struct APR: Interest {
...
}
But in order to do that properly, I need associated types:
protocol Debt {
associatedtype ConcreteInterestType: Interest
var interest: ConcreteInterestType
...
}
Because doing so allows me to be type specific in my specific debt implementation:
protocol CreditCardDebt: Debt {
var principal: Decimal
var interest: APR // Rather than "var interest: Interest"
}
But that also means I can no longer declare var debts: [Debt]
because Debt
now has associatedtype requirements. And since I can't have a collection of debts, I can do basic stuff like iterating over a bunch of debts either.
And boy was I indignant. I thought for sure this couldn't be the case. There MUST be a way!
Basic type-erasure
After pulling my hair out with my own failed workarounds, I turned to the Google-sphere and discovered what appeared to be a miracle panacea: a magical pattern called "type-erasure."
Type-erasure simply means "erasing" a specific type to a more abstract type in order to do something with the abstract type (like having an array of that abstract type). And this happens in Swift all the time, pretty much whenever you see the word "Any."
For example, you can have an array of strings and numbers if you erase to AnyHashable
:
var hashables = [AnyHashable]()
hashables.append("ABC")
hashables.append(123)
Or you could get even more abstract and erase to Any
:
var things = [Any]()
things.append("ABC")
things.append(123)
And you can even iterate over the collection and access the original type:
for thing in things {
if let string = thing as? String {
print(string)
}
if let integer = thing as? Int {
print(integer)
}
}
Magic! All we need to do is create an array of AnyDebt
to solve our initial problem and we're good to go!
So I followed the first type-erasure tutorial I could find, and ended up with this:
/// A type-erased debt
struct AnyDebt: Debt {
/// The amount
let amount: Decimal
/// The interest
let interest: AnyInterest
/// Initialize with a debt of type D
init<D: Debt>(_ debt: D) {
self.amount = debt.amount
self.interest = AnyInterest(debt.interest)
}
}
Since Debt
has associated type requirements, we need a generic initializer to be able to instantiate an AnyDebt
with any type conforming to Debt
. We also need AnyInterest
to avoid the generic AnyDebt<I: Interest>
, which cannot be stored in an array with mixed types of Interest
.
This essentially gives us a type in which the details of the original, specific type is “erased” to the lowest common denominator of any implementation of Debt
.
And this is all well and good. We can now have an array of AnyDebt
and iterate to our heart's content:
var debts: [AnyDebt]
for debt in debts {
print(debt)
}
Hurray! We did it...
Elaborate type-erasure
Not so fast.
While our simple basic type-erasure solution makes it possible to have an array of debts, we're super limited as to what we can do with it. Unlike our mixed array of strings and integers, we can't do something like this:
for debt in debts {
if let creditCardDebt = debt as? CreditCardDebt {
print(creditCardDebt)
}
}
If we try, we get this warning:
Cast from 'AnyDebt' to unrelated type 'CreditCardDebt' always fails
That's because our AnyDebt
implementation offers no way to translate from AnyDebt
back to CreditCardDebt
. When we erase the type with the above approach, we're actually erasing it for good!
On top of that, remember how we could create an array of AnyHashable
and add stuff to it on the fly?
var hashables = [AnyHashable]()
hashables.append("ABC")
hashables.append(123)
Our AnyDebt
implementation is also limited such that we need to wrap each concrete type in AnyDebt
before we can add to a collection (just like with AnyView
in SwiftUI).
var debts = [AnyDebt]()
debts.append(AnyDebt(creditCardDebt))
So what gives?
Turns out, Swifts implementation of Any
and AnyHashable
has way way way more going on under the hood that allows this behavior. Doing type-erasure in a way that's especially useful is actually a non-insubstantial to massive undertaking that I ultimately decided I'd rather avoid.
And there's about a million different flavors that each have their trade-offs, and tutorials all over the internet that share their attempts at each. I actually once tried to reverse engineer Apple's approach but ended up with some really awkward usage patterns that I just scrapped.
All in all, type-erasure is not the panacea I thought it would be — not even close.
Architecting around type-erasure
Since type-erasure is actually a huge pain — pretty often — I eventually let go of my indignation and started to think about solving my problem from a fundamentally different perspective. I asked myself whether I really needed type-erasure at all.
Turns out, if you rethink the way you architect your classes, there's often better ways around this problem.
In my original example, the thing I really wanted was to have an array of debts. And I can in fact still have this by having concrete debt types conform to the Debt
protocol. We just need to get rid of the associatedtype
declaration within Debt
.
But if we do that, how can we have different types of interest objects to encapsulate the various kinds of calculations while maintaining access to their type information?
One way is with enums.
We can keep our Interest
protocol, but require an additional type variable of type enum.
protocol Interest {
var type: InterestType { get }
var amount: Decimal { get }
mutating func calculateInterest(from principal: Decimal, ...)
}
enum InterestType {
case apr
...
}
Then we have concrete implementations of Interest
like so:
struct APR: Interest {
let type = InterestType.apr
var amount: Decimal
mutating func calculateInterest(from principal: Decimal) {
self.amount = 100
}
}
And we can even ensure our CreditCardDebt
necessarily uses the APR
type in our initializer:
struct CreditCardDebt {
let amount: Decimal
let interest: Interest
init(amount: Decimal) {
self.amount = amount
self.interest = APR()
}
}
Sure, it feels a little redundant to have an enum type and a concrete interest type, and I can't helped but be irked by it, but it's a much lower hassle work-around than dealing with the complexities of type-erasure.
More often than not, there's probably some way you can redesign your protocols and structs to avoid type erasure altogether.