Erase your fear, type erasure is here
OkCupid is hiring for iOS Click here to learn more
Introduction
Here at OkCupid we are constantly striving to implement best in class Swift practices. One of these concepts is type erasure. You might be thinking to yourself "I have never had to use type erasure in my life..." but you're wrong! Little did you know, AnyObject
is actually a protocol that erases the concrete type of any object. You may have even seen the Any
prefix scattered around the Swift Standard Library (AnyHashable
, AnyIterator
, AnyCollection
, etc).
This powerful construct is a double-edged sword. On one hand, since all classes conform to AnyObject
, it acts as a universal wrapper for all types which can be useful if you need to work with unknown types. On the other hand, it removes the reliance on Swift's Type Safety system. They don't call it "safety" for no reason! There are important benefits to knowing the type of an object you are working with and we are all too familiar with the ole' Objective-C crash from "Unrecognized selector sent to instance" when you call a method unimplemented on a type.
But could there be a best of both worlds solution? Here at OkCupid we think the answer is a resounding YES. We can take the power of type erasure and apply it to a specific use case to get the benefits of flexible collections and still maintain the rigidity of Swift's robust Type Safety system.
For the sake of illustration, we have put together a sample project that includes code from our app implementing a specialized form of Type Erasure for our Conversations view controller.
https://github.com/OkCupid/swift-type-erasure
The Problem
For this example, we are going to be building the model that represents a conversation in the OkCupid iOS app.
When building out the model layer for our conversations screen, we had a few goals in mind:
- We needed good abstraction to support persistence for the feature in the future
- The model needed to be easily mockable for unit testing
- The architecture should be extensible, to allow us to expand the logic as needed
Creating a simple struct was our first approach:
struct Conversation {
let threadId: String
let isUnread: Bool
let correspondent: User
// .. and so on
}
However, we quickly realized that this approach did not meet all of our goals. Specifically, this did not give us the extensibility we needed. For example, if OkCupid were to introduce a new conversation type we would need to refactor a lot of code to support it.
Some examples of these "future" types might be PersistingConversation
, GroupConversation
, SecretConversation
, etc. Because Conversation
is a concrete struct, supporting multiple types of conversations throughout the app would need a significant refactor.
Since we know that any type of conversation will share the same set of basic properties, let's abstract the concrete type using a protocol.
The Protocol Oriented Approach
Let's create ConversationProtocol
, a protocol which contains all the shared properties of a conversation.
protocol ConversationProtocol {
var threadId: String { get }
var correspondent: UserProtocol { get }
var isUnread: Bool { get }
// etc..
}
ConversationProtocol
protocol is perfect for expressing shared property requirements, but we also want some shared functionally amongst our conversations. For example, we need a way to differentiate one conversation from another. Luckily, Swift provides us with the Hashable
protocol (which inherits from the Equatable
protocol) for this purpose.
What we want is a single implementation of Hashable
that can be shared amongst the current and future concrete implementations of ConversationProtocol
. However, it's impossible for a protocol (ConversationProtocol
) to implement another protocol (Hashable
). We need a concrete type
We can instead provide a default implementation of a protocol on another protocol by using the where
declaration in the extension, which specifically applies to concrete types:
extension ConversationProtocol where Self: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(threadId)
}
func isEqualTo(_ other: ConversationProtocol) -> Bool {
guard let otherConversation = other as? Self else {
return false
}
return threadId == otherConversation.threadId
}
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.isEqualTo(rhs)
}
}
This tells the compiler that any concrete type that implements ConversationProtocol
and Hashable
gets a default implementation for free! This is tremendously powerful because it allows for a single central implementation of Hashable
among all concrete types of ConversationProtocol
.
To use it, all we need to do is create a concrete type:
struct Conversation: ConversationProtocol {
var threadId: String
var correspondent: UserProtocol
var isUnread: Bool
}
Now, to use our shared implementation of Hashable
all we need to do is:
// This is where the magic happens 🎩✨
extension Conversation: Hashable {}
Now we have a concrete Conversation
struct that automatically gets our implementation of Hashable
simply by providing a blank implementation.
BUT... This is all great until we try to store our instance of ConversationProtocol
in set... after all, we still want to use the abstracted type for ultimate flexibilty.
var conversationSet = Set<ConversationProtocol>()
The compiler is yelling at us...
error: type 'ConversationProtocol' does not conform to protocol 'Hashable'
This makes sense as a protocol cannot implement a protocol. So how do make a set of ConversationProtocol
s? This is where type erasure comes into play.
We need a concrete type that conforms to Hashable to store in a set.
struct AnyHashableConversation: ConversationProtocol {
var threadId: String {
return conversation.threadId
}
var correspondent: UserProtocol {
return conversation.correspondent
}
var isUnread: Bool {
return conversation.isUnread
}
private let conversation: ConversationProtocol
init(conversation: ConversationProtocol) {
self.conversation = conversation
}
}
// Use our existing Hashable implementation
extension AnyHashableConversation: Hashable {}
That's it! Now we can create a set of any conversation type and it will be hashed using a single shared implementation of Hashable
!
let conversations = Set<AnyHashableConversation>()
Conclusion
The use of protocols combined with type erasure is a useful technique for abstracting common functionality amongst many concrete types. It allows us to keep our code loosley coupled, extensible, and easily testable. Do you have experience using type erasure or other abstractions to solve similar problems? Leave us your thoughts, we'd love to learn more!