Alternatives to protocols in Swift
Basics article available: ProtocolsProtocols are, without a doubt, a major part of Swift’s overall design — and can provide a great way to create abstractions, to separate concerns, and to improve the overall flexibility of a system or feature. By not strongly tying types together, but rather connecting the various parts of a code base through more abstract interfaces, we usually end up with a much more decoupled architecture that lets us iterate on each individual feature in isolation.
However, while protocols can be a great tool in many different situations, they also come with their own set of downsides and trade-offs. This week, let’s take a look at some of those characteristics, and explore a few alternative ways of abstracting code in Swift — to see how they compare to using protocols.
Single requirements using closures
One of the advantages of abstracting code using protocols is that it lets us group multiple requirements together. For example, a PersistedValue
protocol might require both a save
and a load
method — which both enables us to enforce a certain degree of consistency among all such values, and to write shared utilities for saving and loading data.
However, not all abstractions involve multiple requirements, and it’s very common to end up with protocols that only have a single method or property — such as this one:
protocol ModelProvider {
associatedtype Model: ModelProtocol
func provideModel() -> Model
}
Let’s say that the above ModelProvider
protocol is used to abstract the way we load and provide models across our code base. It uses an associated type in order to let each implementation declare what type of model that it provides in a very type-safe way, which is great, as it enables us to write generic code to perform common tasks — such as rendering a detail view for a given model:
class DetailViewController<Model: ModelProtocol>: UIViewController {
private let modelProvider: AnyModelProvider<Model>
init<T: ModelProvider>(modelProvider: T) where T.Model == Model {
// We wrap the injected provider in an AnyModelProvider
// instance to be able to store a reference to it.
self.modelProvider = AnyModelProvider(modelProvider)
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
let model = modelProvider.provideModel()
...
}
...
}
While the above code works, it illustrates one of the downsides of using a protocol with associated types — we can’t store a reference to ModelProvider
directly. Instead we first have to perform type erasure to turn our protocol reference into a concrete type, which both clutters up our code, and requires us to implement additional types just to be able to use our protocol.
Since we’re dealing with a protocol that only has a single requirement, the question is — do we really need it? After all, our ModelProvider
protocol doesn’t add any additional grouping or structure, so let’s instead lift out its sole requirement and turn that into a closure — which can then be injected directly, like this:
class DetailViewController<Model: ModelProtocol>: UIViewController {
private let modelProvider: () -> Model
init(modelProvider: @escaping () -> Model) {
self.modelProvider = modelProvider
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
let model = modelProvider()
...
}
...
}
To learn more about the above way of dependency injection, check out “Simple Swift dependency injection with functions”, and “Functional networking in Swift”.
By directly injecting the functionality that we need, rather than requiring a type to conform to a protocol, we’ve also drastically improved the flexibility of our code — since we’re now free to inject anything from a free function, to an inline defined closure, to an instance method. We also no longer have to perform any type erasure, leaving us with much simpler code.
Using generic types
While closures and functions can be a great way to model single-requirement abstractions, using them can get a bit messy if we’ll start adding additional requirements. For example, let’s say that we’d like to extend our above DetailViewController
to also support bookmarking and deleting models. If we were to stick with our closure-based approach, we’d end up with something like this:
class DetailViewController<Model: ModelProtocol>: UIViewController {
private let modelProvider: () -> Model
private let modelBookmarker: (Model) -> Void
private let modelDeleter: (Model) -> Void
init(modelProvider: @escaping () -> Model,
modelBookmarker: @escaping (Model) -> Void,
modelDeleter: @escaping (Model) -> Void) {
self.modelProvider = modelProvider
self.modelBookmarker = modelBookmarker
self.modelDeleter = modelDeleter
super.init(nibName: nil, bundle: nil)
}
...
}
Not only does the above setup require us to keep track of multiple stand-alone closures, we also end up with a lot of duplicated “model” prefixes — which (using the “Rule of Threes”) tells us that we have somewhat of a structural problem here. While we could go back to encapsulating all of the above closures into a protocol, that’d again require us to do type erasure, and to lose some of that flexibility that we gained when we started using closures.
Instead, let’s use a generic type to group our requirements together — which both lets us retain the flexibility of using closures, while also adding some additional structure to our code:
struct ModelHandling<Model: ModelProtocol> {
var provide: () -> Model
var bookmark: (Model) -> Void
var delete: (Model) -> Void
}
Since the above is a concrete type, it doesn’t require any form of type erasure (in fact, it actually looks quite similar to the sort of type erased wrappers that we’re often forced to write when using protocols with associated types). So, just like a closure, it can be used and stored directly — like this:
class DetailViewController<Model: ModelProtocol>: UIViewController {
private let modelHandler: ModelHandling<Model>
private lazy var model = modelHandler.provide()
init(modelHandler: ModelHandling<Model>) {
self.modelHandler = modelHandler
super.init(nibName: nil, bundle: nil)
}
@objc private func bookmarkButtonTapped() {
modelHandler.bookmark(model)
}
@objc private func deleteButtonTapped() {
modelHandler.delete(model)
dismiss(animated: true)
}
...
}
While protocols with associated types are incredibly useful when defining more high-level requirements (just like the standard library’s Equatable
and Collection
protocols do), when such a protocol needs to be used directly, using either a stand-alone closure or a generic type instead can often give us the same level of encapsulation — but through a much simpler abstraction.
Separating requirements using enums
A common challenge when designing any sort of abstraction is to not “over-abstract” by adding too many requirements. For example, let’s now say that we’re working on an app that lets the user consume multiple kinds of media — like articles, podcasts, videos, and so on — and that we’d like to create a shared abstraction for all of those different formats. If we again start with the protocol-oriented approach, we might end up with something like this:
protocol Media {
var id: UUID { get }
var title: String { get }
var description: String { get }
var text: String? { get }
var url: URL? { get }
var duration: TimeInterval? { get }
var resolution: Resolution? { get }
}
Since the above protocol needs to work with all different kinds of media, we end up with multiple properties that are only relevant for certain formats. For example, an Article
type doesn’t have any concept of a duration or resolution — leaving us with several properties that we simply have to implement because our protocol requires us to:
struct Article: Media {
let id: UUID
var title: String
var description: String
var text: String?
var url: URL? { return nil }
var duration: TimeInterval? { return nil }
var resolution: Resolution? { return nil }
}
Not only does the above setup require us to add unnecessary boilerplate to our conforming types, it could also be a source of ambiguity — as there’s no way for us to enforce that an article actually contains text, or that the types that should support a URL, duration or resolution actually carries that data — since all of those properties are optionals.
There are multiple ways that we could address the above problem, starting with splitting our protocol up into multiple ones, each with an increasing degree of specialization — like this:
protocol Media {
var id: UUID { get }
var title: String { get }
var description: String { get }
}
protocol ReadableMedia: Media {
var text: String { get }
}
protocol PlayableMedia: Media {
var url: URL { get }
var duration: TimeInterval { get }
var resolution: Resolution? { get }
}
The above is definitely an improvement, as it would enable us to have types like Article
conform to ReadableMedia
, and playable types (like Audio
and Video
) conform to PlayableMedia
— reducing both ambiguity and boilerplate, as each type can pick which specialized version of Media
that it wants to conform to.
However, since the above protocols are all about data, it would arguably make even more sense to model them using an actual data type instead — which would both reduce the need for duplicate implementations, and would also let us work with any media format through a single, concrete type:
struct Media {
let id: UUID
var title: String
var description: String
var content: Content
}
The above struct now only contains the data that’s shared among all of our media formats, except for its content
property — which is what we’ll use for specialization. But this time, rather than making Content
a protocol, let’s use an enum — which will enable us to define a tailored set of properties for each format, through associated values:
extension Media {
enum Content {
case article(text: String)
case audio(Playable)
case video(Playable, resolution: Resolution)
}
struct Playable {
var url: URL
var duration: TimeInterval
}
}
Gone are the optionals, and we’ve now achieved a nice balance between having a shared abstraction and enabling format-specific specialization. The beauty of enums is also that they enable us to express data variance without having to use either generics or protocols — everything can be encapsulated within the same, concrete type, as long as we know the number of variants up-front.
Classes and inheritance
Another approach that might not be as popular in Swift as in other languages, but is still definitely worth considering, is to create abstractions using classes that are specialized through inheritance. For example, rather than using a Content
enum to implement our above media formats, we could’ve used a Media
base class that would then be subclassed in order to add format-specific properties — like this:
class Media {
let id: UUID
var title: String
var description: String
init(id: UUID, title: String, description: String) {
self.id = id
self.title = title
self.description = description
}
}
class PlayableMedia: Media {
var url: URL
var duration: TimeInterval
init(id: UUID,
title: String,
description: String,
url: URL,
duration: TimeInterval) {
self.url = url
self.duration = duration
super.init(id: id, title: title, description: description)
}
}
However, while the above approach makes total sense from a structural perspective — it does come with a few downsides. First, since classes don’t yet support memberwise initializers, we have to define all initializers ourselves — and we also have to manually pass data upwards through our inheritance tree by calling super.init
. But perhaps more importantly is that classes are reference types, which means that we’d have to be careful not to perform any unexpected mutations when sharing Media
instances across our code base.
But that doesn’t mean that there are no valid use cases for inheritance in Swift. For example, in “Under the hood of Futures & Promises in Swift”, inheritance provided a great way to expose a read-only Future
type to API users — while still enabling such an instance to be privately mutated through a Promise
subclass:
class Future<Value> {
fileprivate var result: Result<Value, Error>? {
didSet { result.map(report) }
}
...
}
class Promise<Value>: Future<Value> {
func resolve(with value: Value) {
result = .success(value)
}
func reject(with error: Error) {
result = .failure(error)
}
}
func loadCachedData() -> Future<Data> {
let promise = Promise<Data>()
cache.load { promise.resolve(with: $0) }
return promise
}
Using the above setup, we can enable the same instance to expose a different set of APIs in different contexts, which is really useful when we want to only allow one of those contexts to mutate a given object. That’s especially true when working with generic code, as we’d again run into the associated types problem if we were to try to achieve the same thing using a protocol instead.
Conclusion
Protocols are great, and will most likely remain the most common way of defining abstractions in Swift for the foreseeable future. However, that doesn’t mean that using a protocol will always be the best solution — sometimes looking beyond the popular “protocol-oriented programming” mantra can result in code that’s both simpler and more robust — especially if the protocol we’re looking to define requires us to use associated types.
What do you think? Besides protocols, what are your favorite ways of creating abstractions in Swift? Let me know — along with any questions, comments or feedback that you might have — either on Twitter or via email.
Thanks for reading! 🚀