[Swift6に向けて] SE-0286: Forward-scan matching for trailing closures
要約
変更点
複数のTrailingClosureが引数として与えられたときに、それらをパラメータに対応付ける挙動が変わる。
- ASIS: 後方スキャン(最後のパラメータから決めていく)
- TOBE: 前方スキャン(最初のパラメータから決めていく)
//"デフォルト引数をもつClosureパラメータが複数ある関数"の例
func foo(a: (()->String)? = nil, b: (()->String)? = nil, c: (()->String)? = nil) {}
foo { "hoge" }
// ASIS: 後方スキャン(最後のパラメータから決めていく)
TrailingClosureは、`c`にマッチする。
// TOBE: 前方スキャン(最初のパラメータから決めていく)
TrailingClosureは、`a`にマッチする。
いつから
Swift6
対応の難易度
中
影響がありそうなこと
Closureパラメータが複数ある関数
対応すべきこと
"デフォルト引数をもつClosureパラメータが複数ある関数"は挙動が変わる恐れがあるため、Swift6に上げる前に、Warningをチェックしたほうが良い。
Proposalの内容
背景
SE-0279(Multiple Trailing Closures)では、それまでのTrailingClosureの文法を壊さずに、複数のClosure引数がある関数を使うときに便利な文法が提供された。
以下のような、SwiftUIでよく見かける文法である。
Section { // <- Trailing Closure 1つめ
// content
} header: { // <- Trailing Closure 2つめ
...
} footer: { // <- Trailing Closure 3つめ
...
}
既存のコードとの互換性を保つために、関数のパラメータと、与えられるTrailingClosure引数のマッチング(どの引数がどのパラメータに対応するかを判断する)のときに、後方スキャン(最後のパラメータに対するマッチングから先に決めていく)を採用した。
モチベーション
しかし、後方スキャンによるマッチングの欠点がいくつか指摘されている。
- 通常の引数のマッチングは、前方スキャンで行われ、それと挙動が違う
- 良いAPIのデザインの妨げになる
特に、2つめの点については、以下のような関数を考えたときに
func animate(withDuration:animations:completion:)
以下のようなコードを書くと
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
後方スキャンの挙動から、与えたTrailingClosureはcompletion
の方にマッチングし、以下のコンパイルエラーを目にすることになる。
error: missing argument for parameter 'animations' in call
animate(withDuration: 0.3) {
新しい提案
TrailingClosureのマッチングを、前方スキャンに変更するプロポーザルである。
他の種類の引数のときのマッチングと挙動が同じになるため理解しやすくなる。
前方スキャンになると、さきほどの例の挙動は以下のようになる
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
// 上と下は同じ意味のコード
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
})
//前方マッチなので、先頭のanimationsにマッチする。
既存のコードとの互換性
上記の変更は、いくつかの破壊的変更を伴う。
それらを軽減するために、2つのヒューリスティックが導入される。
ヒューリスティック1
互換性の問題
以下の関数について、考える。
func sheet(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View
//onDimissはデフォルト引数を持っている。
//contentはデフォルト引数を持っていない。
この関数を以下のように呼び出すと、
sheet(isPresented: $isPresented) { Text("Hello") }
これまでの後方スキャンの挙動だと、TrailingClosureはcontent
にマッチしていた。
しかし、前方スキャンの挙動では、TrailingClosureはonDismiss
にマッチしてしまう。そのときに、content
は与えられてないので、コンパイルエラーになる。
今回の挙動の変更は、上記のような関数を呼び出している箇所において、コンパイルエラーを引き起こしてしまう。
解決策
ヒューリスティクを導入して、上記の問題を解決する。
もし、前方からマッチングを行っていくときに、以下の2つの条件が満たされるなら、マッチをしないでそのパラメータを飛ばす。
- ラベルのないTrailingClosureにマッチしそうなパラメータが、デフォルト引数を持っている。
- そのパラメータの後方に、引数が必要なパラメータが存在する。
上の例では、前方のパラメータから引数とのマッチングを決めていく時に、onDismiss
へのマッチングがスキップされて、content
にマッチする。
-
onDimiss
がデフォルト引数を持っている。 -
onDimiss
の後に、引数が必要なパラメータ(content
)が存在する。
このヒューリスティックのおかげで、多くの場合で、コンパイルエラーが発生せず、既存の後方スキャンの挙動と同じ結果が得られる。
ヒューリスティック2
互換性の問題
デフォルト引数をもつClosureパラメータが複数ある場合、コンパイルエラーにはならないが、挙動が変わってしまう例が存在する。
class BlockObserver {
init(
startHandler: ((AOperation) -> Void)? = nil,
produceHandler: ((AOperation, Foundation.Operation) -> Void)? = nil,
finishHandler: ((AOperation, [NSError]) -> Void)? = nil
) {
self.startHandler = startHandler
self.produceHandler = produceHandler
self.finishHandler = finishHandler
}
}
既存の後方スキャンと、提案された前方スキャンで挙動を比べてみる。
- 1つだけTrailingClosureを与えたとき
- (後方スキャン)最後のパラメータにマッチする。
- (前方スキャン)最初のパラメータにマッチする。
// SE-0279 backward scan behavior
BlockObserver { (operation, errors) in
print("finishHandler!")
}
// Proposed forward scan
BlockObserver { aOperation in
print("startHandler!") {
}
- 2つTrailingClosureを与えたとき
- (後方スキャン)最後と中央のパラメータを与えることができる。
- (前方スキャン)最初と中央のパラメータを与えることができる。
// SE-0279 backward scan behavior
BlockObserver { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// Proposed forward scan
BlockObserver { aOperation in
print("startHandler!")
} produceHandler: { (aOperation, foundationOperation) in
print("produceHandler!")
}
- 3つTrailingClosureを与えたとき
- これは、どちらも挙動が同じ
// SE-0279 backward scan behavior
BlockObserver { aOperation in
print("startHandler!")
} produceHandler: { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// Proposed forward scan
BlockObserver { aOperation in
print("startHandler!")
} produceHandler: { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
例1のように、コンパイルエラーにはならないが挙動が変わってしまうことがあり、こちらの方がやっかいかもしれない。
解決策
Swift6以前のコンパイラは以下の挙動をする
- 後方スキャンと前方スキャンのどちらかのみが文法として正しいなら、それを採用する
- どちらも成立するなら、互換性の観点から、後方スキャンの挙動を採用し、Deprecated warningを表示する。
たとえば、
BlockObserver { (operation, errors) in
print("finishHandler!")
}
このときTrailingClosureは、
- 後方スキャンでは、
finishHandler
にマッチ - 前方スキャンでは、
startHandler
にマッチ
2通りの解釈ができるため、後方スキャンの挙動を採用し、finishHandler
にマッチする。
同時に、以下のwarningを表示する。
warning: backward matching of the unlabeled trailing closure is deprecated; label the argument with 'finishHandler' to suppress this warning
BlockObserver { (operation, errors) in
^
(finishHandler:
warningでは、対応するパラメータを明示するように提案している。
(注)
上記2つのヒューリスティックによって、既存のコードとの互換性の問題は軽減されたが、Future Direction
のセクションでは、これらを将来的には取り除く可能性があると、書かれている。
影響がありそうなこと
- "デフォルト引数をもつClosureパラメータが複数ある関数"は挙動が変わる恐れがある。
- Swift6に上げる前に、Warningをチェックし曖昧性をなくしておく。
//"デフォルト引数をもつClosureパラメータが複数ある関数"の例
func foo(a: (()->String)? = nil, b: (()->String)? = nil, c: (()->String)? = nil) {}
foo { return "hoge" }
// ASIS: 後方スキャン(最後のパラメータから決めていく)
TrailingClosureは、`c`にマッチする。
// TOBE: 前方スキャン(最初のパラメータから決めていく)
TrailingClosureは、`a`にマッチする。
挙動の変化の例
func foo(a: (()->String)? = nil, b: (()->String)? = nil, c: (()->String)? = nil) { }
// ASIS 後方マッチング
foo(a: { "a" }, b: { "b" }, c: { "c" })
foo(a: { "a" }, b: { "b" }) { "c" }
foo(a: { "a" }) { "c" } //warningが出る
foo { "c" } //warningが出る
// TOBE 前方マッチング
foo(a: { "a" }, b: { "b" }, c: { "c" })
foo(a: { "a" }, b: { "b" }) { "c" }
foo(a: { "a" }) { "b" }
foo { "a" }
まとめ
複数のTrailingClosureが引数として与えられたときに、それらをパラメータに対応付けるときの挙動が変わる。
- ASIS: 後方スキャン(最後のパラメータから決めていく)
- TOBE: 前方スキャン(最初のパラメータから決めていく)
"デフォルト引数をもつClosureパラメータが複数ある関数"は挙動が変わる恐れがあるため、Swift6に上げる前に、Warningをチェックしたほうが良い。
Refs
Discussion