Patterns for Working With Associated Types
Understand how to model your way around some of the issues that arise when introducing associated types into your protocols
Associated Types
Swift is a powerful language with a very powerful type system. Among the
features that define said type system are associated types
. They can
be defined on a protocol
to allow implementors of the protocol
to
specialize certain types in a generic way:
protocol Example {
associatedtype Value
var value: Value { get }
}
In the snippet above, any type that implements the Example
protocol
has to define the Value
type. Protocols with associated types
can be
understood as unfinished types. Compared to regular protocols, which
can be used within Swift like normal types, those protocols can only be
used as a generic constraint. This means that once your type requires an
associated type
, using it suddenly becomes much more complicated.
The example below shows an example of finishing a type. By
explicitly telling the compiler that the Value
type is Int
it is now
able to understand ImplementExample
fully.
struct ImplementExample: Example {
typealias Value = Int
}
Associated types are useful for a certain kind of problems where subclassing and composition does allow you to build the right kind of abstractions. However, this is a seperate topic. The topic of this article, on the other hand, is what to do when you end up with associated types trouble.
Associated Types Trouble
The classic example of associated types
trouble certainly is the
following Swift error message:
This happens once your type conforms to a protocol which conforms to
Equatable
:
protocol Bookmarkable: Equatable {
}
struct User {
var bookmarks: [Bookmarkable]
}
Here, the problem is that Equatable
contains a method ==
which has
two paramters of type Self
. Protocol Methods with Self
parameters
automatically opt in to associated types
.
Let's investigate several patterns that allow
you to work your way around the associated type
requirement or that
show how such a type can be handled.
Make Your Types Equatable
The first solution for the archetypical problem is also a really simple
one. Instead of enforcing Equatable
on your custom protocol
, you can
simply require your full fledged, final, types to conform to the
Equatable
protocol instead of your custom protocol. Consider the
previously defined Bookmarkable
protocol:
protocol Bookmarkable {
}
struct Bookmark: Bookmarkable, Equatable {
var identifier: Int
}
func ==(lhs: Bookmark, rhs: Bookmark) -> Bool {
return lhs.identifier == rhs.identifier
}
var myBookmarks: [Bookmark] = []
In the example above, the Equatable
requirement actually stems from
the Bookmark
type conforming to the Equatable
protocol, not the
Bookmarkable
protocol itself. The actual Equatable
information,
however, lies in the new identifier
property, which has been added to
the Bookmark
struct
. As you can easily see, this also requires you
to make the myBookmarks
array require only elements of type
Bookmark
. A serious disgression if you're used to using protocols
like partially anonymous types. A better solution, if your design allows
for it, goes one step further by enforcing the new property
which we
introduced in this example.
Equatable Properties
Here, the idea is that we take one of the types that already implement
Equatable
in a proper way (i.e. Int
, String
, ...) and add a new
property
requirement to our Bookmarkable
protocol. Then, we can use
this property
to add Equatable
support without actually implementing
Equatable
:
protocol Bookmarkable {
var identifier: Int { get }
}
struct Bookmark: Bookmarkable {
var identifier: Int
}
var myBookmarks: [Bookmarkable] = []
The main change, compared to the code above, is that the
var identifier
moved to the Bookmarkable
protocol and that we
removed the func ==
.
While this works better, it still has a major deficit. Since
Bookmarkable
does not directly comply with Equatable
, you will not
gain the standard library's methods that specifically deal with
Equatable
types. So instead of being able to call Array.contains
like this:
let ourBookmark = Bookmark(identifier: 0)
let result = myBookmarks.contains(ourBookmark)
You will have to use the more verbose closure-based version:
let ourBookmark = Bookmark(identifier: 0)
let result = myBookmarks.contains { (bookmark) -> Bool in
return bookmark.identifier == ourBookmark.identifier
}
Associated Types and Self
Another vector which can introduce associated types
into your codebase
is the usage of Self
:
protocol Example {
/// Indirect Associated Type
var builder: Self { get }
/// Indirect Associated Type
func makeSomething(with example: Self)
}
var myExamples: [Example] = []
As you can see in the example above, using Self
as a method parameter
or using Self
as a property type automatically introduces an
associated type
(like we saw with Equatable
, earlier).
The most helpful note here is that once you use a method
instead of a
property
in order to return something of type Self
you will not opt
in to an associated type
:
protocol Example {
/// No Indirect Associated Type
func builder() -> Self
}
var myExamples: [Example] = []
This example works fine. No indirect associated
type is introduced.
Method-Only Types
If your associated type
requirement doesn't come from Equatable
conformance but instead from your own use, you can double-check if you
actually need these associated types.
Take this example of a validator type:
protocol Validator {
associatedtype I
func validate(_ input: I) -> Bool
}
As the associated type
is only used in one method, you can
alternatively just make it a generic
method and thus save yourself
from introducing unnecessary unfinished types:
protocol Validator {
func validate<I>(_ input: I) -> Bool
}
Hiding Behind Protocols
This is an especially useful and flexible pattern. It can be used in
many situations where you want to use protocols with associated types
like a normal, full fledged type, but still be able to opt in to the
generic part if necessary. The idea here is that you define two
protocols that share common methods. Only one of those protocols
contains associated types
, the other does not. Your types conform to
both protocols. This means that you can use the normal protocol as a
type for all situations. If you, then, need to use the parts of the type
that only affect the associated type
, you can do so by means of a
runtime cast.
Begin by defining an associated
Protocol ExampleAssociatedProtocol
that is shadowed by a normal
Protocol ExampleProtocol
.
/// The `Normal` Protocol
protocol ExampleProtocol {
var anyValue: Any { get }
}
/// The Protocol with an associated type
protocol ExampleAssociatedProtocol: ExampleProtocol {
associatedtype Value
/// Retrieving the actual associated type
var value: Value { get }
}
/// Conform to the `ExampleProtocol`
extension ExampleAssociatedProtocol {
var anyValue: Any {
return value
}
}
Now, you can use the ExampleProtocol
as a normal type throughout your
app in all situations where a protocol with an associated type
would
otherwise fail:
struct World {
var examples: [ExampleProtocol]
let example: ExampleProtocol
func generate() -> ExampleProtocol {
return example
}
}
However, if you need to access the property that is specific to the
ExampleAssociatedProtocol
(value
) then you can do so through at
runtime.
/// Custom type implementing `ExampleAssociatedProtocol`
struct IntExample: ExampleAssociatedProtocol {
var value: Int
}
/// Custom type implementing `ExampleAssociatedProtocol`
struct StringExample: ExampleAssociatedProtocol {
var value: String
}
/// Shadowing via `ExampleProtocol`
let myExamples: [ExampleProtocol] =
[StringExample(value: \"A\"), IntExample(value: 10)]
/// Runtime Casting
for aNormalExample in myExamples {
if let anAssociatedExample = aNormalExample as? IntExample {
print(anAssociatedExample.value)
}
if let anAssociatedExample = aNormalExample as? StringExample {
print(anAssociatedExample.value)
}
}
This will print "A10" as both types (IntExample
and StringExample
)
are being identified at runtime via a cast from ExampleProtocol
.
Type Erasure
Quite often, when Swift's associated types are dicussed, type erasure
is mentioned as another solution to the problem of handling the issues
that associated types
bring along.
Type Erasure in the context of associated types
solves one particular
problem. We'll use computers as an example. Back in the golden age of
desktop operating systems, you could buy a desktop computer with many
non-X86 CPU architectures: PowerPC, Alpha, Sparc, 68000, and so on. One
of the many differences were the endianness
of the architecture. Lets
model these computers in Swift:
protocol CPU {
var littleEndian: Bool { get }
}
struct PowerPC: CPU {
let littleEndian = false
}
struct X86: CPU {
let littleEndian = true
}
Next up, we want to define a protocol for a computer. It could be a
desktop computer or a phone or maybe a game console, so we use a
protocol. In order to model the CPU, we're using an associated type
,
so that the actual type can define the CPU:
protocol Computer {
associatedtype ProcessorType: CPU
var processor: ProcessorType { get }
var processorCount: Int { get }
}
Based on this, we can now define a couple of systems:
struct PowerMacG5: Computer {
let processor = PowerPC()
let processorCount = 2
}
struct Xbox360: Computer {
let processor = PowerPC()
let processorCount = 1
}
struct MacPro: Computer {
let processor = X86()
let processorCount = 1
}
Now that we have all this, we'd like to perform a computation on all PowerPC based computers. I.e. something like:
let powerComputers = [PowerMacG5(), Xbox360()]
However, what would be the type of this? We can't use the Computer
protocol, as it contains associated types
. However, the
associated types
for the PowerMacG5 and the Xbox360 are the
same, so in terms of types, Swift ought to understand that those things
are kinda similar. However, there's no way to (easily) express this in
the type system; both PowerMacG5 and Xbox360 are not the correct
types for the array:
// None of those work
let powerComputers: [PowerMacG5] = [PowerMacG5(), Xbox360]
let powerComputers: [Xbox360] = [PowerMacG5(), Xbox360]
let powerComputers: [Computer] = [PowerMacG5(), Xbox360]
Type erasure is a solution for this. The idea is to box the actual type into a generic wrapper so that Swift can coalesce around wrapper + type. The solution we're aiming for would look like this in the end:
let powerComputers: [AnyComputer<PowerPC>] = [AnyComputer(PowerMacG5()), AnyComputer(Xbox360())]
Now we would have our shared type, in this case it is
AnyComputer<CPU>
. Where does this mystic AnyComputer
come from? We
have to build it ourselves. This is a multi-step process, and requires
quite a bit of boilerplate. We will start simple and expand step by
step. This solution requires multiple types.
An Abstract Class
In essense, what we're going to build, is a generic wrapper (or box)
that hosts a type conforming to a protocol
with an associated type
.
It does so by implementing the requirements of the protocol
and
forwarding all invocations to the boxed type.
The first new type we need for that is a base class
that acts as a
abstract class:
class AnyComputerBase<Processor: CPU>: Computer {
var processor: Processor {
fatalError()
}
var processorCount: Int {
fatalError()
}
}
This class
should never be initialized, as it only provides an
abstract template of what subclasses should implement. While other
languages (like Java) allow explicitly marking classes as abstract,
Swift doesn't offer us a way to do so. One solution to this is adding a
fileprivate init
to this class
. However as that requires subclasses
to be in the same file as this superclass, we can also just make the
whole class
private
with an even better result. Now, other parts of
the code won't even know about the existence of AnyComputerBase
or
even initialize
it:
private class AnyComputerBase<Processor: CPU>: Computer {
...
}
Why do we even need this, and what does it do? As you can see, it just
implements the Computer
protocol
by implementing the requirements
and doing nothing in there. The more important part is that it moves the
associated type
from the protocol into a generic type for the class
:
AnyComputerBase<Processor: CPU>
.
Swift automatically figures out that Processor
is the typealias
for
Computer.ProcessorType
. However, when in doubt you can also add an
extra typealias:
class AnyComputerBase<Processor: CPU>: Computer {
typealias ProcessorType = Processor
...
}
A Box Type
The next step is the most difficult to understand part of type erasure,
which means that after this, it'll be easy. We will introduce another
private
type. This will be the actual box that houses our original
type (the XBox360 or the PowerMac G5). Let's start by having a look at
the code:
private class AnyComputerBox<ConcreteComputer: Computer>:
AnyComputerBase<ConcreteComputer.ProcessorType>
{
private let internalComputer: ConcreteComputer
override var processor: ConcreteComputer.ProcessorType {
return internalComputer.processor
}
override var processorCount: Int {
return internalComputer.processorCount
}
init(_ computer: ConcreteComputer) {
internalComputer = computer
}
}
The most important concept here can be found in the very first line:
private class AnyComputerBox<ConcreteComputer: Computer>:
AnyComputerBase<ConcreteComputer.ProcessorType>
Here, we define a new type AnyComputerBox
which is generic over
any computer (ConcreteComputer
). This new type, then, is a
subclass of our earlier abstract class AnyComputerBase
. Remember that
AnyComputerBase
made the original ProcessorType
of the Computer
protocol generic by adding it as a generic parameter CPU
. Now, our new
box has a different generic type (Computer
) and provides only its
associated type
ProcessorType to the abstract superclass. In a
simpler explanation, this is what happens (in a mock language):
Computer<CPU>
AnyComputerBase<Processor: CPU>: Computer<CPU> where Computer.CPU = Processor
AnyComputerBox<ConcreteComputer: Computer>: AnyComputerBase<ConcreteComputer.ProcessorType>
So the box (AnyComputerBox
) subclasses the abstract class and forwards
in the Processor
type via its own generic Computer
type which also
has a ProcessorType
.
Why do we do this? It makes the box generic over any computer so that any computer can be boxed into it.
The rest of the class
is simple. There's an internal
computer
internalComputer
which is the actual type conforming to the Computer
protocol
. We're also overriding the two classes that are required by
the protocol and forwarding the implementations of the
internalComputer
. Finally we have an initializer with a new
ConcreteComputer
(i.e. the Computer
protocol).
Puttting it all together
In the next and final step, we're building the actual type that will be used as the proverbial type eraser. Just as before, lets have a look at the code first:
final class AnyComputer<Processor: CPU>: Computer {
private let box: AnyComputerBase<Processor>
var processor: Processor {
return box.processor
}
var processorCount: Int {
return box.processorCount
}
init<Concrete: Computer>(_ computer: Concrete)
where Concrete.ProcessorType == Processor {
box = AnyComputerBox(computer)
}
}
This AnyComputer
conforms to the Computer
protocol and is generic
over the CPU
type that the protocol requires. Once again, we implement
the protocol requirements (processor
, and processorCount
) and
forward to a boxed type. This time we're forwarding to
private let box: AnyComputerBase<Processor>
. This box
is set in the
initializer where most of the magic happens:
init<Concrete: Computer>(_ computer: Concrete)
where Concrete.ProcessorType == Processor {
box = AnyComputerBox(computer)
}
The problem with protocols with associated types
is that you can't
use them as property types. Here, init
requires any type conforming to
the Computer
protocol. This is done by having a method-generic type
Concrete
that requires Computer
conformance. Even more, we also add
a constraint that makes sure that the generic Processor
type of the
new AnyComputer
class is the same type as the associated type
of the
Concrete
Computer
type.
And now comes the kicker: Since we cannot set a property as being of
type Computer
we, instead, have a property that is of
AnyComputerBase
with a generic type for the Processor
. As our
AnyComputerBox
type is a subclass of AnyComputerBase
we can
literally put any box (that is a subclass of AnyComputerBase
into
this property. In this case, we're creating a new box with the
Concrete
Computer
.
Then we return the implementations of the contents of the box (i.e. the
actual Concrete
Computer
) in our Computer
implementations:
var processorCount: Int {
return box.processorCount
}
Using It
With all this machinery in place, we can finally use this in order to have different types (which share an associated type) in one container:
let powerComputers: [AnyComputer<PowerPC>] =
[AnyComputer(PowerMacG5()), AnyComputer(Xbox360())]
Conclusion
Associated types
are a powerful concept however they come with a fair
share of difficulties. Most notably, as soon as you introduce an
associated type
you can't use it like you'd use normal full types.
This article provided several patterns that make it a bit easier to
handle associated type
problems in your codebase. Each of these
patterns has downsides though. In general, if you intend to use
associated types
in a protocol
, one of the best solutions is to try
to only use the types that implement this protocol
instead of the
protocol
itself. Because then you don't even need those patterns.
Similar Articles |
---|