【译】Using the ‘some’ and ‘any’ keywords to reference generic protocols in Swift 5

124 阅读7分钟

前言

本文译自在 Swift 5.7 中使用“some”和“any”关键字来引用泛型协议,仍然是介绍 someany 关键字的使用。补充了 primary associated type 的介绍使用。

正文

结合 Swift 灵活的泛型系统和面向协议编程,通常可以实现一些非常强大的功能。同时最大程度的减少代码重复。并且能够在我们的代码库中建立明确定义的抽象层。然而在 Swift 5.7 之前编写此类代码时,很容易就造成下面的编译报错:

 Protocol 'X' can only be used as a generic constraint because it has Self or associated type requirements.

让我们看看 Swift 5.7 (当前处于 Xcode 14 的测试阶段)是如何通过引入一些关键的新功能来使上面的错误信息成为历史的。

Opaque parameter types 不透明参数类型

就像我们在问答文章 Why can’t certain protocols, like Equatable and Hashable, be referenced directly? 里仔细研究的那样,在使用泛型协议时导致上面编译错误如此普遍的原因是,一旦一个协议被定义为 associated type 关联类型,编译器则会开始限制如何引用该协议。

例如,假设我们正在开发一个处理各种分组的应用程序,为了尽可能的使分组处理代码复用,我们选将核心 Group 类型定义为泛型协议。让每一个遵循协议的实现类型都包含一个 Item 值。

 protocol Group {
     associatedtype Item
 ​
     var items: [Item] { get }
     var users: [User] { get }
 }

现在,因为关联类型 Item,我们不能直接引用 Group协议——即使在与分组项目无关的代码中。例如下面的函数用来计算从给定用户分组中的名字展示:

 // Error: Protocol 'Group' can only be used as a generic constraint
 // because it has Self or associated type requirements.
 func namesOfUsers(addedTo group: Group) -> [String] {
     group.users.compactMap { user in
         isUserAnonymous(user) ? nil : user.name
     }
 }

当低于 Swift 5.7 版本时, 解决以上问题的一种方式是将我们的函数 namesOfUsers 泛型化,然后按照错误提示信息来修复,仅仅将 Group协议作为泛型约束使用,像这样:

 func namesOfUsers<T: Group>(addedTo group: T) -> [String] {
     group.users.compactMap { user in
         isUserAnonymous(user) ? nil : user.name
     }
 }

这种方式当然没有问题,但是这与使用非泛型协议或者其他形式的 Swift 类型(包括具体的泛型类型)相比,它使我们的函数定义复杂了很多。

庆幸的是,在 Swift 5.7 中通过扩展 some 关键字(在 Swift 5.1 中引入)功能,允许在函数参数位置使用,从而彻底的解决了这个问题。所以就像我们定义 SwiftUI 视图中 body属性的返回类型 some View一样。我们可以定义 namesOfUsets函数接收 some Group作为入参:

 func namesOfUsers(addedTo group: some Group) -> [String] {
     group.users.compactMap { user in
         isUserAnonymous(user) ? nil : user.name
     }
 }

就像使用 some 定义不透明返回类型(例如构建 SwiftUI 视图),编译器将自动推断出函数每次调用时传入的实际具体类型,无需我们编写任何额外的代码,非常的简洁。

primary associated types

有时,针对给定的参数可能想添加更多的约束,而不仅仅是要求它遵循某个协议。例如,假设我们正在开发一款允许用户为他们最喜欢的文章添加标签的 app,我们新建一个 BookmarksController类,声明一个 bookmarkArticles 函数允许传入文章列表:

 class BookmarksController {
     ...
 ​
     func bookmarkArticles(_ articles: [Article]) {
         ...
     }
 }

然而并不是所有的调用方都使用列表来存储他们的文章,例如 ArticleSelectionController ,使用字典来跟踪已经选中的文章和它所在 UITableView或者UICollectionView中的 IndexPath索引。所以,当传给 bookmarkArticles 文章列表时,我们首先需要先转换成列表,如下:

 class ArticleSelectionController {
     var selection = [IndexPath: Article]()
     private let bookmarksController: BookmarksController
     ...
 ​
     func bookmarkSelection() {
         bookmarksController.bookmarkArticles(Array(selection.values))
         ...
     }
 }

但是,如果我们想要更新 bookmarkArticles 方法让它支持任意包含 Article元素类型的集合类型,我们没办法简单的改变参数类型为 some Colleciton, 这不足以表明我们正在寻求具有特定元素类型作为输入集合的目的。

我们不得不再次使用泛型来解决这个问题:

 class BookmarksController {
     ...
 ​
     func bookmarkArticles<T: Collection>(
         _ articles: T
     ) where T.Element == Article {
         ...
     }
 }

同样这样写也没什么错误,但是 Swift 5.7 再次引入一些更轻量的方式来表达上述的声明,实际上这和使用具体泛型类型的执行方式是一样的(例如 Array<Article>),我们现在可以简单的告诉编译器我们输入的集合类型想包含什么样的元素类型,通过在协议后添加<>就可以实现:

 class BookmarksController {
     ...
 ​
     func bookmarkArticles(_ articles: some Collection<Article>) {
         ...
     }
 }

非常酷,我们甚至可以嵌套类型的声明——如果我们想让BookmarksController 支持所有遵循泛型协议 ContentItem 的文章添加书签的话。我们可以指定 some ContentItem 作为集合包含的 Element 元素类型。代替使用 Article这个具体类型:

 protocol ContentItem: Identifiable where ID == UUID {
     var title: String { get }
     var imageURL: URL { get }
 }
 ​
 class BookmarksController {
     ...
 ​
     func bookmark(_ items: some Collection<some ContentItem>) {
         ...
     }
 }

上文代码能正常编译归功于 Swift 新的功能 primary associated types,以及 Swift 的 Collection 协议将Element 声明为关联类型,如下所示:

 protocol Collection<Element>: Sequence {
     associatedtype Element
     ...
 }

译者补充: Swift 5.7 之前Collection 的声明为 public protocol Collection : Sequence {...}

当然作为 Swift 新的特性,我们也可以在自己定义的协议中使用 primary associated types,语法是完全相同的。

existentials and the 'any' keyword (存在类型和 any 关键字)

最后,让我们进一步将 ArticleSelectionController改写为泛型类型,让其支持选择任意符合 ContentItem的类型,不仅限于 Article。我们现在混合遵守同一个协议的多种具体类型,some 无法做到,因为some工作机制是让编译器在每个调用时都推测一个具体的类型,而不是多个。

这就是新的 any 关键字(Swift 5.6 引入)生效的地方。允许我们将ContentItem作为 existential type 引用。这样做有一定的性能和内存影响。原因是它作为自动形式的类型擦除使用。但是在我们希望能够动态存储异构元素集合的场景下,它又是非常有用的。

例如,通过简单的使用 any ContentItem 作为 selection字典的值类型,我们可以存储任意遵循协议的值:

 class ContentSelectionController {
     var selection = [IndexPath: any ContentItem]()
     private let bookmarksController: BookmarksController
     ...
 ​
     func bookmarkSelection() {
         bookmarksController.bookmark(selection.values)
         ...
     }
 }

但是,这引入了一个新的编译错误, BookmarksController期望接收元素类型相同的集合——但是ContentSelectionController的实现并非如此。

很庆幸,解决这个问题和用 any ContentItem替换 some ContentItem 时一样的简单。重新声明 bookmark函数如下:

 class BookmarksController {
     ...
 ​
     func bookmark(_ items: some Collection<any ContentItem>) {
         ...
     }
 }

我们甚至可以混合 anysome的引用。编译器会自动帮我们在两者之间转换。例如,我们重载单元素类型的 bookmark函数,然后再第一个函数中调用。我们可以这样做(即使第一个函数的 items 集合元素类型为 any ContentItem,而第二个函数接收some ContentItem):

 class BookmarksController {
     ...
 ​
     func bookmark(_ items: some Collection<any ContentItem>) {
         for item in items {
             bookmark(item)
         }
     }
 ​
     func bookmark(_ item: some ContentItem) {
         ...
     }
 }

再次强调,使用 any确实会在底层引入类型擦除,编译器会自动完成这些工作——所以使用静态类型(使用 some 也是这种场景)仍然是首选方式。

译者补充:这里的意思就是说,优先选择泛型或者 some 来实现,由于 some最终也会推导出静态具体类型,所以和泛型归为一类,统称为了静态类型。

结论

Swift 5.7 不仅使泛型系统更强大,而且更容易使用了。因为减少了泛型类型约束和其他更高级的泛型编程技术来使用某些协议。

泛型不是解决每个问题的绝对工具,但事实证明,能够以更轻量的方式使用 Swift 泛型系统绝对是编程史的一大胜利。

我希望这篇文章对你是有用的。如果你有任何问题、意见或者反馈,请随时通过 Twitter电子邮件联系。有关泛型特性的更多信息,我推荐观看今年优秀的 WWDC 视频 “What's new in Swift”“Embrace Swift generics”

感谢阅读。