Abstract types and methods in Swift
Discover page available: GenericsIn object-oriented programming, an abstract type provides a base implementation that other types can inherit from in order to gain access to some kind of shared, common functionality. What separates abstract types from regular ones is that they’re never meant to be used as-is (in fact, some programming languages even prevent abstract types from being instantiated directly), since their sole purpose is to act as a common parent for a group of related types.
For example, let’s say that we wanted to unify the way we load certain types of models over the network, by providing a shared API that we’ll be able to use to separate concerns, to facilitate dependency injection and mocking, and to keep method names consistent throughout our project.
One abstract type-based way to do that would be to use a base class that’ll act as that shared, unified interface for all of our model-loading types. Since we don’t want that class to ever be used directly, we’ll make it trigger a fatalError
if its base implementation is ever called by mistake:
class Loadable<Model> {
func load(from url: URL) async throws -> Model {
fatalError("load(from:) has not been implemented")
}
}
Then, each Loadable
subclass will override the above load
method in order to provide its loading functionality — like this:
class UserLoader: Loadable<User> {
override func load(from url: URL) async throws -> User {
...
}
}
If the above sort of pattern looks familiar, it’s probably because it’s essentially the exact same sort of polymorphism that we typically use protocols for in Swift. That is, when we want to define an interface, a contract, that multiple types can conform to through distinct implementations.
Protocols do have a significant advantage over abstract classes, though, in that the compiler will enforce that all of their requirements are properly implemented — meaning we no longer have to rely on runtime errors (such as fatalError
) to guard against improper use, since there’s no way to instantiate a protocol by itself.
So here’s what our Loadable
and UserLoader
types from before could look like if we were to go the protocol-oriented route, rather than using an abstract base class:
protocol Loadable {
associatedtype Model
func load(from url: URL) async throws -> Model
}
class UserLoader: Loadable {
func load(from url: URL) async throws -> User {
...
}
}
Note how we’re now using an associated type to enable each Loadable
implementation to decide what exact Model
that it wants to load — which gives us a nice mix between full type safety and great flexibility.
So, in general, protocols are definitely the preferred way to declare abstract types in Swift, but that doesn’t mean that they’re perfect. In fact, our protocol-based Loadable
implementation currently has two main drawbacks:
- First, since we had to add an associated type to our protocol in order to keep our setup generic and type-safe, that means that
Loadable
can no longer be referenced directly. - And second, since protocols can’t contain any form of storage, if we wanted to add any stored properties that all
Loadable
implementations could make use of, we’d have to re-declare those properties within every single one of those concrete implementations.
That property storage aspect is really a huge advantage of our previous, abstract class-based setup. So if we were to revert Loadable
back to a class, then we’d be able to store all objects that our subclasses would need right within our base class itself — removing the need to duplicate those properties across multiple types:
class Loadable<Model> {
let networking: Networking
let cache: Cache<URL, Model>
init(networking: Networking, cache: Cache<URL, Model>) {
self.networking = networking
self.cache = cache
}
func load(from url: URL) async throws -> Model {
fatalError("load(from:) has not been implemented")
}
}
class UserLoader: Loadable<User> {
override func load(from url: URL) async throws -> User {
if let cachedUser = cache.value(forKey: url) {
return cachedUser
}
let data = try await networking.data(from: url)
...
}
}
So, what we’re dealing with here is essentially a classic trade-off scenario, where both approaches (abstract classes vs protocols) give us a different set of pros and cons. But what if we could combine the two to sort of get the best of both worlds?
If we think about it, the only real issue with the abstract class-based approach is that fatalError
that we had to add within the method that each subclass is required to implement, so what if we were to use a protocol just for that specific method? Then we could still keep our networking
and cache
properties within our base class — like this:
protocol LoadableProtocol {
associatedtype Model
func load(from url: URL) async throws -> Model
}
class LoadableBase<Model> {
let networking: Networking
let cache: Cache<URL, Model>
init(networking: Networking, cache: Cache<URL, Model>) {
self.networking = networking
self.cache = cache
}
}
The main disadvantage of that approach, though, is that all concrete implementations will now have to both subclass LoadableBase
and declare that they conform to our new LoadableProtocol
:
class UserLoader: LoadableBase<User>, LoadableProtocol {
...
}
That might not be a huge issue, but it does arguably make our code a bit less elegant. The good news, though, is that we can actually solve that issue by using a generic type alias. Since Swift’s composition operator, &
, supports combining a class with a protocol, we can re-introduce our Loadable
type as a combination between LoadableBase
and LoadableProtocol
:
typealias Loadable<Model> = LoadableBase<Model> & LoadableProtocol
That way, concrete types (such as UserLoader
) can simply declare that they’re Loadable
-based, and the compiler will ensure that all such types implement our protocol’s load
method — while still enabling those types to use the properties declared within our base class as well:
class UserLoader: Loadable<User> {
func load(from url: URL) async throws -> User {
if let cachedUser = cache.value(forKey: url) {
return cachedUser
}
let data = try await networking.data(from: url)
...
}
}
Neat! The only real disadvantage of the above approach is that Loadable
still can’t be referenced directly, since it’s still partially a generic protocol under the hood. That might not actually be an issue, though — and if that ever becomes the case, then we could always use techniques such as type erasure to get around such problems.
Another slight caveat with our new type alias-based Loadable
setup is that such combined type aliases cannot be extended, which could become an issue if we wanted to provide a few convenience APIs that we don’t want to (or can’t) implement directly within our LoadableBase
class.
One way to address that issue, though, would be to declare everything that’s needed to implement those convenience APIs within our protocol, which would then enable us to extend that protocol by itself:
protocol LoadableProtocol {
associatedtype Model
var networking: Networking { get }
var cache: Cache<URL, Model> { get }
func load(from url: URL) async throws -> Model
}
extension LoadableProtocol {
func loadWithCaching(from url: URL) async throws -> Model {
if let cachedModel = cache.value(forKey: url) {
return cachedModel
}
let model = try await load(from: url)
cache.insert(model, forKey: url)
return model
}
}
So that’s a few different ways to use abstract types and methods in Swift. Subclassing might not currently be as popular as it once was (and remains to be within other programming languages), but I still think these sorts of techniques are great to have within our overall Swift development toolbox.
If you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.
Thanks for reading!