Swift Tips 026 - Using closure types in generic constraints
每天了解一点不一样的 Swift 小知识
代码截图
小笔记
这段代码在说什么
这段代码利用扩展的方式为 Sequence 增加了 2 个 API,并通过 where
语句约束了元素的类型为 ()->Void
或者 ()->String
才可以使用其对应的 API。
通过 Swift 泛型的类型约束能力,我们让代码变得又那么优雅了一点!
关于泛型的理念
如果我们希望实现一个数组里的元素能够累加的功能,你会想到什么方法么?
当然创建一个类似 sum(_ numbers: [Int])
的 top-level 级别的函数没任何毛病,不过我们今天并不打算采用这种方式。我们要采用另外一种方式:
extension Array where Element: Numeric {
func sum() -> Element {
return reduce(0, +)
}
}
这里我们为 Array 创建了一个扩展,并指明 Element 必须遵循 Numeric 协议。如果你好奇为什么 reduce 也能实现累加的功能,不妨去看看 reduce 函数的定义,这里就不展开说明了。
也许看到这里你会想,这样设计 API 有什么用呢?从功能上,确实它和 sum(_ numbers: [Int])
这样的 top-level 级别的函数没什么区别,但从使用上,我们设计的 API 和其使用类型建立了一种关联,这种关联让我们在使用上更加便利,如果你还是不太明白我在说什么,我们来做个调用上的比对吧。
let totalPrice = sum(itemPrices) // itemPrices 和 sum 没有关联,不能通过 itemPrice 寻找到 sum 函数
let totalPrice = itemPrices.sum() // sum 是 itemPrice 类型独有的方法,使用点语法即可搜寻到 sum 函数
类型约束的变化与增强
泛型的理念是希望我们可以根据抽象概念的特征来描述类型,而不是直接使用它们的具体类型,这种描述类型的能力是需要编程语言提供相应的约束能力才能实现的。当前版本的 Swift 虽然在泛型编程上有了不少亮点,但它一开始也并不是如此好用。
关于泛型的知识点可以在 官方文档 里找到相应的教学内容,本篇 Tips 就不展开介绍了!
在 Swift 3.1 之前,泛型的类型约束只能限制其遵循某个特定的 protocol (例如上面的 Numeric) 或者基于某个特定的 subclasses,虽然也能解决大多数问题,但也有其局限性,随着 Swift 3.1 和 4 之后,Swift 泛型的类型约束能力得到了极大的增强。
举个例子说,如果我们有一个计算包含字符串的集合里的所有单词数的需求。在 Swift 3.1 之后,我们完全可以这样写:
extension Collection where Element == String {
func countWords() -> Int {
return reduce(0) { count, string in
let components = string.components(separatedBy: .whitespacesAndNewlines)
return count + components.count
}
}
}
在上面的代码中,我们为 Collection 类型增加了扩展,并限定了 Collection 中的 Element 为 String 类型,这样就限制了 API 的使用场景。
另外一个变化就是今天代码截图里的知识点,那就是闭包类型也可以作为类型约束的条件之一了,通过这种能力,我们可以让代码变得更加优雅和简洁。
extension Sequence where Element == () -> Void {
func callAll() {
for closure in self {
closure()
}
}
}
observers.callAll()
协议里的类型约束
类型约束的另外一个使用场景就是在 protocol 里定义 API。这种在 protocol 里定义 API 的方式算是开发中的一个最佳实践。但如果使用不当,也会带来一些棘手的问题。
这里我们还是举一个实际的例子,我们定义了如下的协议:
protocol ModelManager {
associatedtype Model
func models(matching query: String) -> [Model]
}
为了让整个 app 中能够通过一个相同的接口来响应请求并获取数据,我们定义了 ModelManager 并添加了一个对应的 API,
虽然上面的代码乍一看没什么毛病,但细细品来,似乎这个接口设计的还是有点缺陷,毕竟它的输入和输出都绑定了一个具体的类型上,这让它的灵活性受到了极大的限制。
为了解决这个问题,我们可以在 protocol 里使用关联类型,一个叫做 Query 类型,用来抽象查询指令的概念,另一个是 Collection 类型,用来抽象查询后的结果,并且我们在这里设定了 Collection 里的元素与 Model 类型一致。
protocol ModelManager {
associatedtype Model
associatedtype Collection: Swift.Collection where Collection.Element == Model
associatedtype Query
func models(matching query: Query) -> Collection
}
通过这种方式,我们可以自由的实现 models(matching query: Query) -> Collection
方法,而且还保证其遵循一定的规律。
例如下面的两个实现,都采用了 Enum 类型来表示 Query 占位类型。在 UserManager 里,我们使用 Array 来表示 Collection 占位类型,而再 MovieManager 里,我们使用了 Dict 来表示 Collection 占位类型。
// case 1
class UserManager: ModelManager {
typealias Model = User
enum Query {
case name(String)
case ageRange(Range<Int>)
}
func models(matching query: Query) -> [User] {
...
}
}
// case 2
class MovieManager: ModelManager {
typealias Model = (key: Genre, value: Movie)
enum Query {
case name(String)
case director(String)
}
func models(matching query: Query) -> [Genre : Movie] {
...
}
}
泛型是把双刃剑
通过泛型,我们可以定义符合自己需求的类型约束,这些约束将提供更为强大的泛型编程能力。像可哈希(hashable) 这种抽象概念,我们完全可以根据它们的概念特征来描述类型,而不是它们的具体类型,就像上面的示例一样。
尤其在 Swift 4 之后,泛型的能力变得十分强大,这让我们可以写出更优秀的代码,但过于抽象的也会增加理解上的负担,所以也不是所有的代码都要进行抽象化和泛型化。
那么你是如何把握好这个度的呢?