Swift Digest
SE-0326 | Swift Evolution

複数文クロージャのパラメータ/結果型推論を有効にする

Enable multi-statement closure parameter/result type inference

Proposal
SE-0326
Authors
Pavel Yaskevich
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Swift のクロージャには、これまで 単一式クロージャ複数文クロージャ で型推論の挙動が大きく異なるという問題がありました。

単一式クロージャ(本体が式ひとつだけのもの)は、呼び出し元の式と一緒にまとめて型検査されるため、クロージャ本体から得られる情報(パラメータ型や戻り値型)を外側の式に伝播できます。たとえば次のコードでは、doSomething($0) の呼び出しから T が推論されます。

func map<T>(fn: (Int) -> T) -> T {
  return fn(42)
}

func doSomething<U: BinaryInteger>(_: U) -> Void { /* processing */ }

let _ = map {
  doSomething($0)
}

ところが、ここに文をひとつ足して複数文クロージャにすると挙動が変わります。

let _ = map {
  logger.info("About to call 'doSomething(\($0))'")
  doSomething($0)
}

複数文クロージャの本体は、外側の map の呼び出しが完全に解決されたあとで、分離して型検査されます。そのため本体の情報は外側に伝わらず、T は決まらないか、あるいは Void にデフォルトされるといった、利用者から見て予想しづらい結果になります。さらに、本体が分離して型検査されることでエラー診断も狙いを外しやすく、「VoidBinaryInteger に適合しません」といった的外れなメッセージや、「クロージャの型を明示してください」というフォールバック診断しか出せないケースが頻発していました。

次のように、すべての return 文から戻り値型を決めたい、ごく素直に見えるコードも同じ理由で型検査に失敗していました。

struct Box {
  let weight: UInt
}

func heavier_than(boxies: [Box], min: UInt) -> [UInt] {
  let result = boxies.map {
    if $0.weight > min {
       return $0.weight
    }

    return 0
  }

  return result
}

こうした挙動は「クロージャに文をひとつ足しただけでコンパイルが通らなくなる」という崖(behavior cliff)を生み、ユーザーには毎回クロージャの型を明示させるしかない状況でした。複数のパラメータや複雑なタプル戻り値を持つクロージャでは、この明示が特に煩雑になります。

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

複数文クロージャでも、本体から推論された情報を外側の式に伝播できるようにします。これにより単一式クロージャとの差がなくなり、「文が増えた瞬間に型検査が失敗する」崖が解消されます。

推論のルール

新しいルールでは、複数文クロージャのパラメータ型と戻り値型の推論は次のように行われます。

  • クロージャの 文脈から与えられた型 が最優先の情報源です。クロージャが代入される変数の型やクロージャを受け取る関数のパラメータ型などから推論されます。
  • 文脈から情報が得られない場合、匿名パラメータ($0 など)は最初に使われた箇所で型が決まり、その型がそのクロージャ全体で固定されます。以降の文で矛盾する型を要求するとあいまいとしてエラーになります。
  • 最初の return 文の型が戻り値型を決めます。以降の return 文で異なる型を返そうとするとエラーになります(文脈型がある場合はそちらが優先)。
  • return 文がひとつも無いクロージャの戻り値型は、従来どおり Void にデフォルトされます。
  • inout のパラメータは、従来どおり文脈型かパラメータ宣言で明示する必要があります。複数文クロージャの本体からの inout の推論・逆伝播は今回のスコープ外です。

クロージャ本体自体の型検査は従来どおり「上の文から下の文へ」一方向に進み、あとの文の情報が前の文の推論に影響することはありません。関数・サブスクリプト・getter などと同じく、最初に決まった型がそのまま宣言の型になる、という一貫したモデルです。

動作例

冒頭のロガー付きの例は、新しいルールのもとでは素直に型検査できます。doSomething($0) の引数型から $0Int に推論され、return 文が無いのでクロージャの戻り値型は Void にデフォルトされます(関数 mapTVoid に決まります)。

func map<T: BinaryInteger>(fn: (Int) -> T) -> T {
  return fn(42)
}

func doSomething<U: BinaryInteger>(_: U) -> Void { /* processing */ }

let _ = map {
  logger.info("About to call 'doSomething(\($0))'")
  doSomething($0)
}

if と複数の return を含む次のコードも新しいルールで型検査できます。

struct Box {
  let weight: UInt
}

func heavier_than(boxies: [Box], min: UInt) -> [UInt] {
  let result = boxies.map {
    if $0.weight > min {
       return $0.weight
    }

    return 0
  }

  return result
}

最初の return $0.weight から戻り値型が UInt と決まり、その情報が次の文の整数リテラル 0 に伝わって UInt として扱われます。そのうえで result の型は [UInt] となり、外側の式にまで推論結果が伝播します。同じことは map の結果にさらに filter を繋いだような、より複雑な式でも成り立ちます。

func precisely_between(boxies: [Box], min: UInt, max: UInt) -> [UInt] {
  let result = boxies.map {
    if $0.weight > min {
       return $0.weight
    }

    return 0
  }.filter {
    $0 < max
  }

  return result
}

オーバーロード解決への影響

複数文クロージャ本体の情報が外側に伝わるようになるため、オーバーロードされた関数の呼び出しで、これまでは複数文クロージャなら通っていたコードが新しいルールではあいまいになることがあります。たとえば次のように testUnsafePointer<PtrTy> を受け取るものと UnsafeRawBufferPointer を受け取るものでオーバーロードされている場合、

func test<PtrTy, R>(_: (UnsafePointer<PtrTy>) -> R) -> R { ... }
func test<ResultTy>(_: (UnsafeRawBufferPointer) -> ResultTy) -> ResultTy { ... }

let _: Int = test { ptr in
  print(ptr) // 本体に無関係な文を足しても...
  return Int(ptr[0]) << 2
}

旧ルールでは本体が型検査に参加しないので最初のオーバーロードから PtrTy を推論できず、結果として曖昧さなく解決されていました。新ルールでは return 文から PtrTyInt と推論できてしまうため、単一式クロージャのときと同様にあいまいと判定されます。こうしたケースでは、呼び出し側で ptr: UnsafePointer<Int>UnsafeRawBufferPointer のようにパラメータ型を明示するのが解決策です。

03 今後の見通し

以下は今回のスコープ外で、将来の方向性として示されているものです。実現が約束されているわけではありません。

inout 推論の統一

複数文クロージャでは、本体からの inout の逆伝播が行われません。inout を使うには、文脈型かパラメータ宣言で明示する必要があります。

// 文脈型から `inout` が伝わるケース(現状でもOK)
[1, 2].reduce(into: 0) { $0 += $1 }

// 複数文クロージャの本体から `inout` を逆伝播するのは未対応
// 明示が必要: { (x: inout Int) -> Void in ... }

一方、単一式クロージャでは本体での使い方(代入や & 付きの引数渡しなど)から外側のパラメータ型に inout を逆伝播できます。この差は見た目から読み取りづらく、利用者の混乱の元になっています。両者を統一する案は、ソース互換性への影響が読みにくいため本提案からは切り離されており、将来の Swift で改めて検討される可能性があります。

return 文をまたぐ型推論

新しいルールでは「最初の return 文の型が戻り値型を決める」ため、情報量の少ない return 文が先に現れるとクロージャ全体の戻り値型を決められない場合があります。たとえば guard let で早期に return nil するコードでは、nil だけからは戻り値型を推論できません。

func test<T>(_: () -> T?) { ... }

test {
  guard let x = doSomething() else {
     return nil // ここからは `T` を決められない
  }
  ...
}

現状はクロージャの型を明示する以外に解決策がありません。将来的には、本体に現れる複数の return 文の型の「join」を取って戻り値型を決める、という新しい推論ルールを導入する案が挙げられています。