Enable multi-statement closure parameter/result type inference
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 にデフォルトされるといった、利用者から見て予想しづらい結果になります。さらに、本体が分離して型検査されることでエラー診断も狙いを外しやすく、「Void が BinaryInteger に適合しません」といった的外れなメッセージや、「クロージャの型を明示してください」というフォールバック診断しか出せないケースが頻発していました。
次のように、すべての 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) の引数型から $0 が Int に推論され、return 文が無いのでクロージャの戻り値型は Void にデフォルトされます(関数 map の T も Void に決まります)。
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
}
オーバーロード解決への影響
複数文クロージャ本体の情報が外側に伝わるようになるため、オーバーロードされた関数の呼び出しで、これまでは複数文クロージャなら通っていたコードが新しいルールではあいまいになることがあります。たとえば次のように test が UnsafePointer<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 文から PtrTy を Int と推論できてしまうため、単一式クロージャのときと同様にあいまいと判定されます。こうしたケースでは、呼び出し側で ptr: UnsafePointer<Int> や UnsafeRawBufferPointer のようにパラメータ型を明示するのが解決策です。
Future Directions
以下は今回のスコープ外で、将来の方向性として示されているものです。実現が約束されているわけではありません。
inout推論の統一: 現状、複数文クロージャでは本体からのinout逆伝播が行われず、単一式クロージャとの挙動差が残っています。ソース互換性への影響が読みにくいため別出しとされていますが、将来の Swift で統一が検討される可能性があります。return文をまたぐ型推論:guard let x = ... else { return nil }のように、情報量の少ないreturn文が先に現れるとクロージャ全体の戻り値型を決められない場合があります。これを補うため、複数のreturn文の型の「join」を取る形で戻り値型を推論する案が今後の方向性として挙げられています。