on
Approaches to Type Erasure in Swift
Protocols bring a lot of power and flexibility to our code. Some might say that this is the most important feature of Swift, especially since Apple described it as “Protocol oriented programming language”. But every once in a while, things don’t work as expected using protocols, and we encounter the following compilation error: Protocol ‘X’ can only be used as a generic constraint because it has Self or associated type requirements
:oops: In this article we will talk about different approaches to get around this issue.
Imagine that we were creating an app that features movies and gives some useful information about them. We quickly notice that we have a recurring pattern of configuring views using their ViewModels everywhere in our codebase. So we wanted to build a layer of abstraction around this idea by creating a Configurable
protocol that can be reused in all parts of our app.
In an underlying view of our detail page, we have a stackView containing two types of views, that we’d like to configure (Let’s say: CastsView
, and KeywordsView
for this example).
Ideally we would like to be able to configure our views in a simple for-loop using this new Configurable
protocol. The problem with this implementation is that, although we can store the views in an Array
of UIView
, we cannot create an Array
of Configurables
. The error that the compiler throws is the following:
Protocol 'Configurable' can only be used as a generic constraint because it has Self or associated type requirements
Indeed, Configurable is a Protocol with associated type (PAT). PATs are more abstract and complex than a normal protocol, and currently in Swift, PATs can only be used as a generic constraint.
In this article we will illustrate different ways of implementing Type Erasure to overcome this problem. Type Erasure is a workaround that is used extensively in the Swift standard library and frameworks such as Combine. There is a lot of work still going on to enhance generics and PATs in Swift, hopefully it will make them easier to work with.
We’ll see two different approaches to implement Type erasure, the first one involves a technique called Boxing, And the second one involves what we can refer to as Shadowing.
Type erasure using boxing
Boxing addresses compile time protocol limitations to be deferred until runtime by using a container type.
The fact that our container is a concrete type that implements Configurable
allows us to create an Array
of that type, we can then have an array of GenericConfigurables
.
The steps to implement this are fairly simple:
- First, we introduce a generic container type
GenericConfiguration
conforming toConfigurable
- The container accepts a
Configurable
generic that has the sameViewModel
as its own so it can store its configuration method - We store the configure method of the
Configurable
init argument in ourconfigurationClosure
property - Whenever configure is called, we call our
configurationClosure
that has the real implementation
Note that we have to store every single method that is defined in the erased protocol type. In this case we were lucky because Configurable
only needs one.
Now imagine that we need to have multiple other views to display that implement Configurable
, each one of them having a different ViewModel
. The problem is that we cannot insert them in the previous array of configurables since it only accepts an element of type GenericConfigurable<TagsViewModel>
.
In this case we’d want a container type that has no constraints over the ViewModel
of its mirrored type. We can achieve that using the type Any
that can represent an instance of any type at all.
Since our container’s configure method can now take any type of argument, we have to downcast it to the concrete type of our underlying configurable’s ViewModel
so we can pass it as an argument to the real implementation. This then allows us to write the following concise code (with models of type [Any]
):
And this is how you can apply type erasure using boxing :tada:! The same technique would allow us to store a Set
of type erased objects that conform to Hashable
for example.
This working solution is really close to what we’d want for our layer of abstraction that can be used in any other views and viewControllers, but it has some limitations. Firstly, we need to instantiate a new type eraser every single time we want to use our views for configuration, which adds boilerplate code in several parts of our codebase
Secondly, we cannot use our type eraser if our views are contained in a stackView as follows:
The problem is that AnyConfigurable
takes a concrete type that conforms to Configurable
, and we lost this type information by casting our views to UIView
. It is certainly possible to test downcasting to our custom views but it’s not really suitable when we have a lot of possible outcomes.
And last but not least, the viewModels are now contained in an array of type [Any]
which is not type safe since there can be anything in the array. This means that we are not guaranteed that our views are configured with the proper needed type. To overcome these limitations we’ll try to use another kind of type erasure.
Shadow type erasure
We usually use the term shadowing for two or more variables created in overlapping scopes. When a variable in the outer scope is hidden by one in the inner scope, we say that the first one is shadowed by the second one. In this case, we can say that the context of the execution is the scope and the shadowed definition is that of the first variable. We will try to apply the same logic to create our type eraser, the context will be the type of the protocol that is used, and the shadowed definition will be that of our configure method. For that, we create a new protocol that will allow us to conceal our Configurable
PAT.
Then we can take advantage of the fact that method dispatch in protocol extensions are always performed statically (i.e: at compile-time), using the most accurate method definition to be found in the scope of the extension, which saves us from creating a second configuration method or falling into an infinite loop when performing the following operation:
With these simple modifications we can begin to use our new API:
As a result of our new implementation we can also combine multiple strictly typed boxed type erasers to create an array of shadow Configurables
:
Nice! We’ve managed a great deal of abstraction so far, but maybe a little too much by taking Any
as an argument in our new protocol’s configure method.
To narrow the scope of types taken by our shadow protocol we’ll break its inheritance relationship with Configurable
, give it a more specific naming, and make our views conform to it. This way, we could use other protocols for different sets of viewModels in other parts of our app.
We can define our viewModel type by using an enum:
This is a quick solution to the problem, but enums don’t scale well, and maintaining a large number of cases is annoying. What if we wanted to return the viewModel’s id
? We’d have to switch again through all cases. A second solution is to use a protocol instead:
Another improvement would be error handling. Right now if we try to configure the view with the wrong viewModel, it just does nothing, it would be better to raise an error so we can fix our code whenever this happens. For that we can use an assertion with a detailed message:
With this pattern in place, we can begin to use our new Configurable
protocol throughout our codebase.
Conclusion
In this article we’ve seen that protocols with associated types are very constrained to work with. We’ve also seen how we can take advantage of different kinds of type-erasures to workaround the issues we face with PATs.
In Swift, PATs are not meant to be used inside an array. The main purpose of creating a Protocol with associated types is to create a common API to describe a generalized concept that can be implemented using different algorithms according to the associated types. Then we can take advantage of this common API as a generic constraint to implement algorithms inside a method definition or an extension. Take Equatable
for example, when a custom type conforms to it, you define your own implementation of the ==
operator, (in fact, most of the time, the compiler does it for you), but then, as a result, your custom type get to be used in a whole range of algorithms that rely on this idea of equality. You could call contains
in an array of your new type for example.
When you hold a hammer, everything starts looking like nails. It can be tempting to use protocols, PATs and type erasure everywhere in your app, however a common trap is to start using a protocol before knowing what it is you truly need. As a result you end up adding a new layer of complexity to your code. In fact, we should think twice before using them, and be really sure that it is the right level of abstraction that is required for our use case.