Swift Digest
SE-0279 | Swift Evolution

Multiple Trailing Closures

Proposal
SE-0279
Authors
Kyle Macomber, Pavel Yaskevich, Doug Gregor, John McCall
Review Manager
Ben Cohen
Status
Implemented (Swift 5.3)

01 何が問題だったのか

Swift には初期から trailing closure syntax(末尾クロージャ構文)がありました。関数の最後の引数がクロージャである場合、その引数を括弧の外に「押し出す」ことができる糖衣構文です。たとえば UIView.animate(withDuration:animations:) は次のように書けます。

// trailing closure を使わない書き方
UIView.animate(withDuration: 0.3, animations: {
  self.view.alpha = 0
})
// trailing closure を使った書き方
UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
}

trailing closure は呼び出し側を簡潔かつネストの浅いかたちにでき、Swift の API と相性がよいことから広く使われてきました。しかし 最後のクロージャ 1 つにしか適用できない という制約のため、クロージャを複数受け取る関数で使うと、かえって読みにくくなるケースがありました。

たとえば UIView.animate(withDuration:animations:completion:) のように完了ハンドラも受け取る版では、次のように 1 つ目のクロージャはラベル付きで括弧の中に残り、2 つ目だけが trailing closure として外に出る非対称な形になります。

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

この書き方は trailing closure の役割が読み取りにくく、最初のクロージャはネストしたまま残ります。結果として、Google の Swift Style Guide などではクロージャを複数渡す関数呼び出しで trailing closure を使うことが禁止される流れになっていました。

さらに、あとから完了ハンドラを追加したくなった場合、もとの呼び出しを trailing closure 形式から通常の引数形式へ書き直す必要があり、進行性のある disclosure(progressive disclosure)を意識した API 設計にとって摩擦が大きい状態でした。

// クロージャ引数が1つ → trailing closure
UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
}
// クロージャ引数が複数 → trailing closure を使えない(従来の書き方)
UIView.animate(withDuration: 0.3, animations: {
  self.view.alpha = 0
}, completion: { _ in
  self.view.removeFromSuperview()
})

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

trailing closure syntax を拡張し、最初のラベルなしクロージャに続けて、ラベル付きのクロージャを任意個数 並べられるようにします。

// 単一の trailing closure
UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
}
// 複数の trailing closure
UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
} completion: { _ in
  self.view.removeFromSuperview()
}

構文ルールは次のとおりです。

  • 最初の trailing closure は、従来どおり 引数ラベルを省略 します。
  • 2 つ目以降の trailing closure は、引数ラベルの記述が必須 です。

これにより、あとから完了ハンドラを追加するときも、先頭のクロージャはそのまま残して末尾に completion: などを足すだけで済みます。

API デザインガイドラインの更新

この構文と合わせて、Swift API Design Guidelines に次のガイダンスが追加されます。

関数名は、最初の trailing closure の引数ラベルが省略されることを前提に命名する。2 つ目以降の trailing closure には意味のある引数ラベルを付ける。

最初の trailing closure にラベルを付けることは許されません。許すとケースバイケースで判断する必要が生じ、結局 linter やスタイルガイドで禁止されることが目に見えているためです。

利用例: progressive disclosure を意識した API

複数の trailing closure を使うと、メインとなる必須クロージャと、オプショナルな補助的クロージャとを自然に書き分けられます。Combine の sink は、従来 receiveCompletion: を先に書かなければならず不格好でしたが、新しい構文では値を受け取るほうを先頭の trailing closure にでき、receiveCompletion: を末尾に添える形にできます。

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  } receiveCompletion: { completion in
    // handle error
  }

SwiftUI の Section のように、必須のコンテンツに加えてオプショナルな headerfooter を取るビューも、次のように統一的に書けるようになります。

Section {
  // content
} header: {
  // header
} footer: {
  // footer
}

ラベル付き trailing closure のマッチング規則

ラベル付きの trailing closure は、対応するパラメータのラベルと一致する必要 があります。ラベルなしパラメータを指すための特別なラベルとして _: も使えますが、これは「ラベルがないパラメータ」とのみ一致し、ラベル省略の特権は持ちません。

func pointFromClosures(
  x: () -> Int,
  _ y: () -> Int
) -> (Int, Int) {
  (x(), y())
}
pointFromClosures { 10 } _: { 20 }  // OK

func performAsync(
  action: @escaping () -> Void,
  completionOnMainThread: @escaping () -> Void
) { ... }
performAsync {
  // some action
} _: {               // NG: completionOnMainThread: と書かなければならない
  window.exit()
}

型検査のルール(後方スキャン)

型検査は従来の「末尾の 1 つだけ」を拡張したルールで行われます。具体的には、まずパラメータリストを 後ろから前に向かってスキャン し、ラベル付きの trailing closure をラベル一致でパラメータに束縛していきます。そのうえで、最初のラベルなし trailing closure は、最後に一致したラベル付きパラメータより前の区間に対して、従来と同じ限定的な後方スキャンで一致するパラメータを探します。

たとえば次の関数について、

func when<T>(
  _ condition: @autoclosure () -> Bool,
  then: () -> T,
  `else`: () -> T
) -> T { ... }

新構文での呼び出しは、

when(2 < 3) {
  print("then")
} else: {
  print("else")
}

従来構文の次の呼び出しと等価です。

when(2 < 3, then: { print("then") }, else: { print("else") })

デフォルト引数との相互作用の注意点

trailing closure とデフォルト引数の関係は、従来の振る舞いがそのまま維持されます。そのため、必須の主クロージャと、デフォルト値付きのオプショナルなクロージャとを組み合わせた API は、期待どおりには型検査されません。

func resolve(
  id: UUID,
  action: (Object) -> Void,
  completion: (() -> Void)? = nil,
  onError: ((Error) -> Void)? = nil
) { ... }

// 期待したようには型検査されない
resolve(id: paulID) { paul in
  // action
} onError: { error in
  // handle error
}

これは、オプショナルなクロージャを両方省略した場合に「既存の単一 trailing closure の呼び出し」になってしまい、ソース互換性を壊さずにルールを変えられないためです。当面の回避策として、ライブラリ設計側ではデフォルト引数ではなく オーバーロード でこの形を表現することが推奨されます(型検査ルール自体を見直すことは、将来のソース互換モードで検討されうる方向性として示されています)。

default: の扱い

ラベル付き trailing closure の構文と switch 文の default: ラベルとの曖昧さを避けるため、エスケープされていない default キーワードは trailing closure のラベルとして使えません。どうしても使いたい場合は `default`: とバッククォートでエスケープする必要があります。