Swift中的抽象类型和方法
在面向对象编程中,抽象类型提供了一个基本实现,其他类型可以从中继承,以便访问某种共享的通用功能。抽象类型与常规类型的区别在于,它们从未打算按原样使用(事实上,一些编程语言甚至阻止抽象类型直接实例化),因为它们的唯一目的是作为一组相关类型的共同父亲。
例如,假设想统一在网络上加载某些类型模型的方式,通过提供一个共享的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
的实现能够决定它想要加载的确切模型——这是一个完整类型安全和巨大灵活性之间的良好组合。
因此,一般来说,协议绝对是在Swift中声明抽象类型的首选方式,但这并不意味着它们是完美的。事实上,基于协议的Loadable
实现目前有两个主要缺点:
-
首先,由于必须在协议中添加一个关联类型,以保持设置通用和类型安全,这意味着
Loadable
不能再直接引用。 -
其次,由于协议不能包含任何形式的存储,如果想添加所有
Loadable
实现都可以使用的任何存储属性,必须在每个具体实现中重新声明这些属性。
该属性存储方面确实是基于抽象类方案的巨大优势。因此,如果要将可Loadabel
恢复到一个类,那么就能够将子类所需的所有对象存储在我们的基类本身中——无需跨多个类型复制这些属性:
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
,那么如果只为该特定方法使用协议呢?然后,仍然可以将网络和缓存属性保留在基类中——像这样:
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
是一种组合类型别名,无法扩展,如果想提供一些便利API, 同时又不想(或不能)直接在LoadableBase
类中实现 ,这可能会成为一个问题。
然而,解决这个问题的一种方法是在协议中声明实现这些便利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开发工具箱中很棒。
如果您有任何问题、评论或反馈,请随时通过推特或电子邮件告诉我。
感谢您的阅读!