SE286: Forward-scan matching for trailing closures
Introduction
SE-0279 "Multiple Trailing Closures" は、ソースコードの互換性を壊さずに、multiple trailing closuresに対して、便利な文法を提供した。
その時に妥協した点は、パラメータと引数のtrailing-closureをマッチングするルールとして、最後のパラメータからの後方スキャンを行うように、既存のルールを拡張したことである。
しかし、後方スキャンによるマッチングルールは、trailing-closuresを使用したAPIをうまく書くことを困難にした。 特に、複数のtrailing-closureを持つケース。
ここでは、後方スキャンを、可能な限り前方スキャンで置き換える手法を提案する。
そうすることで、シンプルで、普通の引数のマッチングと同じようになり、trailing-closureを持つAPIと デフォルト引数を持つ APIと うまくいくようになる。
この変更では、小さい破壊的変更がある。(デフォルトパラメータを持つclosure引数が複数ある場合)
その破壊的変更は、複数のSwift versionに渡って保留される。
Motivatinon
何人かが、"backword" matchingの欠点を指摘している。
backword matchingの欠点を理解するために、UIView.animate(withDuration:animations:completion:)
を例に挙げる。
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
後方マッチングの方法では、名前がついたclosure引数を後方からマッチングする。
例えば以下の例では
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
} completion: { _ in
self.view.removeFromSuperview()
}
completion:
は最後のパラメータにマッチする。
そして、名前のないクロージャー引数はanimations:
にマッチする。
この例では、backword-ruleはうまく動作する。
しかし、もし 引数に、無名でひとつだけのクロージャしか与えられなかった時に、話は困難になる、
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
このときは、backword ruleによって、無名のtrailing closure引数は、completion:
にマッチする。
そして、compileerは以下のエラーを吐く
error: missing argument for parameter 'animations' in call
animate(withDuration: 0.3) {
注釈として、
実際のUIViewのAPIは 二つの異なるmethodがある。
- animate(withDuration:animations:completion:)
- animate(withDuration:animations:)
そして、二つ目は以下のようになっている
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void
)
二つ目の関数は、一つのクロージャー引数を持つ。そのため、backward-matchingルールで、単一trailing-closureのケースとして処理される。
これらのoverloadsは それらがデフォルト引数の概念がないObjective-Cからimportされたため存在する。
新しいSwiftのAPIでは、このように書かれることはないだろう。
Proposed Solution
"Forward-scan matching rule"は、他の種類の引数と同じように、trailing closure引数をパラメータに forwrd方向にマッチングさせていくルールである。
ラベルのないtrailing_closureは ラベルのない もしくは 構造的に関数の形に似ている型を持つ 次のパラメータとマッチする、
例えば以下の例
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
// equivalent to
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
})
前方マッチなので、先頭のanimationsにマッチする。
and
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
} completion: { _ in
self.view.removeFromSuperview()
}
// equivalent to
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
}, completion: { _ in
self.view.removeFromSuperview()
})
先頭からマッチしていく
どちらの例でも、ラベルのないtrailing-closureはanimations
に対応している。
追加の trailing-closureを指定すると、広報のパラメータに対応するが、
ラベルのないtrailing-closureをより前方のparameterにシフトすること*あできない。
注意として 依然として、前方のパラメータを指定することによって、ラベルなしのtrailing-closureを後方のものにマッチさせることもできる。
UIView.animate(withDuration: 0.3, animations: self.doAnimation) { _ in
self.view.removeFromSuperview()
}
// equivalent to
UIView.animate(withDuration: 0.3, animations: self.doAnimation, completion: { _ in
self.view.removeFromSuperview()
})
前方のパラメータと指定しているから、trailing closureが最後の引数に一致する。
これは、前方一致の結果であるとおもに、互換性のためにも必要である。
Mitigating the source compatibility impact
破壊的変更を軽減するために、以下のヒューリスティックを導入。
以下の二つが満たされるなら、unlabeld-trailing-closureはマッチしない。(前方スキャンの時に、skipする)
- その引数はデフォルト値がある
- その後ろに続くclosure引数は、デフォルト値を持ってない)
func sheet(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View
sheet(isPresented: $isPresented) { Text("Hello") }
この場合は、'onDismiss'にマッチしそうだけど、
上のヒューリスティックのおかげで、contentにマッチする
複数のclosure引数 with default値の場合は、上のヒューリスティックが働かないので、挙動が変わる。
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
}
Swift compiler will attempt both: if only one succeeds, use it. If both succeed, prefer the backward-scanning rule (for source compatibility reasons) and produce a warning about the use of the backward scan
その場合は、complierが以下の動作をする
- 一つしか解釈がないならそれを採用する
- 複数の解釈が可能なら、backwordを優先する(既存の挙動を使う) & warningを出す
対応しないと行けなさそうなこと
- コードベースで、複数のclosureを引数に取っていて、それらにデフォルト引数がある 関数が存在する。
- その場合は、呼び出してるところをチェックする(back scan -> forw scanになったことで、挙動が変化しないか?)
その場合でも、warningを出してくれるみたいなので、見落としはなさそう。
warningをチェックすれば良さそう。
後、特にSwiftUIのコードで 直すところが多そう。
関数型との構造的な類似性?
関数に引数が必要ない場合(e.g. 可変長引数、デフォルト値が与えられてる場合)
呼ぶ側は、パラメータを省略することができる。その場合は、デフォルトパラメータが使用される。
パラメータと引数のマッチングは、特定のパラメータが省略されたかどうかを判断するために、引数のラベルに依存する傾向がある。
func nameMatchingExample(x: Int = 1, y: Int = 2, z: Int = 3) { }
nameMatchingExample(x: 5) // equivalent to nameMatchingExample(x: 5, y: 2, z: 3)
nameMatchingExample(y: 4) // equivalent to nameMatchingExample(x: 1, y: 4, z: 3)
nameMatchingExample(x: -1, z: -3) // equivalent to nameMatchingExample(x: -1, y: 2, z: -3)
ラベルのないtrailing-closureは 引数の名前を省略できる。それは、どのパラメータがどの引数にマッチするのかを決定するために、ラベルを使用するのの邪魔をする。
UIViewの例に戻って、withDuration
にデフォルト引数を与えてみる。
class func animate(
withDuration duration: TimeInterval = 1.0,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
以下の呼び出しを考える
UIView.animate {
self.view.alpha = 0
}
最初の引数はwithDuration
であるが、カッコの中には引数がない。
ラベルのないtrailing-closureはパラメータの名前を気にしないので、追加のルールなしでも、(上記の例では)ラベルなしtrailing-closureは withDuration
にマッチしようとするだろう。
しかし、それでは 型が合わない
forward-scan matchingは 構造的に似ていないパラメータをスキップする。
構造的に似ているパラメータは以下の二つの条件を満たす
-
inout
型でない - 関数型である。
調整したパラメータの型は、関数の中で戦前されたパラメータの型である。?
下の3種類の調整をおこない、typealiasesもみる
- もし、@autoclosureなら、そのresultの方を使用する
- 可変長なら、arrayの要素のタイプを見る
- 外側のOptinalを取り除く
そのルールに従って、withDuration
は類似する関数型ではない。
しかし、animations
は類似する型なので、それとマッチする。
破壊的変更の影響を小さくする
foward-scanningルールは、上で述べたように、破壊的変更となる。
この変更を有効にしてSwiftの互換性チェックスートを実行したところ、3つのプロジェクトで互換性が壊れていた、
ひとつ目の問題は 以下のコードで起こる。
View.sheet(isPresented:onDismiss:content:):
func sheet(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View
注釈として、onDismiss
とcontent
はともに、構造的に似ている関数型である。
このAPIは、backward-matching ruleではうまく動く。なぜなら、ラベルなしtrailing-closureは常にcontent
に解決されるからである。
そして、onDismiss
はデフォルトの値であるnil
となる。
sheet(isPresented: $isPresented) { Text("Hello") }
//backwordだと、Text()はContetにマッチする
forward-scanだと、ラベルなしtrailing-closureはonDismiss
にマッチし、content
には適切な引数が与えられない。(デフォルトを持ってないから)
よって、コンパイルエラーになる。
しかし、関数の宣言より、以下のことが明確である
- onDismissはデフォルト引数がある
- contentはラベルなしtrailing-closureにマッチしないなら引数を持たない
これらをヒューリスティックなルールとして採用することで、既存のコードのコンパイーるエラーと破壊的変更による影響を減らす、
特に、以下の二つの条件が満たされるなら、
- もしラベルなしtrailing-closureにマッチ使用なパラメータがデフォルト引数を持っている。
- そのパラメータの後に、次のtrailing-closureのラベルと一致するラベルを持つ最初のパラメータまで、引数が必要なパラメータが存在する
その時は、そのパラメータとラベルなしtrailing-closureのマッチをしないようにする。
その代わりに、それを飛ばして、次のパラメータがラベルなしtrailing-closureとマッチするかを試す。
View.sheet(isPresented:onDismiss:content:) APIに関しては、これは、ラベルなしtrailing-closureが
contentとマッチするように デフォルト引数を持つ
onDimiss`は 飛ばされることを意味する。
そのヒューリスティックによって、コンパイルが通る状態を保つことができる。
このヒューリスティックはとても効果的で、多くの互換性の問題を解決した。
このヒューリスティックの一つの実用的な効果として、多くの場合で、既存のbackword scanと同じ結果を生成する。
Swift6より小さいバージョンでの互換性への影響を軽減する
上記のヒューリスティックを採用しても、幾つかの既存のコードはコンパイルできない、また、意味が変わる可能性がある。(それは、デフォルト引数を持つクロージャータイプの引数が複数ある場合)
例えば、
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
}
注釈として、上の関数は3つのクロージャー引数を持つ。
既存のbackward scanでは、finishHandler:
とマッチする。
提案するfoward scanでは、startHandler:
とマッチする。
前節で述べたヒューリスティックは、この場合には適応されない、なぜなら、すべての引数がデフォルト引数を持つからである。
既存のコードで、trailing closureを使っていたら、解釈が変わってしまう。
// SE-0279 backward scan behavior
BlockObserver { (operation, errors) in
print("finishHandler!")
}
// label finishHandler, unlabeled moves "back" to produceHandler
BlockObserver { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// label produceHandler, unlabeled moves "back" to startHandler
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!") {
}
// add another
BlockObserver { aOperation in
print("startHandler!")
} produceHandler: { (aOperation, foundationOperation) in
print("produceHandler!")
}
// specify everything
BlockObserver { aOperation in
print("startHandler!")
} produceHandler: { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// skip the middle one!
BlockObserver { aOperation in
print("startHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
フォワードスキャンでは、直感にあったマッチをする。
しかし、後方互換性の観点から、foward scanとbackword scanを区別する必要がある。
この問題に対して、Swift6より前のバージョンでは、
foward とbackward の結果が異なるなら、コンパイラは
- どちらか一方のみが 解釈として成り立つなら、それを採用する
- 二つの解釈が考えられるなら、backword scanの方を採用し、deprecated warningを出す、
BlockObserver { (operation, errors) in
print("finishHandler!")
}
この例では、フォワードスキャンは、型の不一致で失敗する。そのため、backward scanが採用される。
これによって、互換性が保たれる。また、trailing-closureをラベルあり引数にするように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:
もし、両方の解釈が考えられ、曖昧なら、backward scanが 互換性の観点から採用される。
func trailingClosureBothDirections(
f: (Int, Int) -> Int = { $0 + $1 }, g: (Int, Int) -> Int = { $0 - $1 }
) { }
trailingClosureBothDirections { $0 * $1 }
ここでは、forward scanだと、trailing-closureはf
に。
backward scanだと、g
にマッチする・
awrningは以下のように記述するように提案する。
trailingClosureBothDirections(g: { $0 * $1 })
ForwardTrailingClosures.
flagで有効にできる。
Future derection
今のとこは、互換性のためにhuristicを導入してるけど、将来的には取り除くかも。