Closed10

SE286: Forward-scan matching for trailing closures

UeeekUeeek

Introduction

SE-0279 "Multiple Trailing Closures" は、ソースコードの互換性を壊さずに、multiple trailing closuresに対して、便利な文法を提供した。
その時に妥協した点は、パラメータと引数のtrailing-closureをマッチングするルールとして、最後のパラメータからの後方スキャンを行うように、既存のルールを拡張したことである。

しかし、後方スキャンによるマッチングルールは、trailing-closuresを使用したAPIをうまく書くことを困難にした。  特に、複数のtrailing-closureを持つケース。
ここでは、後方スキャンを、可能な限り前方スキャンで置き換える手法を提案する。
そうすることで、シンプルで、普通の引数のマッチングと同じようになり、trailing-closureを持つAPIと デフォルト引数を持つ APIと うまくいくようになる。
この変更では、小さい破壊的変更がある。(デフォルトパラメータを持つclosure引数が複数ある場合)
その破壊的変更は、複数のSwift versionに渡って保留される。

UeeekUeeek

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では、このように書かれることはないだろう。

UeeekUeeek

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が最後の引数に一致する。

これは、前方一致の結果であるとおもに、互換性のためにも必要である。

UeeekUeeek

Mitigating the source compatibility impact

破壊的変更を軽減するために、以下のヒューリスティックを導入。
以下の二つが満たされるなら、unlabeld-trailing-closureはマッチしない。(前方スキャンの時に、skipする)

  1. その引数はデフォルト値がある
  2. その後ろに続くclosure引数は、デフォルト値を持ってない)
func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil,
  content: @escaping () -> Content
) -> some View

sheet(isPresented: $isPresented) { Text("Hello") }

この場合は、'onDismiss'にマッチしそうだけど、
上のヒューリスティックのおかげで、contentにマッチする

UeeekUeeek

複数の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を出す
UeeekUeeek

対応しないと行けなさそうなこと

  • コードベースで、複数のclosureを引数に取っていて、それらにデフォルト引数がある 関数が存在する。
  • その場合は、呼び出してるところをチェックする(back scan -> forw scanになったことで、挙動が変化しないか?)
    その場合でも、warningを出してくれるみたいなので、見落としはなさそう。

warningをチェックすれば良さそう。

後、特にSwiftUIのコードで 直すところが多そう。

UeeekUeeek

関数型との構造的な類似性?

関数に引数が必要ない場合(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は類似する型なので、それとマッチする。

UeeekUeeek

破壊的変更の影響を小さくする

foward-scanningルールは、上で述べたように、破壊的変更となる。
この変更を有効にしてSwiftの互換性チェックスートを実行したところ、3つのプロジェクトで互換性が壊れていた、

ひとつ目の問題は  以下のコードで起こる。

View.sheet(isPresented:onDismiss:content:):

func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil,
  content: @escaping () -> Content
) -> some View

注釈として、onDismisscontentはともに、構造的に似ている関数型である。
この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と同じ結果を生成する。

UeeekUeeek

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で有効にできる。

UeeekUeeek

Future derection

今のとこは、互換性のためにhuristicを導入してるけど、将来的には取り除くかも。

このスクラップは2024/04/20にクローズされました