在面向对象的编程中,抽象类型提供了一个基础实现,其他类型可以从中继承,以获得某种共享的、共同的功能。抽象类型与普通类型的区别在于,它们永远不会被当作原样使用(事实上,一些编程语言甚至阻止抽象类型被直接实例化),因为它们的唯一目的是作为一组相关类型的共同父类。
例如,假设我们想统一我们通过网络加载某些类型的模型的方式,通过提供一个共享的API,我们将能够用来分离关注点,使依赖注入[1]和模拟[2]变得容易,并在我们的项目中保持方法名称的一致性。
一个基于抽象类型的方法是使用一个基类,它将作为我们所有模型加载类型的共享、统一的接口。因为我们不希望这个类被直接使用,所以我们要让它在基类的实现被错误调用时触发一个fatalError:
class Loadable<Model> {
func load(from url: URL) async throws -> Model {
fatalError("load(from:) has not been implemented")
}
}
然后,每个Loadable子类将重载上述load方法,以提供其加载功,如下所示:
如果上述模式看起来很熟悉,那可能是因为它本质上与我们在Swift 中通常使用的协议[3]的多态性完全相同。也就是说,当我们想定义一个接口,一个契约,多个类型可以通过不同的实现来遵守。
class UserLoader: Loadable<User> {
override func load(from url: URL) async throws -> User {
...
}
}
不过,协议确实比抽象类有一个显著的优势,因为编译器将强制它们的所有需求都得到正确实现——这意味着我们不再需要依赖运行时错误(例如 fatalError)来防止不当使用,因为我们无法实例化协议。
因此,如果我们采用面向协议的方案,而不是使用抽象基类,那么我们之前的 Loadable 和 UserLoader 类型可能看起来像这样:
protocol Loadable {
associatedtype Model
func load(from url: URL) async throws -> Model
}
class UserLoader: Loadable {
func load(from url: URL) async throws -> User {
...
}
}
请注意我们现在是如何使用一个相关的类型来使每个Loadable实现决定它想要加载的确切Model的——这给了我们一个在完全类型安全和巨大灵活性之间的很好的综合。
所以,一般来说,协议肯定是在Swift中声明抽象类型的首选方式,但这并不意味着它们是完美的。事实上,我们基于协议的Loadable实现目前有两个主要缺点:
- 首先,由于我们不得不为我们的协议添加一个相关的类型,以保持我们的设计是泛型的和类型安全的,这意味着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)
...
}
}
所以,我们在这里处理的基本上是一个典型的权衡方案,两种方法(抽象类与协议)都给我们带来了不同的优点和缺点。但是,如果我们能把这两种方法结合起来,得到两个方案的优点,会怎么样呢?
如果我们仔细想想,基于抽象类的方法唯一真正的问题是,我们必须在每个子类需要实现的方法中加入fatalError,那么如果我们只为这个特定的方法使用一个协议呢?那么我们仍然可以在基类中保留我们的networking 和cache 属性——像这样:
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 {
...
}
这可能不是一个巨大的问题,但可以说它确实使我们的代码不那么优雅。不过,好消息是,我们实际上可以通过使用通用类型别名来解决这个问题。由于Swift的组合运算符&支持将一个类和一个协议结合起来,我们可以将我们的Loadable类型作为LoadableBase和LoadableProtocol之间的组合重新引入:
typealias Loadable<Model> = LoadableBase<Model> & LoadableProtocol
这样,具体的类型(如UserLoader)可以简单地声明它们是基于Loadable的,而编译器将确保所有这些类型实现我们协议的load方法——同时仍然使这些类型能够使用我们基类中声明的属性:
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仍然不能被直接引用,因为它仍然是部分的泛型协议。但这实际上可能不是一个问题——如果这成为一种情况,那么我们总是可以使用诸如类型擦除的技术来解决这些问题。
对于我们新的基于类型别名的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开发工具箱中是非常好的。