PrefaceThe overall goal of Swift is to be both powerful enough to be used for low-level systems programming, yet easy enough for beginners to learn, which sometimes leads to rather interesting situations — when the power of Swift’s type system requires us to deploy fairly advanced techniques to solve problems that might at first glance seem more trivial. Most Swift developers will, at one point or another (usually right away, not later) run into a situation where they need some form of type erasure in order to reference a generic protocol. This week, let’s start by looking at what makes type erasure an essential technique in Swift, then move on to exploring the different “flavors” of implementing it, and why each flavor has its own pros and cons. When is type erasure necessary?At first the term “type erasure” seems contrary to the first impression that Swift gives us of its focus on types and compile-time type safety, so it is better described as hiding types rather than erasing them completely. The purpose is to make it easier for us to interact with generic protocols, because these generic protocols have specific requirements on the various types that will implement them. Take the Equatable protocol from the standard library as an example. Since all it does is compare two values of the same type for equality, the Self metatype is its only required parameter: protocol Equatable { The above code makes it possible for any type to conform to Equatable while still requiring the values on both sides of the == operator to be of the same type, since each type that conforms to the protocol must "fill in" its own type when implementing the above method: extension User : Equatable { The advantage of this approach is that it makes it impossible to accidentally compare two unrelated types for equality (such as User and String), however, it also makes it impossible to reference Equatable as a standalone protocol (such as creating [Equatable]) because the compiler needs to know the exact type that actually conforms to the protocol in order to use it. The same is true when protocols contain 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 fetches) in a unified implementation: protocol Request { The above approach gives us the same tradeoff as Equatable - it is very powerful because it enables us to create a generic abstraction for any type of request, but it also makes it impossible to directly reference the Request protocol itself, such as this: class RequestQueue { One way to solve the above problem is to do exactly what the error message says, i.e. do not reference the Request directly but use it as a general constraint: class RequestQueue { The above works because now the compiler is able to guarantee that the passed handler is indeed compatible with the Request implementation passed as the request - since they are both based on the generic R which in turn is constrained to conform to the Request protocol. However, even though we have fixed the method signature issue, we still can’t do anything with the passed request because we can’t store it as a Request property or as a [Request] array, which would make it difficult to continue building our RequestQueue. That is, unless we start doing type erasure. Generic wrapper type erasureThe first kind of type erasure we'll explore doesn't actually involve erasing any types, but rather wrapping them in a common type that we can reference more easily. Continuing from the RequestQueue example from earlier, we start by creating this wrapper type - this wrapper type will capture each request's perform method as a closure, along with the handler that should be called after the request completes: // This will allow us to wrap our implementation of the Request protocol in a Next, we’ll also convert the RequestQueue itself to be generic to the same Response and Error types – allowing the compiler to ensure that all associated types align with the generic types, allowing us to store the requests as separate references and as part of an array – like this: class RequestQueue < Response , Error : Swift .Error > { Note that the example above, and the rest of the sample code in this article, is not thread-safe—to keep things simple. For more information on thread safety, check out “Avoiding Race Conditions in Swift.” The above approach works well, but has some drawbacks. Not only have we introduced a new AnyRequest type, but we also need to convert RequestQueue to a generic. This gives us a bit of flexibility, as we can now only use any given queue for requests with the same combination of response/error types. Ironically, we may also need to implement queue erasure ourselves in the future if we want to compose multiple instances. Closure type erasureInstead of introducing a wrapper type, let's look at how we can use closures to achieve the same type erasure while also making our RequestQueue non-generic and general enough to be used for different types of requests. When using closure type erasure, the idea is to capture all the type information needed to perform operations inside the closure and make that closure accept only non-generic (even Void) inputs. This allows us to reference, store, and pass around the function without actually knowing what happens inside the function, giving us greater flexibility. Here’s how to update RequestQueue to use closure-based type erasure: class RequestQueue { 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 - making it possible for objects like RequestQueue to work without actually knowing any details about the types working under the hood. For more information on closure-based type erasure and its many different approaches, see “Swift implements type erasure with closures.” External specializationSo far, we have performed all type erasure in RequestQueue itself, which has some advantages - it allows any external code to use our queue without knowing what type of type erasure we are using. However, sometimes doing some lightweight conversion before passing the protocol implementation to the API can both make things simpler and neatly encapsulate the type erasure code itself. For our RequestQueue, one approach would be to require that each Request implementation be specialized before adding it to the queue - this would convert it into a RequestOperation, like this: struct RequestOperation { Similar to how we used closures to perform type erasure in RequestQueue before, the RequestOperation type above will allow us to perform the operation when extending Request: extension Request { The advantage of the above approach is that it makes our RequestQueue much simpler, both in terms of the public API and the internal implementation. It can now focus entirely on being a queue without having to worry about any kind of type erasure: class RequestQueue { The downside here, however, is that we have to manually convert each request into a RequestOperation before adding it to the queue - while this doesn't add a ton of code at each call point, depending on how many times the same conversion has to be done, it could end up being a bit of boilerplate. ConclusionWhile Swift provides an incredibly powerful type system that helps us avoid a ton of bugs, it can sometimes feel like we have to fight the system to use features like generic protocols. Having to do type erasure may seem like an unnecessary chore at first, but it also comes with some benefits — like hiding specific type information from code that doesn’t need to care about those types. In the future we may also see new features added to Swift that automate the process of creating type-erased wrapper types, and also remove a lot of the need for it by enabling protocols to also be used as proper generics (e.g. being able to define a protocol like Request that doesn't just rely on related types). What type of type erasure is most appropriate - both now and in the future - of course depends a lot on the context, and whether our functionality can be easily performed in a closure, or whether full wrapper types or generics are more suitable for the problem. |
<<: How to design Tabs? I summarized these ten methods
>>: Are Windows 2-in-1s doomed to fail?
All good things must come to an end. At least sin...
This article mainly talks about how to develop a ...
The home improvement industry is a typical large ...
This is a A city full of legends It is A battlegr...
This article mainly introduces the preparations b...
Leviathan Press: Do you get flushed when you drin...
According to the latest forecast report released ...
Cockroaches are a common insect in the hot summer...
Sing rap in a baby voice? Wearing sunglasses, a m...
[[127442]] While the major features that Google p...
[[282844]] The recently released "Special Re...
With the gradual entry of foreign banks into the ...
It's the end of the year again. Advertising an...
The Year of the Rabbit is here. "Rabbit Yuan...
This article will explain the five categories of ...