在面向对象的编程中,抽象类型提供了一个基础实现,其他类型可以从中继承,以获得某种共享的、共同的功能。抽象类型与普通类型的区别在于,它们永远不会被当作原样使用(事实上,一些编程语言甚至阻止抽象类型被直接实例化),因为它们的唯一目的是作为一组相关类型的共同父类。
例如,假设我们想统一我们通过网络加载某些类型的模型的方式,通过提供一个共享的API,我们将能够用来分离关注点,促进依赖注入和嘲弄,并在我们的项目中保持方法名称的一致性。
一个基于抽象类型的方法是使用一个基类,它将作为我们所有模型加载类型的共享、统一的接口。由于我们不希望该类被直接使用,我们将使它在基类的实现被错误调用时触发一个fatalError :
class Loadable<Model> {
func load(from url: URL) async throws -> Model {
fatalError("load(from:) has not been implemented")
}
}
然后,每个Loadable 子类将覆盖上述load 方法,以提供其加载功能--像这样:
class UserLoader: Loadable<User> {
override func load(from url: URL) async throws -> User {
...
}
}
如果上述模式看起来很熟悉,那可能是因为它本质上与我们在Swift中通常使用协议的那种多态性完全相同。也就是说,当我们想定义一个接口,一个契约,让多个类型通过不同的实现来遵守。
不过,协议确实比抽象类有很大的优势,因为编译器会强制要求它们的所有要求都得到正确的实现--这意味着我们不再需要依靠运行时错误(比如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开发工具箱中是非常好的。
谢谢你的阅读!