在面向对象编程中,抽象类型提供了其他类型可以继承的基本实现,以便访问某种共享的公共功能。将抽象类型与常规类型区分开来的是,它们从未按原样使用(事实上,一些编程语言甚至阻止直接实例化抽象类型),因为它们的唯一目的是作为一组相关类型的共同父类型。

例如,假设我们希望通过提供共享API来统一我们在网络上加载某些类型模型的方式,我们将能够使用它来分离问题,提升依赖项注入和模拟,并在整个项目中保持方法名称的一致性。

一种基于类型的抽象方法是使用一个基类,作为我们所有模型加载类型的共享统一接口。由于我们不希望该类被直接使用,如果其基本实现被错误调用,我们将使其触发致命错误:

class Loadable<Model> {
    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 {
        ...
    }
}

如果上述模式看起来很熟悉,这可能是因为它本质上与我们在Swift中通常使用协议的多态性完全相同。也就是说,当我们想定义一个接口,一个合同时,多个类型可以通过不同的实现来遵守。

然而,与抽象类相比,协议确实具有重大优势,因为编译器将强制要求正确实现——这意味着我们不再依赖运行时错误(如fatalError)来防止不当使用,因为没有办法单独实例化协议。

因此,如果我们走面向协议的路线,而不是使用抽象的基类,那么我们之前的LoadableUserLoader类型可能会是什么样子:

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

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

请注意,我们现在如何使用关联类型使每个Loadable实现能够决定要加载的确切模型——这使我们能够在全类型安全性和极大的灵活性之间很好地结合。

因此,一般来说,protocols绝对是在Swift中声明抽象类型的首选方式,但这并不意味着它们是完美的。事实上,我们基于协议的Loadable实现目前有两个主要缺点:

  • 首先,由于我们必须在协议中添加关联类型,以保持我们的设置通用性和类型安全,这意味着Loadable不能再直接引用。
  • 其次,由于协议不能包含任何形式的存储,如果我们想添加所有可加载实现都可以使用的任何存储属性,我们必须在每个具体实现中重新声明这些属性。

这种存储属性确实是我们之前基于类的抽象设置的巨大优势。因此,如果我们将Loadable恢复回类,那么我们将能够将子类所需的所有对象存储在我们的基类本身中——无需在多种类型上复制这些属性:

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)
        ...
    }
}

因此,我们在这里处理的本质上是一个经典的权衡场景,这两种方法(abstract classes vs protocols)都给了我们不同的利弊。但是,如果我们能将两者结合起来,以充分利用这两个世界呢?

如果我们仔细想想,基于抽象类的方法的唯一真正问题是,我们必须在每个子类需要实现的方法中添加fatalError,那么如果我们只为该特定方法使用协议呢?然后,我们仍然可以将networkingcache属性保留在基类中——如下所示:

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
    }
}

然而,这种方法的主要缺点是,所有具体实现现在都必须同时对LoadableBase进行子类化,并声明它们符合我们新的LoadableProtocol

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

这可能不是一个大问题,但它确实可以说使我们的代码不那么优雅。然而,好消息是,我们实际上可以通过使用通用type alias来解决这个问题。由于Swift的组合运算符&支持将类与协议相结合,我们可以重新引入我们的Loadable类型,作为LoadableBase和LoadableProtocol之间的组合:

typealias Loadable<Model> = LoadableBase<Model> & LoadableProtocol

这样,具体类型(如UserLoader)可以简单地声明它们是基于Loadable,编译器将确保所有这些类型都实现我们协议的加载方法——同时仍然允许这些类型也使用在我们的基类中声明的属性:

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)
        ...
    }
}

很不错呢!上述方法的唯一真正缺点是,Loadable仍然不能直接引用,因为它仍然是底层的通用协议。然而,这实际上可能不是一个问题——如果情况确实如此,那么我们总是可以使用type erasure 等技术来解决这些问题。

关于我们基于类型别名的Loadable的另一个轻微警告是,此类组合类型别名无法扩展,如果我们想提供一些我们不想(或不能)直接在LoadableBase类中实现的便利API,这可能会成为一个问题。

然而,解决这个问题的一种方法是在我们的协议中声明实现这些方便API所需的一切,这将使我们能够自行扩展该协议:

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
    }
}

因此,这是在Swift中使用抽象类型和方法的几种不同方式。子类化目前可能不像以前那么流行(仍然在其他编程语言中),但我仍然认为这些技术在我们的整体Swift开发工具箱中非常有用。