将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的一部分处于测试阶段)是如何引入一些关键的新功能,旨在使上述那种错误成为过去。
不透明的参数类型
就像我们在问答文章"为什么不能直接引用某些协议,如Equatable和Hashable?"中仔细研究的那样,在使用通用协议时,经常会遇到上述编译器错误,原因是一旦协议定义了相关类型,编译器就开始对如何引用该协议进行限制。
例如,假设我们正在开发一个处理各种组的应用程序,为了能够尽可能多地重复使用我们的组处理代码,我们选择将我们的核心Group
类型定义为一个通用协议,让每个实现类型定义它包含什么样的Item
值。
protocol Group {
associatedtype Item
var items: [Item] { get }
var users: [User] { get }
}
现在,由于这个相关的Item
类型,我们不能直接引用我们的Group
协议--即使是在与组的items
无关的代码中,比如这个计算从一个给定的组的用户列表中显示什么名字的函数。
// 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
}
}
在使用低于 5.7 版本的 Swift 时,解决上述问题的一个方法是让我们的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
,我们现在可以让我们的namesOfUsers
函数接受some Group
作为其输入:
func namesOfUsers(addedTo group: some Group) -> [String] {
group.users.compactMap { user in
isUserAnonymous(user) ? nil : user.name
}
}
就像使用some
关键字来定义不透明的返回类型一样(就像我们在构建 SwiftUI 视图时做的那样),编译器会自动推断出在每个调用点传递给我们的函数的实际具体类型,而不需要我们写任何额外的代码。很好!
主要关联类型
不过有时候,我们可能想给一个给定的参数增加一些要求,而不仅仅是要求它符合某个协议。例如,假设我们现在正在开发一个应用程序,让我们的用户为他们最喜欢的文章做书签,我们已经创建了一个BookmarksController
,其中有一个方法让我们传递一个要做书签的文章数组:
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
值的Collection
,那么我们就不能简单地将其参数类型改为some Collection
,因为这不足以说明我们正在寻找一个具有特定Element
类型的输入集合。
然而,我们可以再次使用一组通用类型约束来解决这个问题:
class BookmarksController {
...
func bookmarkArticles<T: Collection>(
_ articles: T
) where T.Element == Article {
...
}
}
同样,这也没有错--但Swift 5.7再次引入了一种更轻量级的方式来表达上述的声明,其工作方式与专门化一个具体的泛型类型(如Array<Article>
)时完全相同。Element
也就是说,我们现在可以简单地告诉编译器,我们希望我们的输入Collection
,在协议名称后面的角括号中加入该类型:
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的一个新特性,即初级关联类型,以及Swift的Collection
协议将Element
声明为这样一个关联类型的事实,就像这样:
protocol Collection<Element>: Sequence {
associatedtype Element
...
}
当然,作为一个合适的Swift特性,我们也可以在我们自己的协议中使用主要关联类型,使用完全相同的语法。
实体和 "任何 "关键字
最后,让我们再进一步,把我们的ArticleSelectionController
变成一个通用类型,可以用来选择任何符合ContentItem
的值,而不仅仅是文章。由于我们现在正在寻找混合多个都符合相同协议的具体类型,some
关键字不会起到这个作用--因为,就像我们之前看到的,它的作用是让编译器为每个调用站点推断一个具体类型,而不是多个类型。
这就是新的any
关键字(在 Swift 5.6 中引入)的用处,它使我们能够将我们的ContentItem
协议称为存在式。现在,这样做确实对性能和内存有一定的影响,因为它实际上是一种自动的类型清除形式,但在我们希望能够动态地存储异质元素集合的情况下,它可以非常有用。
例如,通过简单地使用any ContentItem
作为我们的selection
字典的值类型,我们现在能够在该字典中存储任何符合该协议的值:
class ContentSelectionController {
var selection = [IndexPath: any ContentItem]()
private let bookmarksController: BookmarksController
...
func bookmarkSelection() {
bookmarksController.bookmark(selection.values)
...
}
}
然而,这一改变确实带来了一个新的编译器错误,因为我们的BookmarksController
期待接收一个包含所有具有完全相同类型的值的集合--在我们新的ContentSelectionController
实现中,情况并非如此。
值得庆幸的是,解决这个问题就像在我们的bookmark
方法声明中用any ContentItem
替换some ContentItem
一样简单:
class BookmarksController {
...
func bookmark(_ items: some Collection<any ContentItem>) {
...
}
}
我们甚至可以混合引用any
和some
,而编译器会自动帮助我们在两者之间进行转换。例如,如果我们想引入第二个单元素的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
关键字时仍然如此)绝对是首选的方式,只要可能。
总结
Swift 5.7不仅让Swift的泛型系统更加强大,还可以说让它更容易使用,因为它减少了使用泛型类型约束和其他更高级的泛型编程技术,只是为了能够引用某些协议。
泛型绝对不是每一个问题的正确工具,但是当它被证明是正确的时候,能够以更轻量级的方式使用Swift的泛型系统绝对是一个大胜利。
谢谢你的阅读!