Specializing protocols in Swift
Basics article available: ProtocolsProtocols continue to be an integral part of Swift - both in terms of how the language itself is designed, and also in how the standard library is structured. It's therefore not very surprising that pretty much every release of Swift adds new features related to protocols - making them even more flexible and more powerful.
Especially when designing abstractions within an app or system, protocols provide a way to clearly separate different types from each other, and to setup more well-defined APIs - which usually makes everything from testing to refactoring much simpler.
This week, let's take a look at how we can use protocols to create multiple levels of abstraction, and try out a few different techniques that let us start out with a more general protocol that we then increasingly specialize to become more and more specific to each use case.
Inheritance
Just like classes, one protocol can inherit the requirements of another protocol - making it possible to form hierarchies, with the added flexibility that (unlike classes) protocols are able to inherit from multiple parents. This is especially useful when one protocol will most often require properties or methods from a parent protocol when used.
An example of that from the standard library is Hashable
, which inherits from Equatable
. That makes a ton of sense, since when a hash value is used - for example when a value is inserted into a set - each successful hash value check is followed by an equality check as well.
Let's take a look at an example of when this pattern can be useful in our own code as well. Let's say that we're building an app that supports multiple kinds of users - logged in members, anonymous users that haven't created an account yet, and admins. To be able to have separate implementations for each kind of user, but still share code between them, we create a User
protocol that defines the basic requirements for each user type - like this:
protocol User {
var id: UUID { get }
var name: String { get }
}
Since each of our user types already has the above two properties, we can easily make them conform to our User
protocol using a series of empty extensions:
extension AnonymousUser: User {}
extension Member: User {}
extension Admin: User {}
Doing the above is already very useful, since we're now able to define functions that accept any User
, without having to know anything specific about the different kinds of users that exist. However, using protocol inheritance, we can take things a bit further and create specialized versions of our protocol for specific features.
Take authentication for example - both members and admins are authenticated, so it would be really nice to be able to share code dealing with authentication between those two - without requiring our AnonymousUser
type to have any knowledge about that.
To do that, let's create another protocol - AuthenticatedUser
, that inherits from our standard User
protocol and then adds new properties related to authentication - for example an accessToken
. We'll then make both Member
and Admin
conform to it, like this:
protocol AuthenticatedUser: User {
var accessToken: AccessToken { get }
}
extension Member: AuthenticatedUser {}
extension Admin: AuthenticatedUser {}
We can now use our new AuthenticatedUser
protocol in contexts where we require a logged in user, for example when creating a request for loading data from a backend endpoint that requires authentication:
class DataLoader {
func load(from endpoint: ProtectedEndpoint,
onBehalfOf user: AuthenticatedUser,
then: @escaping (Result<Data>) -> Void) {
// Since 'AuthenticatedUser' inherits from 'User', we
// get full access to all properties from both protocols.
let request = makeRequest(for: endpoint,
userID: user.id,
accessToken: user.accessToken)
...
}
}
Setting up a hierarchy of smaller protocols that, through inheritance, become increasingly specialized can also be a great way to avoid type casting and non-optional optionals - since we only need to add the more specific properties and methods to the types that actually support them. No more empty method implementations or properties that will always remain nil
, just for the sake of conforming to a protocol.
Specialization
Next, let's take a look at how we can further specialize child protocols when inheriting from a protocol that uses associated types. Let's say that we're building a component-driven UI system for an app, in which a component can be implemented in different ways - for example using a UIView
, a UIViewController
or a CALayer
. To enable that degree of flexibility, we'll start with a very generic protocol called Component
, that enables each component to decide what kind of container that it can be added to:
protocol Component {
associatedtype Container
func add(to container: Container)
}
Most of our components will probably be implemented using views - so for convenience we'll create a specialized version of Component
for that. Just like before with our user protocols, we'll have our new ViewComponent
protocol inherit from Component
, but with the added twist that we'll require its container type to be some kind of UIView
. That can be done with a generic constraint, using the where
clause, like this:
protocol ViewComponent: Component where Container: UIView {
associatedtype View: UIView
var view: View { get }
}
An alternative to doing the above would be to use that where
clause every time we want to deal with UIView
-based components, but setting up a specialized protocol for it can help us remove a lot of boilerplate and make things a bit more streamlined.
Now that we have a compile-time guarantee that all ViewComponent
-conforming components have a view, and that their container type is also a view, we can use protocol extensions to add default implementations of some of the base protocol requirements - like this:
extension ViewComponent {
func add(to container: Container) {
container.addSubview(view)
}
}
This is another example of just how powerful Swift's type system is becoming, and how it lets us both enable a high degree of flexibility, while also reducing boilerplate - using things like generic constraints and protocol extensions.
Composition
Finally, let's take a look at how we can specialize protocols through composition. Let's say that we have an Operation
protocol that we use to implement all sorts of asynchronous operations. Since we're using a single protocol for all operations, it currently requires us to implement quite a few different methods for each one:
protocol Operation {
associatedtype Input
associatedtype Output
func prepare()
func cancel()
func perform(with input: Input,
then handler: @escaping (Output) -> Void)
}
Setting up larger protocols like we do above is not really wrong, but can lead to some redundant implementations if we - for example - have certain operations that can't be cancelled or don't really require any specific preparation (since we're still required to implement those protocol methods).
This is something we can solve using composition. Let's again take some inspiration from the standard library - this time by looking at the Codable
type, which is actually just a typealias that composes two protocols - Decodable
and Encodable
:
typealias Codable = Decodable & Encodable
The beauty of the above approach is that types are free to only conform to either Decodable
or Encodable
, and we can write functions that only deal with either decoding or encoding, while still getting the convenience of being able to refer to both using a single type.
Using that same technique, we can decompose our Operation
protocol from before into three separate ones, each dedicated to a single task:
protocol Preparable {
func prepare()
}
protocol Cancellable {
func cancel()
}
protocol Performable {
associatedtype Input
associatedtype Output
func perform(with input: Input,
then handler: @escaping (Output) -> Void)
}
Then, just like how the standard library defines Codable
, we can add a typealias to compose all those three separate protocols back into an Operation
type - just like the bigger protocol we had before:
typealias Operation = Preparable & Cancellable & Performable
The benefit of the above approach, is that we can now selectively conform to different aspects of our Operation
protocol depending on each type's capabilities. We're also able to reuse our smaller protocols in different contexts - for example Cancellable
could now be used by all sorts of cancellable types - not only operations, which lets us write more generic code, like this extension on Sequence
which enables us to easily cancel any sequence of cancellable types using a single call:
extension Sequence where Element == Cancellable {
func cancelAll() {
forEach { $0.cancel() }
}
}
Pretty cool! 👍 Another big benefit of using smaller building block-style protocols like this is that creating mocks for testing becomes much easier, since we'll be able to only mock the methods and properties that are actually used by the API that we're testing.
Conclusion
As protocols continue to become more and more powerful, more ways of using them open up. Just like how the standard library makes heavy use of protocols to reuse algorithms and other code between various types, we can also use specialized protocols to set up multiple levels of abstraction - each dedicated to solving a specific set of problems.
However, just like when designing any kind of abstraction, it's also important not to jump to the conclusion that a protocol is the right choice too quickly. Even though Swift is often referred to as a "protocol-oriented language", protocols can sometimes add more overhead and complexity than needed. Sometimes just using concrete types is more than good enough, and is perhaps a better starting point - since types can most often be retrofitted with protocols later.
What do you think? Do you usually create multiple specialized versions of protocols, or is it something you'll try out? Let me know - along with your questions, comments and any other feedback you might have - on Twitter @johnsundell.
Thanks for reading! 🚀