原文:Building flexible components with generics and protocols – Donny Wals
最近我想实现一个泛型数据源层。这个数据源将能够从本地缓存中返回几乎任何东西,如果本地缓存不包含请求的对象,它将从远程服务器获取,然后在本地缓存结果,再返回给我。为了实现这个目标,我想我应该写一个泛型本地缓存、一个泛型远程缓存和一个结合这两个缓存的包装器,让我能够透明地检索对象,而不必担心对象来自哪里。
没过多久,我就看到了第一个编译器警告,并且想起了泛型是很难按照你的意愿来弯曲的。特别是当你的泛型对象使用其他泛型对象来透明地做泛型事情时。
在这篇博文中,我将向你展示我处理这类复杂问题的方法,以及我如何使用代码来设计和实现一个完全按照我的意愿工作的流畅的 API。在这篇博文中,我们将讨论以下主题:
- 设计一个 API 而不迷失在细节中;
- 了解 Swift 中如何使用简单的泛型,以及你如何在代码中使用它们;
- 了解你可以拥有具有泛型要求的协议,也被称为关联类型;
- 将泛型与带有关联类型的协议结合起来;
你准备好进入泛型的脑洞大开的世界了吗?很好,我们开始吧。
设计一个 API 而不迷失在细节中
我向你承诺过泛型。相反,我将首先向你展示如何设计一个使用泛型的 API。这是为了建立一个目标,我们可以在这篇博文中努力实现。泛型已经够复杂了,我不想让你困惑到不知道我们又在构建什么。
在这篇文章的介绍中,我提到我想建立一个通用的数据存储,在本地缓存数据,并使用一个远程数据存储作为备份,以防所需的数据不存在于本地。
开始构建这样的东西的一个好方法是写下一些代码,证明你想如何使用你正在构建的 API 或组件。下面是我为缓存层写的东西:
let localDataStore = UserDataStore()
let remoteDataStore = UserApi()
let dataStore = CacheBackedDataStore(localDataStore, remoteDataStore)
dataStore.fetch(userID) { result in
// handle result
}
这是很直接的,对吗?你可以看到,我想创建两个缓存和一个缓存包装器。缓存包装器是用来检索信息的,它使用一个回调来通知调用者结果。简单明了,正是我喜欢的方式。请记住,无论我们设计的是什么,都必须与用户对象以外的东西一起工作。我们还希望能够在这个结构中存储其他信息,例如,属于用户的文档。
让我们再深入一点,为 CacheBackedDataStore
写一个伪实现:
class CacheBackedDataStore {
let localStore: LocalStore
let remoteStore: RemoteStore
func fetch(_ identifier: IdentifierType, completion: @escaping Result<T, Error>) {
// 首先从本地查找
localStore.fetchObject(identifier) { result in
if let result = try? result.get() {
completion(.success(result))
} else {
// 如果本地没有,从远程获取
remoteStore.fetchObject(identifier) { result in
if let result = try? result.get() {
completion(.success(result))
} else {
// extract error and forward to the completion handler
}
}
}
}
}
}
你可能会注意到这里的结果上的类型 T
。这个 T
占位符是我们的泛型冒险开始的地方。这是一个兔子洞的开始,在这里我们要把所有东西都变成可能是任何东西的对象。在这一点上,我们有足够的 "设计" 来开始设置我们的一些构建模块。要做到这一点,我们要看一下 Swift 中的泛型。
在你的代码中添加简单的泛型
在上一节向你展示的伪代码设计中,我使用了 T
这个类型。每当我们在 Swift 中用泛型写代码时,我们通常使用 T
来标记一个类型为泛型。只要满足为其指定的约束条件,泛型类型几乎可以是任何东西。一个可以成为任何你想要的东西的泛型的例子是 Array
。让我们看看在 Swift 中定义空数组的两种相同的方法:
let words1 = [String]()
let words2 = Array<String>()
注意,第二种方式使用了类型名 Array
,后面是 <String>
。这告诉编译器,我们正在定义一个元素类型为 String
的数组。现在让我们试着想象一下 Array
的类型定义会是什么样子:
struct Array<T> {
// implementation code
}
这段代码声明了一个 Array
类型的结构,其中包含一些类型 T
,这些类型是泛型的;它可以是我们想要的任何东西,只要我们在创建实例或将其作为一个类型时指定它。在前面的例子中,let words2 = Array<String>
我们将 T
定义为 String
类型。在我们继续之前,让我们再看一个基本的例子:
struct SpecializedPrinter<T> {
func print(_ object: T) {
print(object)
}
}
这段代码声明了一个在 T
上泛型的 SpecializedPrinter
,它有一个叫做 print
的函数,它接收一个 T
类型的对象并将其打印到控制台。如果你把上面的内容粘贴到 Playground 中,你可以使用这个 SpecializedPrinter
结构,如下所示:
let printer = SpecializedPrinter<String>()
printer.print("Hello!") // this is fine
printer.print(10) // this is not okay since T for this printer is String, not Int
现在你对泛型有了一些了解,我想我们可以为 CacheBackedDataSource
对象编写第一段代码了:
struct CacheBackedDataSource<T> {
func find(_ objectID: String, completion: @escaping (Result<T?, Error>) -> Void) {
}
}
我们在这里没有做什么,但这是你在 Swift 中掌握泛型的一个重要里程碑。你写了一个数据源,声称可以缓存任何类型(T
),并将根据一个字符串标识符对一个元素进行异步查找。find (_:completion:)
函数将用一个包含 T
的可选实例的 Result
对象或一个 Error
对象调用完成块。
在这篇文章前面的伪代码中,有两个属性:
let localStore: LocalStore
let remoteStore: RemoteStore
由于缓存层应该尽可能的通用和灵活,让我们把 LocalStore
和 RemoteStore
定义为协议。这将给我们带来巨大的灵活性,允许任何对象充当本地或远程存储,只要它们实现了相应的功能。
protocol LocalStore {
}
protocol RemoteStore {
}
而在这些协议中,我们将定义方法来获取我们需要的对象,而在本地存储中,我们将定义一个方法来持久化一个对象:
protocol LocalStore {
func find(_ objectID: String, completion: @escaping (Result<T, Error>) -> Void)
func persist(_ object: T)
}
protocol RemoteStore {
func find(_ objectID: String, completion: @escaping (Result<T, Error>) -> Void)
}
不幸的是,这并不奏效。我们的协议不知道 T
是什么,因为它们不是通用的。那么,我们如何使这些协议通用呢?这就是下一节的主题。
给协议添加泛型
虽然我们可以通过在 <>
之间的类型声明中加入泛型参数来定义一个结构,就像我们为 struct CacheBackedDataSource<T>
所做的那样,但这对于协议来说是不允许的。如果你想有一个包含泛型参数的协议,你需要将这个泛型类型声明为协议上的一个关联类型。associatedtype
不一定要在实现协议的对象上作为泛型实现。我将很快演示这一点。现在,让我们修复本地和远程存储协议,这样你就可以看到关联类型的作用了:
protocol LocalStore {
associatedtype StoredObject
func find(_ objectID: String, completion: @escaping (Result<StoredObject, Error>) -> Void)
func persist(_ object: StoredObject)
}
protocol RemoteStore {
associatedtype TargetObject
func find(_ objectID: String, completion: @escaping (Result<TargetObject, Error>) -> Void)
}
请注意我们在这里没有使用像 T
这样的短名称。这是因为关联类型不一定是泛型的,而且我们希望这个类型的目的比我们通常在结构上定义泛型参数时传达得更好一些。让我们创建两个符合 LocalStore
和 RemoteStore
的结构,看看关联类型在符合我们协议的对象中是如何工作的:
struct ArrayBackedUserStore: LocalStore {
func find(_ objectID: String, completion: @escaping (Result<User, Error>) -> Void) {
}
func persist(_ object: User) {
}
}
struct RemoteUserStore: RemoteStore {
func find(_ objectID: String, completion: @escaping (Result<User, Error>) -> Void) {
}
}
在这个例子中,实现协议的关联类型所需要的就是在协议使用其关联类型的所有地方使用相同的类型。另一个更繁琐的方法是在一个符合要求的对象中定义一个 typealias
,并在我们目前使用 User
对象的地方使用协议的关联类型。这方面的一个例子是这样的:
struct RemoteUserStore: RemoteStore {
typealias TargetObject = User
func find(_ objectID: String, completion: @escaping (Result<TargetObject, Error>) -> Void) {
}
}
我更喜欢前一种方式,我们用 User
来代替 TargetObject
,在我看来,这更容易阅读。
由于我们在 RemoteUserStore
中处理的是来自远程服务器的数据,因此限制 TargetObject
的值,只允许用 Decodable
类型来代替 TargetObject
,这将是相当方便的。我们可以这样做:
protocol RemoteStore {
associatedtype TargetObject: Decodable
func find(_ objectID: String, completion: @escaping (Result<TargetObject, Error>) -> Void)
}
如果我们试图用 User
来代替 TargetObject
,而 User
又不是 Decodable
的,编译器就会显示以下错误:
candidate would match and infer 'TargetObject' = 'User' if 'User' conformed to 'Decodable'
func find(_ objectID: String, completion: @escaping (Result<User, Error>) -> Void) {
现在我们已经为 CacheBackedDataSource
以及本地和远程存储协议准备了以下代码:
struct CacheBackedDataSource<T> {
func find(_ objectID: String, completion: @escaping (Result<T, Error>) -> Void) {
}
}
protocol LocalStore {
associatedtype StoredObject
func find(_ objectID: String, completion: @escaping (Result<StoredObject, Error>) -> Void)
func persist(_ object: StoredObject)
}
protocol RemoteStore {
associatedtype TargetObject: Decodable
func find(_ objectID: String, completion: @escaping (Result<TargetObject, Error>) -> Void)
}
让我们为 CacheBackedDataStore
的本地和远程存储添加一些属性:
struct CacheBackedDataSource<T> {
let localStore: LocalStore
let remoteStore: RemoteStore
func find(_ objectID: String, completion: @escaping (Result<T, Error>) -> Void) {
}
}
不幸的是,这不会被编译。Swift 编译器会抛出以下错误:
error: protocol 'LocalStore' can only be used as a generic constraint because it has Self or associated type requirements
let localStore: LocalStore
^
error: protocol 'RemoteStore' can only be used as a generic constraint because it has Self or associated type requirements
let remoteStore: RemoteStore
让我们在下一节课看看如何解决这个错误。
使用具有类型要求的协议作为泛型约束条件
在我们深入研究我们目前被卡住的编译器错误之前,我想快速回顾一下我们到目前为止所准备的东西。因为尽管代码不能编译,但它已经相当令人印象深刻了。我们有一个泛型数据源,可以检索任何对象 T
。
我们也有一个本地存储的协议,可以缓存任何我们想要的类型,协议的实现者可以决定具体缓存什么类型。重要的是,实现该协议的对象有一个 find
方法,根据一个标识符进行查找,并调用一个带有 Result
对象的回调。它也有一个 persist
方法,预计会存储与本地存储可以获取的类型对象相同的对象。
最后,我们有一个远程存储的协议,可以获取任何类型的对象,只要它符合 Decodable
的规定。类似于本地存储的工作方式,RemoteStore
的实现者可以决定 TargetObject
的类型是什么。
这是非常强大的东西,如果上面的内容让你感到有点困惑,那也没关系。尽管代码看起来相当简单,但它并不简单或直截了当。试着跟着我们到目前为止所写的代码走,重新阅读你所学到的东西,也许要休息一会儿,让它沉淀下来。我相信它最终会的。
为了在 CacheBackedDataSource
上使用本地和远程存储协议的类型,我们需要为 CacheBackedDataSource
添加泛型参数,并对这些参数进行约束,使其必须实现我们的协议。用以下内容替换你目前对 CacheBackedDataSource
的实现:
struct CacheBackedDataSource<Local: LocalStore, Remote: RemoteStore> {
private let localStore: Local
private let remoteStore: Remote
func find(_ objectID: String, completion: @escaping (Result<Local.StoredObject, Error>) -> Void) {
}
}
CacheBackedDataSource
的声明现在有两个泛型参数,Local
和 Remote
。每一个都必须符合其各自的协议。这意味着 localStore
和 remoteStore
不应该是 LocalStore
和 RemoteStore
的类型。相反,它们应该是 Local
和 Remote
类型的。注意,Result<T, Error>
已经被替换为 Result<Local.StoredObject, Error>)
。find
方法现在使用 LocalStore
存储的任何类型的对象作为其结果的类型。这真的很强大,因为底层存储现在决定了由数据源对象返回的对象的类型。
但仍有一个问题。没有什么能阻止我们在本地存储与远程存储完全不兼容的东西。幸运的是,我们可以对我们的结构的通用参数施加约束。更新 CacheBackedDataSource
的声明如下:
struct CacheBackedDataSource<Local: LocalStore, Remote: RemoteStore> **where Local.StoredObject == Remote.TargetObject**
现在我们只能创建那些在本地和远程存储中使用相同类型对象的 CacheBackedDataSource
对象。在我告诉你如何创建 CacheBackedDataSource
的实例之前,我们先来实现 find
方法:
func find(_ objectID: String, completion: @escaping (Result<Local.StoredObject, Error>) -> Void) {
localStore.find(objectID) { result in
do {
let object = try result.get()
completion(.success(object))
} catch {
self.remoteStore.find(objectID) { result in
do {
let object = try result.get()
self.localStore.persist(object)
completion(.success(object))
} catch {
completion(.failure(error))
}
}
}
}
}
find
方法的工作原理是在本地存储中调用 find
。如果请求的对象被找到,那么回调被调用,结果被传回给调用者。如果发生错误,例如,因为没有找到对象,就会使用远程存储。如果远程存储找到了请求的对象,它就会被持久化在本地存储中,并将结果传回给调用者。如果没有找到对象或者在远程存储中发生了错误,我们就用收到的错误调用完成闭包。
请注意,这种设置是非常灵活的。CacheBackedDataSource
的实现并不关心它在缓存什么。它只知道如何使用一个本地存储,并回退到一个远程存储。很棒,对吗?让我们通过创建一个 CacheBackedDataSource
的实例来总结一下吧:
let localUserStore = ArrayBackedUserStore()
let remoteUserStore = RemoteUserStore()
let cache = CacheBackedDataSource(localStore: localUserStore, remoteStore: remoteUserStore)
cache.find("someObjectId") { (result: Result<User, Error>) in
}
你所需要做的就是创建你的存储实例,并将它们提供给缓存。然后你可以在你的缓存上调用 find
,编译器能够理解传递给 find
完成闭包的结果对象是一个 Result<User, Error>
。
看看我在这篇文章的开头给你展示的伪代码吧。它和我们最终实现的非常接近,而且和我们想象中的一样强大。如果你一直跟着我,试着为其他类型的对象创建一些 CacheBackedDataSource
对象。这应该是相当直接的。
总结
在这篇博文中你已经学到了很多东西。如果你必须再读一两遍才能完全理解所有这些泛型和类型约束,我也不会感到惊讶。而我们甚至还没有涵盖所有的内容!泛型是 Swift 语言中一个令人难以置信的强大而复杂的功能,但我希望我能够帮助你对它们有一些了解。总的来说,你现在知道在一个对象的声明中加入 <T>
会增加一个泛型参数,这意味着无论何时你在该对象中使用 T
,它都是该对象的用户决定的东西。
你还了解到你可以在协议中添加 associatedtype
以使其支持泛型类型。最重要的是,你学会了如何使用一个包含关联类型的协议作为对象的泛型参数的约束条件,以获得最大的灵活性。如果你在读完这些之后脑子有点疼,那么还是那句话,不要担心。这东西很难,很混乱,很奇怪,很复杂。如果你有任何问题、评论或需要找人谈谈,因为你现在感到迷茫,不要犹豫,请在 Twitter 上联系我们!
附录:示例代码
protocol LocalStore {
associatedtype StoredObject
func find(_ objectID: String, completion: @escaping (Result<StoredObject, Error>) -> Void)
func persist(_ object: StoredObject)
}
protocol RemoteStore {
associatedtype TargetObject: Decodable
func find(_ objectID: String, completion: @escaping (Result<TargetObject, Error>) -> Void)
}
struct CacheBackedDataSource<Local: LocalStore, Remote: RemoteStore> where Local.StoredObject == Remote.TargetObject {
private let localStore: Local
private let remoteStore: Remote
func find(_ objectID: String, completion: @escaping (Result<Local.StoredObject, Error>) -> Void) {
// 首先从本地查找
localStore.find(objectID) { result in
do {
let object = try result.get()
completion(.success(object))
} catch {
// 否则从远程获取
self.remoteStore.find(objectID) { result in
do {
let object = try result.get()
// 缓存到本地
self.localStore.persist(object)
completion(.success(object))
} catch {
completion(.failure(error))
}
}
}
}
}
}
/************************ 使用示例 ************************/
struct User: Decodable {
let name: String
}
struct ArrayBackedUserStore: LocalStore {
func find(_ objectID: String, completion: @escaping (Result<User, Error>) -> Void) {
}
func persist(_ object: User) {
}
}
struct RemoteUserStore: RemoteStore {
func find(_ objectID: String, completion: @escaping (Result<User, Error>) -> Void) {
}
}
let localUserStore = ArrayBackedUserStore()
let remoteUserStore = RemoteUserStore()
let cache = CacheBackedDataSource(localStore: localUserStore, remoteStore: remoteUserStore)
cache.find("someObjectId") { result: Result<User, Error> in
// handle cached data...
}