Preface
The overall goal of Swift is to be both powerful enough to be used for programming the underlying system and easy enough for beginners to learn, which can sometimes lead to quite interesting situations when the power of Swift's type systems requires us to deploy fairly advanced technologies to solve problems that may seem more trivial at first glance.
Most Swift developers will encounter a situation at one moment or another (usually immediately, not later) where some form of type erasure is required to reference a common protocol. Starting this week, let's look at what makes type erasing an essential technology in Swift, and then continue to explore the different "flavors" that implement it, and why each flavor has its pros and cons.
When do type erasing be required?
At first, the term “type erasure” seems to be the opposite of the first feeling Swift gives us about types and compile-time type safety, so it is better to describe it as hidden types rather than erase them completely. The purpose is to make it easier for us to interact with general protocols, which have specific requirements for the various types that will implement them.
Take the Equatable protocol in the standard library as an example. Since all purposes are to compare two values of the same type according to equality, the Self element type is its only required parameter:
protocol Equatable { static func ==(lhs: Self, rhs: Self) -> Bool }
The above code makes any type compliant with Equatable, while still requiring the values on both sides of the == operator to be of the same type, because each type that complies with the protocol must "fill in" its own type when implementing the above method:
extension User: Equatable { static func ==(lhs: User, rhs: User) -> Bool { return == } }
The advantage of this approach is that it is impossible to accidentally compare two unrelated equal types (e.g. User and String ), but it also makes it impossible to reference Equatable as a standalone protocol (e.g. creating [Equatable] ), because the compiler needs to know exactly the exact type that actually conforms to the protocol in order to use it.
The same is true when the protocol contains associated types. 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 extractions) in a unified implementation:
protocol Request { associatedtype Response associatedtype Error: typealias Handler = (Result<Response, Error>) -> Void func perform(then handler: @escaping Handler) }
The above method gives us the same tradeoff method as Equatable - it is very powerful because it allows us to create a general abstraction for any type of request, but also makes it impossible to directly reference the Request protocol itself, such as this:
class RequestQueue { // Error: protocol 'Request' can only be used as a generic // constraint because it has Self or associated type requirements func add(_ request: Request, handler: @escaping ) { ... } }
One way to solve the above problem is to operate completely according to the content of the error message, that is, do not directly refer to the Request, but use it as a general constraint:
class RequestQueue { func add<R: Request>(_ request: R, handler: @escaping ) { ... } }
The above method works because the compiler is now able to ensure that the passed handler is indeed compatible with the Request implementation passed as a request - because they are all based on generic R, which is restricted to conform to the Request protocol.
However, despite solving the signature problem of the method, we still cannot actually handle the passed requests as we cannot store it as a Request property or a [Request] array, which will make it difficult to continue building our RequestQueue. That is, unless we start doing type erasing.
Universal wrapper type erasing
The first type of erasure we will explore does not actually involve erasing any types, but rather wraps them in a general type that we can refer to more easily. Continuing with the previous RequestQueue example, we first create the wrapper type - the wrapper type will capture the perform method of each request as a closure, and the handler that should be called after the request is completed:
// This will allow us to wrap the implementation of the Request protocol in one// In generics with the same response and error types as the Request protocolstruct AnyRequest<Response, Error: > { typealias Handler = (Result<Response, Error>) -> Void let perform: (@escaping Handler) -> Void let handler: Handler }
Next, we will also convert the RequestQueue itself to the same Response and Error type generics—so that the compiler can ensure that all associated types and generic types are aligned, allowing us to store the request as a separate reference and as part of the array—like this:
class RequestQueue<Response, 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 request matches the general type of the queue. func add<R: Request>( _ request: R, handler: @escaping ) where == Response, == Error { //To perform type erasure, we just need to create an instance 'AnyRequest', // Then pass it to the underlying request to use the "perform" method as a closure with the handler. let typeErased = AnyRequest( perform: , handler: handler ) // Since we want to implement the queue, we don't want to have two requests at a time. // So save the request to pull down in case there is a request that is being executed later. guard ongoing == nil else { (typeErased) return } perform(typeErased) } private func perform(_ request: TypeErasedRequest) { ongoing = request { [weak self] result in (result) self?.ongoing = nil // If the queue is not empty, then the next request is executed ... } } }
Note that the above examples, as well as the other example codes in this article, are not thread-safe - to make things simple. For more information about thread safety, see "Avoid race conditions in Swift".
The above method works well, but has some disadvantages. Not only did we introduce the new AnyRequest type, we also needed to convert the RequestQueue to a generic. This gives us a little flexibility, as we can now only use any given queue for requests with the same response/error type combination. Ironically, if we want to form multiple instances, we may also need to implement queue erasing ourselves in the future.
Closure type erasing
Instead of introducing wrapper types, let's look at how to use closures to achieve the same type erase, while also making our RequestQueue non-generic and generic enough for different types of requests.
When using a closure erase type, the idea is to capture all the type information needed to perform operations inside the closure and make the closure accept only non-generic (or even Void) input. This allows us to reference, store and pass the feature without actually knowing what will happen inside the feature, thus giving us greater flexibility.
The method to update the RequestQueue to use closure-based type erasing is as follows:
class RequestQueue { private var queue = [() -> Void]() private var isPerformingRequest = false func add<R: Request>(_ request: R, handler: @escaping ) { // This closure will capture the request and its handler at the same time without exposing any type of information // Outside it, provide full type erasing. let typeErased = { { [weak self] result in handler(result) self?.isPerformingRequest = false self?.performNextIfNeeded() } } (typeErased) performNextIfNeeded() } private func performNextIfNeeded() { guard !isPerformingRequest && ! else { return } isPerformingRequest = true let closure = () closure() } }
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 fully encapsulate type information—making objects like RequestQueue work without really understanding any details of the types that work at the underlying level.
For more information on closure-based type erasure and its more different methods, see "Swift Implementing Type Erase with Closures".
External specialization
So far we have performed all type erasing in the RequestQueue itself, which has some advantages - it allows any external code to use our queue without knowing what type of type erasing we are using. However, sometimes doing some lightweight conversion before passing the protocol implementation to the API can both make things easier and cleverly encapsulate the type erase code itself.
For our RequestQueue, one approach is to require that each Request implementation be specialized before adding it to the queue - this will convert it to a RequestOperation as follows:
struct RequestOperation { fileprivate let closure: (@escaping () -> Void) -> Void func perform(then handler: @escaping () -> Void) { closure(handler) } }
Similar to how we used closures to perform type erasing in RequestQueue, the RequestOperation type above will enable us to do that when extending Request:
extension Request { func makeOperation(with handler: @escaping Handler) -> RequestOperation { return RequestOperation { finisher in // We actually want to capture 'self' here because it doesn't mean it // We will risk not being able to retain basic requests. { result in handler(result) finisher() } } } }
The advantage of the above approach is that it makes our RequestQueue simpler, whether it is a public API or an internal implementation. It can now be fully focused as a queue without having to care about any type erasing:
class RequestQueue { private var queue = [RequestOperation]() private var ongoing: RequestOperation? // Because type erasing now occurs before the request is passed to queue, // It can simply accept a specific instance of "RequestOperation". func add(_ operation: RequestOperation) { guard ongoing == nil else { (operation) return } perform(operation) } private func perform(_ operation: RequestOperation) { ongoing = operation { [weak self] in self?.ongoing = nil // If the queue is not empty, then the next request is executed ... } } }
The downside here, however, is that we have to manually convert each request to a RequestOperation before adding it to the queue - while this doesn't add a lot of code at each call point, it may end up being a bit like a boilerplate depending on how many times the same conversion must be done.
Conclusion
While Swift provides an incredibly powerful type system that can help us avoid a lot of bugs, it sometimes makes people feel like we have to fight the system to use features like common protocols. Having to do type erasing may initially seem like an unnecessary chore, but it also brings some benefits—such as hiding specific types of information from code that doesn't need to care about these types.
In the future, we may also see new features added in Swift that can automate the process of creating type erasing wrapper types, or eliminate a lot of demand for it by making the protocol also used as appropriate generics (such as being able to define protocols like Request) rather than relying on the relevant types).
What type of erasing is the most appropriate—whether now or in the future—of course depends heavily on the context, and whether our functionality can be easily executed in closures, or whether full wrapper types or generics are better suited for this question.
This is the article about erasing of various types of odors in Swift. For more related content on Swift, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!