Combining protocols in Swift
Basics article available: ProtocolsOne of the core strengths of Swift’s protocols is that they enable us to define shared interfaces that multiple types can conform to, which in turn lets us interact with those types in a very uniform way, without necessarily knowing what underlying type that we’re currently dealing with.
For example, to clearly define an API that enables us to persist a given instance onto disk, we might choose to use a protocol that looks something like this:
protocol DiskWritable {
func writeToDisk(at url: URL) throws
}
One advantage of defining commonly used APIs that way is that it helps us keep our code consistent, as we can now make any type that should be disk-writable conform to the above protocol, which then requires us to implement the exact same method for all such types.
Another big advantage of Swift protocols is that they’re extendable, which makes it possible for us to define all sorts of convenience APIs for both our own protocols, as well as those that are defined externally — for example within the standard library, or within any framework that we’ve imported.
When writing those kinds of convenience APIs, we might also want to mix the protocol that we’re currently extending with some functionality provided by another protocol. For example, let’s say that we wanted to provide a default implementation of our DiskWritable
protocol’s writeToDisk
method for types that also conform to the Encodable
protocol — since a type that’s encodable can be transformed into Data
, which we could then automatically write to disk.
One way to make that happen would be to make our DiskWritable
protocol inherit from Encodable
, which in turn will require all conforming types to implement both of those two protocols’ requirements. We could then simply extend DiskWritable
in order to add that default implementation of writeToDisk
that we were looking to provide:
protocol DiskWritable: Encodable {
func writeToDisk(at url: URL) throws
}
extension DiskWritable {
func writeToDisk(at url: URL) throws {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
try data.write(to: url)
}
}
While powerful, the above approach does have a quite significant downside, in that we’ve now completely coupled our DiskWritable
protocol with Encodable
— meaning that we can no longer use that protocol by itself, without also requiring any conforming type to also fully implement Encodable
, which might become problematic.
Another, much more flexible approach would be to let DiskWritable
remain a completely stand-alone protocol, and instead write a type-constrained extension that only adds our default writeToDisk
implementation to types that also conform to Encodable
separately — like this:
extension DiskWritable where Self: Encodable {
func writeToDisk(at url: URL) throws {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
try data.write(to: url)
}
}
The tradeoff here is that the above approach does require each type that wants to leverage our default writeToDisk
implementation to explicitly conform to both DiskWritable
and Encodable
, which might not be a big deal, but it could make it a bit harder to discover that default implementation — since it’s no longer automatically available on all DiskWritable
-conforming types.
One way to address that discoverability issue, though, could be to create a convenience type alias (using Swift’s protocol composition operator, &
) that gives us an indication that DiskWritable
and Encodable
can be combined to unlock new functionality:
typealias DiskWritableByEncoding = DiskWritable & Encodable
When a type conforms to those two protocols (either using the above type alias, or completely separately), it’ll now get access to our default writeToDisk
implementation (while still having the option to provide its own, custom implementation as well):
struct TodoList: DiskWritableByEncoding {
var name: String
var items: [Item]
...
}
let list = TodoList(...)
try list.writeToDisk(at: fileURL)
Combining protocols like that can be a really powerful technique, as we’re not just limited to adding default implementations of protocol requirements — we can also add brand new APIs to any protocol combination, simply by adding new methods or computed properties within one of our extensions.
For example, here we’ve added a second overload of our writeToDisk
method, which makes it possible to pass a custom JSONEncoder
that’ll be used when serializing the current instance:
extension DiskWritable where Self: Encodable {
func writeToDisk(at url: URL, encoder: JSONEncoder) throws {
let data = try encoder.encode(self)
try data.write(to: url)
}
func writeToDisk(at url: URL) throws {
try writeToDisk(at: url, encoder: JSONEncoder())
}
}
We do have to be bit careful not to over-use the above pattern, though, since doing so could introduce conflicts if a given type ends up getting access to multiple default implementations of the same method.
To illustrate, let’s say that our code base also contains a DataConvertible
protocol, which we’d like to extend with a similar, default implementation of writeToDisk
— like this:
protocol DataConvertible {
func convertToData() throws -> Data
}
extension DiskWritable where Self: DataConvertible {
func writeToDisk(at url: URL) throws {
let data = try convertToData()
try data.write(to: url)
}
}
While both of the two DiskWritable
extensions that we’ve now created make perfect sense in isolation, we’ll now end up with a conflict if a given DiskWritable
-conforming type also wants to conform to both Encodable
and DataConvertible
at the same time (which is highly likely, since both of those protocols are about transforming an instance into Data
).
Since the compiler won’t be able to pick which default implementation to use in cases like that, we’d have to manually implement our writeToDisk
method specifically for each of those conflicting types. Not a big problem, perhaps, but it could lead us to a situation where it’s hard to tell which method implementation that will be used for which type, which in turn could make our code feel quite unpredictable and harder to debug and maintain.
So let’s also explore one final, alternative approach to the above set of problems — which would be to implement our disk-writing convenience APIs within a dedicated type, rather than using protocol extensions. For example, here’s how we could define an EncodingDiskWriter
, which only requires the types that it’ll be used with to conform to Encodable
, since the writer itself conforms to DiskWritable
:
struct EncodingDiskWriter<Value: Encodable>: DiskWritable {
var value: Value
var encoder = JSONEncoder()
func writeToDisk(at url: URL) throws {
let data = try encoder.encode(value)
try data.write(to: url)
}
}
So even though the following Document
type doesn’t conform to DiskWritable
, we can still easily write its data to disk using our new EncodingDiskWriter
:
struct Document: Identifiable, Codable {
let id: UUID
var name: String
...
}
class EditorViewController: UIViewController {
private var document: Document
private var fileURL: URL
...
private func save() throws {
let writer = EncodingDiskWriter(value: document)
try writer.writeToDisk(at: fileURL)
}
}
So, although protocol extensions provide us with an incredibly powerful set of tools, it’s always important to remember that there are other alternatives that might be a better fit for what we’re trying to build.
Like with so many things in programming, there are no right or wrong answers here, but I hope that this article has shown a few different ways to combine the functionality of multiple protocols, and what sort of tradeoffs that each approach comes with. If you have any questions, comments, or feedback, then feel free to send me an email, or reach out via Twitter.
Thanks for reading!