Articles, podcasts and news about Swift development, by John Sundell.

Abstract types and methods in Swift

Published on 15 Mar 2022
Discover page available: Generics

In 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:

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!