组合不透明的返回类型与主要关联类型
hudson 译 原文
自从Swift首次推出以来,在处理范型协议时(带关联类型的协议),使用类型擦除技术是非常常见的——这些协议要么在其需求范围内引用Self,要么使用关联类型。
例如,在早期版本的Swift中,当使用苹果的Combine框架进行反应式编程时,每次想从函数或计算属性返回Publisher时,必须首先通过将其包装在AnyPublisher中来擦除它的类型——像这样:
struct UserLoader {
var urlSession = URLSession.shared
var decoder = JSONDecoder()
func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
urlSession
.dataTaskPublisher(for: urlForLoadingUser(withID: id))
.map(\.data)
.decode(type: User.self, decoder: decoder)
.eraseToAnyPublisher()
}
private func urlForLoadingUser(withID id: User.ID) -> URL {
...
}
}
在这种情况下必须使用类型擦除的原因是,简单地声明我们的方法返回符合Publisher协议的东西不会给编译器提供任何关于发布者发射的输出或错误的信息。
当然,类型擦除的替代方案是声明上述方法返回实际具体类型。但是,当使用严重依赖泛型的框架(如Combine和SwiftUI)时,最终往往会遇到非常复杂的嵌套类型,手动声明会非常麻烦。
这是一个在Swift 5.1中部分解决的问题,它引入了some关键字和[不透明返回类型](www.swiftbysundell.com/articles/op… )的概念,这些概念在使用SwiftUI构建视图时经常使用——因为它们让我们利用编译器从给定视图主体body的返回来推断具体的视图的类型:
struct ArticleView: View {
var article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(article.title).font(.title)
Text(article.text)
}
.padding()
}
}
}
虽然上述使用some关键字的方式在SwiftUI中效果很好,但当我们基本上只是将给定的值传递到框架本身时(毕竟,我们从未期望自己去访问body属性),在定义自己使用的API时,它不会那么好用。
例如,将AnyPublisher返回类型替换为以前的UserLoader中的some Publisher(并删除对eraseToAnyPublisher的调用)在技术上是孤立的,但也会使每个调用点不知道发布者会产生什么类型的输出——因为我们将处理一个完全不透明的Publisher类型,无法访问协议的任何关联类型:
struct UserLoader {
...
func loadUser(withID id: User.ID) -> some Publisher {
urlSession
.dataTaskPublisher(for: urlForLoadingUser(withID: id))
.map(\.data)
.decode(type: User.self, decoder: decoder)
}
...
}
UserLoader()
.loadUser(withID: userID)
.sink(receiveCompletion: { completion in
...
}, receiveValue: { output in
// We have no way of getting a compile-time guarantee
// that the output argument here is in fact a User
// value, so we‘d have to use force-casting to turn
// that argument into the right type:
let user = output as! User
...
})
.store(in: &cancellables)
这就是为什么Swift 5.7引入主要关联类型。如果看一下Combine的Publisher协议的声明,可以看到它已更新,通过声明其关联的Output输出和Failure故障类型是主要的关联类型(将它们放在协议名称后面的角括号中)来利用此功能:
protocol Publisher<Output, Failure> {
associatedtype Output
associatedtype Failure: Error
...
}
这反过来又使我们能够以一种全新的方式使用some关键字——通过声明返回值将用于每个协议的主要关联类型的确切类型。因此,如果我们首先更新UserLoader以使用该新功能:
struct UserLoader {
...
func loadUser(withID id: User.ID) -> some Publisher<User, Error> {
urlSession
.dataTaskPublisher(for: urlForLoadingUser(withID: id))
.map(\.data)
.decode(type: User.self, decoder: decoder)
}
...
}
然后,我们将不再需要在每个调用点使用强制类型转换——同时避免任何手动类型擦除,因为现在编译器会保留其完整类型,从loadUser方法定义一直到其每个调用点 。
UserLoader()
.loadUser(withID: userID)
.sink(receiveCompletion: { completion in
...
}, receiveValue: { user in
// We’re now getting a properly typed User
// value passed into this closure.
...
})
.store(in: &cancellables)
当然,由于主要关联类型不仅仅是特定于组合的东西,而是一个适当的Swift功能,因此我们也可以在自己的范型协议上使用上述模式。
例如,假设我们已经定义了一个Loadable可加载协议,允许我们在单个统一接口后面抽象加载给定值的不同方式(这次使用Swift并发:
protocol Loadable<Value> {
associatedtype Value
func load() async throws -> Value
}
struct NetworkLoadable<Value: Decodable>: Loadable {
var url: URL
func load() async throws -> Value {
// Load the value over the network
...
}
}
struct DatabaseLoadable<Value: Identifiable>: Loadable {
var id: Value.ID
func load() async throws -> Value {
// Load the value from the app‘s local database
...
}
}
使用这种模式的一大好处是,能够非常清晰地分离关注点,因为每个调用点不必确切地知道给定值是如何加载的——我们可以简单地从给定函数中返回some Loadable ,正因为引入主要关联类型,我们可获得完整的类型安全,而无需暴露执行实际加载的底层类型:
func loadableForArticle(withID id: Article.ID) -> some Loadable<Article> {
let url = urlForLoadingArticle(withID: id)
return NetworkLoadable(url: url)
}
然而,不透明返回类型的一个重要限制是,编译器要求返回不透明类型的范围内的所有代码路径始终返回完全相同的类型。因此,如果想在两个不同的Loadable之间动态切换,如果像上面那样继续使用some关键字,会遇到编译器错误:
// Error: Function declares an opaque return type ’some Loadable<Article>‘,
// but the return statements in its body do not have matching underlying types.
func loadableForArticle(withID id: Article.ID) -> some Loadable<Article> {
if useLocalData {
return DatabaseLoadable(id: id)
}
let url = urlForLoadingArticle(withID: id)
return NetworkLoadable(url: url)
}
解决上述问题的一种方法是老式流行的类型擦除方法:引入AnyLoadable ,用它来包装两个底层的Loadable实例——但在这一点上,确实感觉像是倒退,因为必须手动编写类型擦除的包装器。
事实证明,事实上,即使在这种更动态的情况下,我们也可以继续利用编译器——所要做的就是用Swift的新any关键字替换some关键字,编译器实际上将代表我们执行所有所需的类型擦除:
func loadableForArticle(withID id: Article.ID) -> any Loadable<Article> {
if useLocalData {
return DatabaseLoadable(id: id)
}
let url = urlForLoadingArticle(withID: id)
return NetworkLoadable(url: url)
}
就像将some与主要关联类型结合使用时一样,使用any也会保留完整的类型安全性,并且仍然使我们能够使用所有可用的LoadbleAPIs,并保持对返回的实例加载Article值的完全了解。简明干脆!
然而,重要的是要指出,以上述方式使用any关键字会将我们的方法返回值变成所谓的存在值,这会带来一定的性能开销,也可能阻止使用某些范型API。例如,如果在前面示例中使用any关键字,那么将无法在返回的发布者上应用任何类型的运算符(如map或flatMap)。因此,非常确定的是,如果可能的话,最好使用some 关键字。
我希望你觉得这篇文章有用。如果您想了解更多关于some和any关键字的信息,请查看我之前关于这些关键字的文章,该文章侧重于在声明属性和参数类型时如何使用它们。如果您有任何问题、评论或反馈,请随时联系我
感谢您的阅读!