😬

[Swift6に向けて] SE-0286: Forward-scan matching for trailing closures

2024/03/20に公開

要約

変更点

複数の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以前のコンパイラは以下の挙動をする

  1. 後方スキャンと前方スキャンのどちらかのみが文法として正しいなら、それを採用する
  2. どちらも成立するなら、互換性の観点から、後方スキャンの挙動を採用し、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

https://zenn.dev/imaizume/articles/a66eac283e4469
https://zenn.dev/ueeek/scraps/70b86e26206998
https://zenn.dev/ueeek/scraps/d1289cf0be953e

Discussion