Swift Digest
SE-0286 | Swift Evolution

Forward-scan matching for trailing closures

Proposal
SE-0286
Authors
Doug Gregor
Review Manager
John McCall
Status
Implemented (Swift 5.3)

01 何が問題だったのか

SE-0279 で複数トレイリングクロージャ構文が導入されましたが、そこで採用されたのは既存ルールを拡張した 後方スキャン(backward scan) による引数とパラメータの対応付けでした。つまり、パラメータリストの末尾からトレイリングクロージャを割り当て、順に前のパラメータへ遡っていきます。

しかし、この後方スキャンはトレイリングクロージャを使う API、特に複数トレイリングクロージャを使う API の設計を難しくしていました。たとえば UIKit の UIView.animate(withDuration:animations:completion:) を SE-0279 の想定通りに Swift で書き直すと、次のようなシグネチャになります。

class func animate(
    withDuration duration: TimeInterval,
    animations: @escaping () -> Void,
    completion: ((Bool) -> Void)? = nil
)

複数のトレイリングクロージャを渡すケースでは後方スキャンでも自然に動きます。completion: は末尾の completion に、ラベルなしトレイリングクロージャは animations にマッチします。

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0
} completion: { _ in
    self.view.removeFromSuperview()
}

問題は、ラベルなしトレイリングクロージャを 1 つだけ渡したときです。

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0
}

後方スキャンはラベルなしクロージャをパラメータリストの末尾(completion:)から割り当てようとするため、このクロージャは completion: にマッチし、必須の animations: が欠けているというエラーになってしまいます。

error: missing argument for parameter 'animations' in call
  animate(withDuration: 0.3) {

実際の UIKit では、Objective-C 由来のためデフォルト引数が使えず、animate(withDuration:animations:completion:)animate(withDuration:animations:) の 2 つのオーバーロードが用意されていてこの問題が回避されていました。しかし、純粋な Swift API をゼロから設計するならデフォルト引数 1 つで済ませたいところで、SE-0279 の後方スキャンはそうした API 設計を阻んでいました。

また、後方スキャンはラベルなしトレイリングクロージャの「位置」が不安定になる特徴があります。以下のように 3 つのクロージャ引数を取る API があるとします。

init(
    startHandler: ((AOperation) -> Void)? = nil,
    produceHandler: ((AOperation, Foundation.Operation) -> Void)? = nil,
    finishHandler: ((AOperation, [NSError]) -> Void)? = nil
)

後方スキャンでは、トレイリングクロージャを追加するたびにラベルなしクロージャが対応するパラメータが「後ろに移動」します。

// ラベルなしは finishHandler
BlockObserver { (operation, errors) in ... }

// ラベルなしが produceHandler に後退
BlockObserver { (aOperation, foundationOperation) in ...
} finishHandler: { (operation, errors) in ... }

// ラベルなしがさらに startHandler まで後退
BlockObserver { aOperation in ...
} produceHandler: { (aOperation, foundationOperation) in ...
} finishHandler: { (operation, errors) in ... }

ラベルなしクロージャが何にマッチするかがパラメータ数で変わるため、呼び出し側から見て挙動が直感的ではなく、API の使い方を理解しづらい状況になっていました。

02 どのように解決されるのか

トレイリングクロージャを通常の引数と同様に 前方スキャン(forward scan) でマッチさせるよう、マッチングルールを置き換えます。ラベルなしトレイリングクロージャは、パラメータを左から右に走査し、ラベルなし、あるいは型が関数型に「構造的に似ている」最初のパラメータ にマッチします。後続のラベル付きトレイリングクロージャは、そのあとに続くパラメータの中からラベル一致で割り当てられます。

これにより、先ほどの UIKit の例は次のように自然に動きます。

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0
}
// 次と等価
UIView.animate(withDuration: 0.3, animations: {
    self.view.alpha = 0
})

複数のトレイリングクロージャを指定した場合も、ラベルなしクロージャのマッチ先は animations: のまま変わらず、追加したクロージャは後ろのパラメータに割り当てられます。

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0
} completion: { _ in
    self.view.removeFromSuperview()
}

ラベルなしトレイリングクロージャを「後ろ」のパラメータにマッチさせたい場合は、それより前のパラメータを通常の括弧内引数として明示的に渡します。

UIView.animate(withDuration: 0.3, animations: self.doAnimation) { _ in
    self.view.removeFromSuperview()
}

関数型への「構造的な類似」

ラベルなしトレイリングクロージャは通常のラベル照合を無視するため、途中のデフォルト引数などをどのようにスキップするかを決める必要があります。このために、パラメータが関数型に「構造的に似ている」場合のみラベルなしトレイリングクロージャのマッチ候補とし、それ以外はスキップします。

「構造的に似ている」とは次の両方を満たすことです。

  • パラメータが inout ではない
  • パラメータの 調整後の型 が関数型である

調整後の型は、パラメータの宣言上の型を次のように変形したものです。

  • 型エイリアスはすべて展開する
  • @autoclosure なら、その関数型の結果型を取り出す
  • 可変長引数なら、暗黙の配列の要素型を取り出す
  • 外側の Optional はすべて取り除く

この結果、TimeInterval のような非関数型はスキップされ、@escaping () -> Void((Bool) -> Void)?@autoclosure () -> ((Int) -> Int) などは関数型とみなされてマッチ対象になります。

たとえば withDuration にデフォルト引数を付けて次のようにしても、

class func animate(
    withDuration duration: TimeInterval = 1.0,
    animations: @escaping () -> Void,
    completion: ((Bool) -> Void)? = nil
)

UIView.animate {
    self.view.alpha = 0
}

withDuration は関数型ではないのでスキップされ、ラベルなしトレイリングクロージャは animations: にマッチします。

デフォルト引数の関数型パラメータをスキップするヒューリスティック

前方スキャンをそのまま適用すると、SwiftUI の View.sheet(isPresented:onDismiss:content:) のように、関数型パラメータが複数並び、前側にデフォルト引数が付いている API で既存コードが壊れてしまいます。

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

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

素朴な前方スキャンではラベルなしトレイリングクロージャが onDismiss: にマッチしてしまい、必須の content: が欠ける形になってしまいます。そこで次のヒューリスティックを追加します。

  • ラベルなしトレイリングクロージャがマッチしようとしているパラメータがデフォルト引数や可変長引数を持ち、引数を必須としていない
  • そのパラメータより後ろに、次のトレイリングクロージャのラベルに一致するパラメータが現れるより前に、引数必須のパラメータが存在する

この 2 条件を満たすときは、手前のパラメータをスキップしてその先のパラメータにマッチさせます。先の sheet の例では、onDismiss: はデフォルト引数を持ち、後ろに引数必須の content: があるのでスキップされ、ラベルなしトレイリングクロージャは content: に正しくマッチします。このヒューリスティックは実際の大規模コードベースのほとんどのケースを救えることが確認されています。

Swift 6 未満でのソース互換性の緩和

ヒューリスティックを入れても、デフォルト引数の関数型パラメータが複数並ぶ API では前方スキャンと後方スキャンで結果が変わることがあります。たとえば先ほどの BlockObserver で、

BlockObserver { (operation, errors) in
    print("finishHandler!")
}

と書いた場合、前方スキャンは startHandler:(1 引数のクロージャ型)、後方スキャンは finishHandler: にマッチさせるため、型が合うのは後方スキャンだけです。

そこで Swift 6 未満のモードでは、ラベルなしトレイリングクロージャが 1 つだけ のケースに限り、前方スキャンと後方スキャンで結果が異なるときの両方を試します。

  • 片方しか型検査を通らない場合: 通ったほうを採用する
  • 両方通る場合: ソース互換のため後方スキャンを採用する

どちらの場合も、後方スキャンの結果が選ばれたときは 後方スキャンは deprecated である旨の警告 が出ます。警告にはラベルを明示して書き直す Fix-It が添えられます。

warning: backward matching of the unlabeled trailing closure is deprecated;
label the argument with 'finishHandler' to suppress this warning

この警告に従ってラベル付きで書き直しておけば、どの言語モードでも同じ挙動になります。

BlockObserver(finishHandler: { (operation, errors) in
    print("finishHandler!")
})

Swift 6 以降ではこの後方スキャンへのフォールバックは廃止され、前方スキャンのみに統一されます。Swift 6 と同じ挙動は、既存の言語モードでも upcoming feature flag ForwardTrailingClosures を有効にすることで先取りできます(この flag は Swift 5.8 から利用可能です)。

後方スキャンに依存していた API の書き換え

後方スキャンを前提に「デフォルト引数の関数型パラメータが複数並ぶ」形で設計されていた API は、末尾側のデフォルト引数を外してオーバーロードを足すことで、呼び出し側の見た目を変えずに前方スキャン前提の設計に移行できます。たとえば BlockObserver なら、finishHandler のデフォルト引数を落としたうえで、

init(
    startHandler: ((AOperation) -> Void)? = nil,
    produceHandler: ((AOperation, Foundation.Operation) -> Void)? = nil,
    finishHandler: ((AOperation, [NSError]) -> Void)?
)

init() {
    self.init(startHandler: nil, produceHandler: nil, finishHandler: nil)
}

のようにゼロ引数オーバーロードを別途用意します。こうすると前節のヒューリスティックが働き、ラベルなしトレイリングクロージャは finishHandler: に正しくマッチするようになります。

Future Directions

将来の言語バージョンでは、前述のヒューリスティックそのものも外して「純粋な前方スキャンのみ」にすることが検討されています。その場合、View.sheet(isPresented:onDismiss:content:) のような API をどう表現するかが課題になるため、あくまで可能性としてですが次のようなアイデアが挙げられています。

  • 特定のパラメータに対してトレイリングクロージャ構文を禁止する @noTrailingClosure のようなパラメータ属性
  • 最初のトレイリングクロージャがラベル付きパラメータにマッチすることを禁じ、通常の引数マッチングに揃える
  • 最初のトレイリングクロージャにも明示的にラベルを付けて、どのパラメータにマッチさせるかを呼び出し側から指定できるようにする

いずれも実現が約束されたものではありませんが、本提案で前方スキャンに移行しておくことで、こうした将来的な調整のコストが小さくなります。