Make writing a habit together! This is the 8th day of my participation in the “Gold Digging Day New Plan · April More Text Challenge”. Click here for more details.
preface
The overall goal of Swift is to be both powerful enough to program underlying systems and easy enough for beginners to learn, which sometimes leads to pretty interesting situations when the power of Swift’s type system requires us to deploy fairly advanced technology to solve problems that at first glance might seem more trivial.
At some point or another (usually right away, not later), most Swift developers will come across a situation where some form of type erasure is required to reference the common protocol. Starting this week, let’s look at what makes type erasing a must-have technique in Swift, and then move on to explore different “Flavors” that implement it, and how each flavor has advantages and disadvantages.
When do I need type erasure?
At first, the term “type erase” seems counter to Swift’s first sense of focus on type and compile-time type safety, so it’s best described as hiding types rather than erasing them entirely. The goal is to make it easier to interact with common protocols that have specific requirements for the types of protocols that will implement them.
Take the Equatable protocol in the standard library as an example. Since all purpose is to compare two values of the same type in terms of equality, the Self meta-type is its only required argument:
protocol Equatable {
static func = =(lhs: Self.rhs: Self) -> Bool
}
Copy the code
The above code allows any type to be Equatable, while still requiring the values on both sides of the == operator to be of the same type, because each protocol-compliant type must “fill in” its own type when implementing the above method:
extension User: Equatable {
static func = =(lhs: User.rhs: User) -> Bool {
return lhs.id = = rhs.id
}
}
Copy the code
The advantage of this approach is that it is impossible to accidentally compare two unrelated equal types (such as User and String), but it also makes it impossible to reference Equatable as a separate protocol (such as creating [Equatable]), Because the compiler needs to know the exact type that actually matches the protocol in order to use it.
This is also true when the protocol contains the associated type. For example, here we define a Request protocol that allows us to hide various forms of data requests (such as network calls, database queries, and cache extraction) in a unified implementation:
protocol Request {
associatedtype Response
associatedtype Error: Swift.Error
typealias Handler = (Result<Response.Error- > >)Void
func perform(then handler: @escaping Handler)
}
Copy the code
The above method gives us the same tradeoff as Equatable — it’s powerful because it allows us to create generic abstractions for any type of Request, but it also makes it impossible to directly reference the Request protocol itself, such as this:
class RequestQueue {
Protocol 'Request' can only be used as a generic
// constraint because it has Self or associated type requirements
func add(_ request: Request.handler: @escaping Request.Handler) {
.}}Copy the code
One way around this is to do exactly what the error message says, that is, not reference Request directly, but use it as a general constraint:
class RequestQueue {
func add<R: Request> (_ request: R.handler: @escaping R.Handler) {
.}}Copy the code
The above approach works because the compiler can now guarantee that the handlers passed are indeed compatible with the Request implementation passed as a Request — because they are all based on generic R, which is restricted to the Request protocol.
However, even though we solved the method signature problem, we still couldn’t actually process the passed Request because we couldn’t store it as a Request attribute or [Request] array, which made it difficult to continue building our RequestQueue. That is, unless we start doing type erasure.
Universal wrapper type erasure
The first type erasure we’ll explore doesn’t actually involve erasing any types, but wrapping them in a generic type that we can more easily reference. Continuing with the previous RequestQueue example, we first create the wrapper type that captures each request’s Perform method as a closure, as well as a handler that should be called after the request completes:
// This will allow us to wrap the implementation of the Request protocol in one
// a generic with the same response and error types as the Request protocol
struct AnyRequest<Response.Error: Swift.Error> {
typealias Handler = (Result<Response.Error- > >)Void
let perform: (@escaping Handler) - >Void
let handler: Handler
}
Copy the code
Next, we’ll also convert the RequestQueue itself to generics of the same Response and Error types — so that the compiler can ensure that all associated types are aligned with the generic type, so that we can store requests as separate references and as part of an array — like this:
class RequestQueue<Response.Error: Swift.Error> {
private typealias TypeErasedRequest = AnyRequest<Response.Error>
private var queue = [TypeErasedRequest] ()private var ongoing: TypeErasedRequest?
// We modified the 'add' method to include a 'where' clause,
// This clause ensures that the type associated with the passed request matches the common type of the queue.
func add<R: Request> (_ request: R.handler: @escaping R.Handler
) where R.Response = = Response.R.Error = = Error {
// To perform type erasure, we simply create an instance of 'AnyRequest',
// It is then passed to the underlying request to use the "Perform" method as a closure along with the handler.
let typeErased = AnyRequest(
perform: request.perform,
handler: handler
)
// Since we are implementing queues, we don't want to have two requests at a time,
// So save the request and pull it down, in case there is a request in progress later.
guard ongoing = = nil else {
queue.append(typeErased)
return
}
perform(typeErased)
}
private func perform(_ request: TypeErasedRequest) {
ongoing = request
request.perform { [weak self] result in
request.handler(result)
self?.ongoing = nil
// If the queue is not empty, the next request is executed
.}}}Copy the code
Note that the above example, along with the rest of the sample code in this article, is not thread-safe — to keep things simple. For more information about thread safety, see Avoiding race conditions in Swift.
The above method works well, but it has some drawbacks. Not only did we introduce the new AnyRequest type, we also needed to convert the RequestQueue to a generic type. This gives us a bit of flexibility because we can now only use any given queue for requests with the same combination of response/error types. Ironically, if we want to compose multiple instances, we may need to implement queue erasure ourselves in the future.
Closure type erasure
Instead of introducing wrapper types, let’s look at how we can use closures to achieve the same type erasure, while still making our RequestQueue non-generic and generic enough to work with different types of requests.
When you use a closure to erase a type, the idea is to capture all the type information needed to perform an operation inside the closure and make the closure accept only non-generic (or even Void) input. This way, we can reference, store, and pass the functionality without actually knowing what’s going on inside the functionality, giving us greater flexibility.
Update the RequestQueue to use closures based type erasure as follows:
class RequestQueue {
private var queue =[() - >Void] ()private var isPerformingRequest = false
func add<R: Request> (_ request: R.handler: @escaping R.Handler) {
// This closure captures both the request and its handler without exposing any type of information
// Outside it, provide full type erasure.
let typeErased = {
request.perform { [weak self] result in
handler(result)
self?.isPerformingRequest = false
self?.performNextIfNeeded()
}
}
queue.append(typeErased)
performNextIfNeeded()
}
private func performNextIfNeeded(a) {
guard !isPerformingRequest && !queue.isEmpty else {
return
}
isPerformingRequest = true
let closure = queue.removeFirst()
closure()
}
}
Copy the code
While over-reliance on closures to capture functionality and state can sometimes make our code difficult to debug, it can also make it possible to completely encapsulate type information — allowing objects like RequestQueue to work without really knowing any of the details of the types at work underneath.
For more information about closures based type erasure and its many different methods, see “Swift Uses Closures for Type Erasure.”
External Specialization
So far, we’ve performed all type erasure in the RequestQueue itself, which has some advantages — it lets any external code use our queue without knowing what type of type erasure we’re using. Sometimes, however, lightweight conversions before passing protocol implementations to apis can both make things simpler and neatly encapsulate the type-erasing code itself.
One approach for our RequestQueue is to require that each Request implementation be specialized before it is added to the queue — this will convert it to RequestOperation, as shown below:
struct RequestOperation {
fileprivate let closure: (@escaping() - >Void) - >Void
func perform(then handler: @escaping() - >Void) {
closure(handler)
}
}
Copy the code
Similar to the way we used closures to perform type erasers in RequestQueue, the above RequestOperation type will enable us to do this when extending the Request:
extension Request {
func makeOperation(with handler: @escaping Handler) -> RequestOperation {
return RequestOperation { finisher in
// We actually want to capture 'self' here, because otherwise
// We run the risk of not being able to keep the basic request.
self.perform { result in
handler(result)
finisher()
}
}
}
}
Copy the code
The advantage of the above approach is that it makes our RequestQueue much simpler, both in terms of the public API and internal implementation. It can now focus entirely on being a queue without having to worry about type erasure of any kind:
class RequestQueue {
private var queue = [RequestOperation] ()private var ongoing: RequestOperation?
Since type erasure now occurs before the request is passed to the queue,
// It can simply accept a concrete instance of "RequestOperation".
func add(_ operation: RequestOperation) {
guard ongoing = = nil else {
queue.append(operation)
return
}
perform(operation)
}
private func perform(_ operation: RequestOperation) {
ongoing = operation
operation.perform { [weak self] in
self?.ongoing = nil
// If the queue is not empty, the next request is executed
.}}}Copy the code
The downside here, however, is that we have to manually convert each request to RequestOperation before adding it to the queue — while this doesn’t add a lot of code at each call point, depending on how many times the same transformation has to be done, it can end up being a bit of a boilerplate.
conclusion
While Swift provides an incredibly powerful type system that helps us avoid a lot of bugs, it sometimes feels like we have to fight the system to use something like a common protocol. Having to do type erasers may seem like an unnecessary chore at first, but it brings some benefits — like hiding type-specific information from code that never needs to care about those types.
In the future, we may also see new features added to Swift to automate the process of creating type-erasing wrappers, and to eliminate much of the need for it by making protocols also used as appropriate generics (for example, being able to define protocols like Request
), Not just depending on the related type).
What type erasure is most appropriate — now or in the future — of course depends a lot on the context, and whether our functionality can be easily performed in closures, or whether full wrapper types or generics are more appropriate.
Thanks for reading! 🚀
Different Flavors of Type Erasure in Swift by John Sundell