Swift 中的多尾随闭包(Multiple Trailing
[ 本文运行环境:Xcode12_beta_6 (Swift 5.3) ]
多尾随闭包(Multiple Trailing Closures)
尾随闭包在开发中随处可见:
// 定义:
open class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void)
// 调用
UIView.animate(withDuration: 0.3) {
// 各种动画
}
在 Swift5.3 之前,当有多个尾随闭包时写法是这样的:
// 定义
open class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil)
// 调用
UIView.animate(withDuration: 0.3, animations: {
// 各种动画
}) { (finish) in
// ???
}
可以发现,这里的多尾随闭包有个缺点:调用时最后一个闭包没有名字
。这样造成的后果是代码可读性差,对于不熟悉调用方法的开发者来说,就得去看方法的定义,运气差点接手了个毫无规范的前辈留下的自定义方法,没写注释,参数名再整个a
、b
、c
,那还得去看方法实现。
苹果很早就注意到了这个问题,但却只是在规范中建议在调用含有多个闭包参数的方法中避免使用尾随闭包
。意思是建议使用其他方式实现多尾随闭包的结构。
这很不 "Swift"。
好在 Swift5.3 中对尾随闭包进行了优化,调用多尾随闭包时最后一个闭包将会显示出参数名:
UIView.animate(withDuration: 0.3) {
// 各种动画
} completion: { (finish) in
// completion - 动画结束
}
老夫这强迫症终于... "等等,第一个闭包名呢?"
与之前相比,Swift 在 5.3 中默认不显示第一个闭包参数名,后面的闭包均显示出了参数名。对此,苹果的说法为:这样的写法无伤大雅,因为含有多尾随闭包的方法一般第一个为最主要的闭包,其他闭包都是可选的
。这意思是默认所有开发者都有良好的开发规范,如方法名,参数名等等。
对于晚期强迫症有个好消息,第一个尾随闭包的参数名是可选的,开发者可以自己加上名称,以 UIView.animte 为例:
// 单个闭包
UIView.animate(withDuration: 0.3, animations: {() in
// ...
})
// 多个闭包
UIView.animate(withDuration: 0.3, animations: { () in
// ...
}, completion: { (finish) in
// ...
})
但为了加上第一个闭包的参数,几乎将默认的调用结构重写一遍,这样的代价是否值得?是否可以使用一个中间闭包来包含所有尾随闭包?又或者自定义一个链式语法的扩展链接所有尾随闭包?
中间闭包增大了方法的复杂性,而链式调用扩展来连接所有闭包是对尾随闭包的二次封装,SE-0279 中的设计原理部分可以看到多尾随闭包底层的实现机制,仅为了一个闭包名进行二次封装是否值得?实现出的效果风格也异于系统。
扩展:向后搜索匹配与向前搜索匹配
对于多尾随闭包,5.3之前和之后,一个是匿名闭包放最末尾,一个是匿名闭包在最前。 当匿名闭包与剩余未匹配参数不是1对1的关系时 Swift 又是如何匹配匿名闭包的?
例如下面代码,以下谁将持有闭包?
func test(a: () -> Int = { 1 }, b: Any? = nil) {}
test { 2 }
// a 与 b 谁将持有传入的闭包参数?
test
函数有 a
和 b
两个参数,两者均拥有默认参数
。运行后发现 b
持有了传入的闭包。为什么会这样?
在 SE-0279 的设计原理中提到,Swift 匹配多尾随闭包最开始使用的是 “backwards scan”(向后扫描/逆向扫描)
来匹配匿名闭包。
通过例子了解下向后扫描/逆向扫描
是个什么东西:
typealias RJBlock = ()->Int
func test(a: RJBlock? = nil, b: RJBlock? = nil, c:RJBlock? = nil, d:RJBlock? = nil) {
print(a,b,c,d)
}
test(a: {1}, b: {2}, c: {3}, d: {4}) // 1
test(b: {100}) // 2
test{ 100 } // 3
test(a: nil) { 100 } // 4
test(b: nil) { 100 } // 5
test {200} c: { 100 } // 6
持有结果如下:
- 传入了所有闭包且指定了对应的参数,运行正常。
- 只传入了一个闭包并指定其为b的参数,其余参数使用默认值,运行正常。
- 传入了一个匿名闭包,从运行结果来看,其被
d
所持有 - 传入了一个闭包给予
a
与一个尾随匿名闭包,匿名闭包被d
所持有 - 传入了一个闭包给予
b
与一个尾随匿名闭包, 匿名闭包被d
所持有 - 传入了一个指定参数的尾随闭包
c
,第一个参数为匿名闭包,匿名闭包被a
所持有
测试的过程中可以看到3,4,5用例均显示了警告信息:
Backward matching of the unlabeled trailing closure is deprecated; label the argument with 'XXX' to suppress this warning
提示我们不推荐使用未标记的尾随闭包来进行“向后匹配”
。前面的举例是因为b
参数为Any
类型,所以编译器未提示警告。
多尾随闭包中匿名闭包匹配是通过对参数执行向后扫描完成的。使用标签匹配所有带标签的尾随闭包,然后从匹配到的最后一个标记参数对匿名尾随闭包执行扫描。所以,在4,5用例中,指定了部分参数名,尾随闭包从最后一个参数开始“向后扫描”
,而d
就是第一个被扫描的参数,扫描类型结果匹配,所以匿名的尾随闭包就赋值给了d
。
等等···在第六个用例,指定了参数c
,逆向搜索不应该是从d
开始么,就算是从C开始,也应该是b
啊,怎么匿名闭包赋值给了a
?还是因为匿名闭包非末尾
闭包,匹配方式有所不同?这与"向后扫描"
本身缺陷有关。
在 SE-0286中,苹果注意到了多尾随闭包“向后扫描匹配”
所衍生的问题:
向后扫描匹配规则使得编写使用尾部闭包(尤其是多个尾随闭包)的好 API 变得困难。
文档中苹果举了以下例子:
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
使用“向后搜索匹配”
传入的匿名闭包将被completion
持有,这明显不是我们想要的结果,为解决“向后搜索匹配”
的问题,,苹果提出了“向前搜索匹配”
了。上面的例子在“向前搜索匹配”
下等价于:
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
})
使用“向前搜索匹配”
时,匿名闭包将从未匹配的参数从前向后进行匹配,用例中匹配到的第一个参数为animations
。
接下来我们尝试一下参数默认值对匹配的影响:
// 删除参数b的默认值,匿名函数将优先匹配 b
func test(a: RJBlock? = nil, b: RJBlock?, c:RJBlock? = nil, d:RJBlock? = nil) {}
无论是向前搜索匹配还是向后搜索匹配都会优先匹配第一个找到的无默认值
的同类型参数,当全部都有默认值时则匹配第一个类型相匹配的参数(此处可以扩展闭包与方法类型的匹配)。那么何时使用向前匹配
,何时又使用向后匹配
?
对此,SE-0286 有这样一段描述:
“Swift会使用两种方法进行匹配,如果两者都成功了,由于兼容性考虑将首选向后扫描匹配”
”向后匹配将会在 Swift6 中移除“
总结
- Swift 在 5.3 版本中,加入了显示末尾闭包的参数名,默认隐藏了首个尾随闭包的参数名。
- 在部分多尾随闭包的场景下,不同Swift版本下将会有不同的匹配结果。
- 向后匹配将会在 Swift6 中移除,在多个匿名闭包的地方要留意使用了
向后搜索匹配
的情况。 - 与其总让 Swift 去采用
向前/向后
匹配,不如老老实实按规范把参数名写上。