This article has been archived, as it was published several years ago, so some of its information might now be outdated. For more recent articles, please visit the main article feed .
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
Loadablecan 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
Loadableimplementations 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