Make writing a habit together! This is the second day of my participation in the “Gold Digging Day New Plan · April More text challenge”. Click here for more details.

In object-oriented programming, abstract types provide a base implementation from which other types can inherit to achieve some shared, common functionality. Abstract types differ from normal types in that they are never used as if they were original (in fact, some programming languages even prevent abstract types from being instantiated directly), because their sole purpose is to be the common parent of a set of related types.

For example, suppose we wanted to unify the way we load certain types of models over the network. By providing a shared API, we would be able to use it to separate concerns, make dependency injection and emulation easy, and keep method names consistent across our projects.

One approach based on abstract types is to use a base class that will serve as a shared, unified interface for all of our model load types. Since we don’t want this class to be used directly, we want it to raise a fatalError when the implementation of the base class is called incorrectly:

class Loadable<Model> {
    func load(from url: URL) async throws -> Model {
        fatalError("load(from:) has not been implemented")}}Copy the code

Each Loadable subclass will then override the above load method to provide its load work, as shown below:

class UserLoader: Loadable<User> {
    override func load(from url: URL) async throws -> User {
        .}}Copy the code

If the above pattern seems familiar, it’s probably because it’s essentially the same protocol polymorphism we typically use in Swift. That is, when we want to define an interface, a contract, multiple types can be complied with by different implementations.

Protocols do have a significant advantage over abstract classes, however, because the compiler will force all of their requirements to be properly implemented — meaning that we no longer need to rely on runtime errors (such as FatalErrors) to prevent improper use, since we can’t instantiate the protocol.

So, if we were going for a protocol-oriented approach instead of using abstract base classes, our previous Loadable and UserLoader types might look something like this:

protocol Loadable {
    associatedtype Model
    func load(from url: URL) async throws -> Model
}

class UserLoader: Loadable {
    func load(from url: URL) async throws -> User {
        .}}Copy the code

Notice how we now use a related type to let each Loadable implementation decide the exact Model it wants to load — this gives us a nice mix of complete type safety and great flexibility.

So, protocols are certainly the preferred way to declare abstract types in Swift in general, but that doesn’t mean they’re perfect. In fact, our protocol-based implementation of Loadable currently has two major shortcomings:

  • First, since we had to add an associated type to our protocol to keep our design generic and type-safe, this meant that Loadable could no longer be directly referenced.

  • Second, because the protocol cannot contain any form of storage. If we wanted to add any storage properties that all Loadable implementations could use, we would have to redeclare those properties in each concrete implementation.

This property storage aspect is really a big advantage of our previous abstract class-based design. Therefore, if we restore Loadable to a class, we can store all objects needed by our subclasses directly in our base class — no longer need to declare these attributes repeatedly in 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)
        .}}Copy the code

So what we’re dealing with here is basically a classic tradeoff, with both approaches (abstract classes versus protocols) giving us different advantages and disadvantages. But what if we could combine these two approaches and get the best of both schemes?

If we think about it, the only real problem with methods based on abstract classes is that we have to add fatalError to every method that subclasses need to implement, so what if we just use one protocol for that particular method? Then we can still keep our networking and cache properties in the 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
    }
}
Copy the code

But the main drawback of this approach is that all concrete implementations must now subclass LoadableBase and declare that they conform to our new LoadableProtocol:

class UserLoader: LoadableBase<User>, LoadableProtocol {
    .
}
Copy the code

This may not be a huge problem, but it does make our code less elegant to say the least. The good news, however, is that we can actually solve this problem by using generic type aliases. Since Swift’s combinatorial operator & supports combining a class and a protocol, we can reintroduce our Loadable type as a combination between LoadableBase and LoadableProtocol:

typealias Loadable<Model> = LoadableBase<Model> & LoadableProtocol
Copy the code

This way, concrete types (such as UserLoader) can simply declare that they are Loadable, and the compiler will ensure that all these types implement our protocol’s load method — while still enabling those types to use the properties declared in our base class:

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)
        .}}Copy the code

Very good! The only real downside to the above approach is that Loadable still cannot be referenced directly, because it is still a partially generic protocol. But this may not actually be a problem — if this becomes the case, then we can always use techniques such as type erasure to solve these problems.

Another slight caveat to our new Loadable design based on type aliases is that such composite type aliases cannot be extended, which could be a problem if we want to provide some convenient API that we don’t want (or can’t) implement directly in the LoadableBase class.

One way around this, however, would be to declare everything needed to implement these convenience apis in our protocol, which would allow us to extend the protocol ourselves:

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
    }
}
Copy the code

These are a few different ways to use abstract types and methods in Swift. Subclassing may not be as popular as it once was (nor in other programming languages), but I still think these techniques are very good in our entire Swift development toolbox.

Thank you for reading 🚀