Swift Digest
SE-0418 | Swift Evolution

Inferring Sendable for methods and key path literals

Proposal
SE-0418
Authors
Angela Laar, Kavon Farvardin, Pavel Yaskevich
Review Manager
Becca Royal-Gordon
Status
Implemented (Swift 6.0)

01 何が問題だったのか

Swiftでは、メソッドを呼び出さずに参照するだけで関数値を作ることができます。インスタンスに束縛した形(部分適用)と、型に対して束縛していない形(unapplied)の二通りがあります。

struct S {
  func f() { /* ... */ }
}

let partial: () -> Void = S().f    // 部分適用
let unapplied: (S) -> (() -> Void) = S.f  // unapplied

ところが、これらの関数値を @Sendable が要求される場所に渡そうとすると、型が Sendable であっても @Sendable 性が自動では伝わらず、警告になっていました。

protocol P: Sendable { init() }

func g<T: P>(_ f: @escaping @Sendable (T) -> (() -> Void)) {
  Task { f(T())() }
}

struct S: P {
  func f() {}
}

g(S.f) // warning: Converting non-sendable function value to
       // '@Sendable (S) -> (() -> Void)' may introduce data races

メソッドが実際にキャプチャするのは self のインスタンスだけなので、SSendable であれば S.fS().f も本来 @Sendable として安全に扱えるはずです。しかし従来はそれをユーザー側で明示しなければならず、g({ @Sendable in S.f($0) }) のように @Sendable クロージャでラップするボイラープレートが必要でした。

key path リテラルの過剰な制約

SE-0302 では、key path リテラルは 常に暗黙に Sendable として扱われる、と定められていました。その結果、subscript の引数などに non-Sendable な値をキャプチャする key path を書くと、たとえ並行に渡す予定がなくても警告が出てしまいます。

class Info: Hashable { /* ... */ }
public struct Entry {}
public struct User {
  public subscript(info: Info) -> Entry { /* ... */ }
}

let entry: KeyPath<User, Entry> = \.[Info()]
// warning: cannot form key path that captures non-sendable type 'Info'

このコード自体は isolation boundary を越えないためデータ競合は起こらないのに、key path を作った瞬間に警告が出るのは過剰です。別モジュールで定義された型については対処のしようもなく、また、並行性をまだ意識していない利用者にとっては唐突に感じられる診断であり、progressive disclosure の観点からも望ましくありません。

グローバル関数の扱い

クロージャと違い、グローバル関数やstaticなグローバル関数は何もキャプチャしません。それでも、Task に直接渡すと「@Sendable ではない」として警告になっていました。

func doWork() -> Int { Int.random(in: 1..<42) }

Task<Int, Never>.detached(priority: nil, operation: doWork)
// warning: Converting non-sendable function value to
// '@Sendable () async -> Void' may introduce data races

キャプチャが存在しない以上、データ競合の余地はなく、既定で @Sendable にできるはずです。

non-Sendable 型のメソッドに @Sendable を付けられてしまう問題

逆方向の穴もありました。Sendable ではない型のメソッドに @Sendable を付けると、そのメソッドを通して内部のミュータブルな状態が並行に触られ、データ競合を招きかねません。

class C {
  var random: Int = 0 // `C` は Sendable にできない

  @Sendable func generateN() async -> Int { // 本来は禁止されるべき
    random = Int.random(in: 1..<100)
    return random
  }
}

この書き方は現状コンパイルが通ってしまい、@Sendable 属性の意味を壊していました。

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

関数値と key path リテラルについて、「non-Sendable な値をキャプチャしない」ことがコンパイラにわかるケースでは @Sendable / & Sendable自動的に推論 するようにします。あわせて、@Sendable を付けてはいけないケースを明示的に禁止します。

本提案は upcoming feature flag InferSendableFromCaptures で有効化できます。

1. Sendable 型の unapplied メソッドを @Sendable として推論

型が Sendable に適合していれば、そのメソッドの unapplied 参照は @Sendable 関数型として扱えます。

struct User: Sendable {
  var address: String
  var password: String

  func changeAddress(new: String, old: String) { /* ... */ }
}

let unapplied: @Sendable (User) -> ((String, String) -> Void) = User.changeAddress // OK

2. Sendable 型の部分適用メソッドを @Sendable として推論

部分適用(インスタンスに束縛した)メソッドの関数値も、インスタンスの型が Sendable であれば @Sendable として扱えます。

let partial: @Sendable (String, String) -> Void = User().changeAddress // OK

ただし、mutating メソッドの unapplied / 部分適用参照はもともと言語仕様上認められていないため(SE-0042 参照)、この推論の対象外です。staticメソッドは対象に含まれます。

3. key path リテラルは「non-Sendable がデフォルト」に転換し、必要なときに & Sendable を推論

key path リテラルの扱いを反転させ、明示的に & Sendable を要求しない限り、key path は non-Sendable とします。そのうえで、non-Sendable な値をキャプチャせず、actor-isolated なプロパティ/subscript も参照していない key path については、文脈から & Sendable を推論します。

struct User {
  var name: String
  @MainActor var age: Int
  subscript(_ info: Info) -> Entry { /* ... */ }
}

let name = \User.name             // WritableKeyPath<User, String> & Sendable(推論)
let ageKP = \User.age             // KeyPath<User, Int>(non-Sendable: age が MainActor isolated)
let infoKP = \User.[Info()]       // non-Sendable(Info が non-Sendable)

明示的に型を書いた場合の扱いは次のとおりです。

let name: KeyPath<User, String> & Sendable = \.name          // OK
let name: KeyPath<User, String> = \.name                     // OK だが non-Sendable
let otherName: KeyPath<User, String> & Sendable = name       // エラー(name は non-Sendable)
let entry: KeyPath<User, Entry> & Sendable = \.[Info()]      // エラー(Info が non-Sendable)

key path 型は @Sendable 関数型と相互に使えます。@Sendable (User) -> String を要求する文脈に \.name を渡すと、コンパイラが { $0[keyPath: \.name] } に相当するクロージャを合成します。

let name: @Sendable (User) -> String = \.name // OK(\.name 自身が Sendable である必要はない)

let value = NonSendable()
let _: @Sendable (User) -> String = \.[value] // エラー(value をキャプチャしてしまう)

KeyPath を引数に取る既存APIは、@preconcurrency を付けつつ & Sendable を足すことでABI互換を保ったまま要件を強化できます。

@preconcurrency public func getValue<T, U>(_: KeyPath<T, U> & Sendable) { /* ... */ }

key path リテラルが引数として渡される場合は、パラメータ側の & Sendable 要件が推論のヒントになります。

func getValue<T: Sendable>(_: KeyPath<User, T> & Sendable) -> T {}

getValue(\.name)             // OK(& Sendable が推論される)
getValue(\.[NonSendable()])  // エラー(non-Sendable なキャプチャ)

4. 非ローカル関数は既定で @Sendable

グローバル関数とstaticなグローバル関数は何もキャプチャしないため、既定で @Sendable として扱われます。

func doWork() -> Int { Int.random(in: 1..<42) }

Task<Int, Never>.detached(priority: nil, operation: doWork) // OK

5. non-Sendable 型のメソッドへの @Sendable 付与を禁止

型自身が Sendable でないのに、そのメソッドだけ @Sendable を名乗ることは data race を招きうるため、コンパイラが拒否します。

class C {
  var random: Int = 0

  @Sendable func generateN() async -> Int { // error: adding @Sendable to function
                                             // of non-Sendable type prohibited
    random = Int.random(in: 1..<100)
    return random
  }
}

本提案の推論が入った結果、利用者はメソッド/関数宣言に @Sendable を明示的に書く必要がなくなります。既存の @Sendable 付き宣言は引き続き許容されますが、役割は実質的に推論に置き換わります。

appending(...) の Sendable 版オーバーロード

KeyPath.appending(...) は、ベースと引数がともに & Sendable な場合に結果も & Sendable になるよう、Sendable 拡張として新しいオーバーロードが標準ライブラリに追加されます。

extension Sendable where Self: AnyKeyPath {
  @inlinable
  public func appending<Root, Value, AppendedValue>(
    path: KeyPath<Value, AppendedValue> & Sendable
  ) -> KeyPath<Root, AppendedValue> & Sendable where Self: KeyPath<Root, Value> {
    // ...
  }
}

func makeUTF8CountKeyPath<Root>(
  from base: KeyPath<Root, String> & Sendable
) -> KeyPath<Root, Int> & Sendable {
  return base.appending(path: \.utf8.count) // OK: 両方 Sendable なので結果も Sendable
}

互換性への影響

型を 明示していない 宣言で、関数値や key path リテラルが Sendable 性を新たに獲得するため、Sendable 有無でオーバーロードされているAPIの解決が変わることがあります。

func callback(_: @Sendable () -> Void) {}
func callback(_: () -> Void) {}
callback(MyType.f) // f が @Sendable 推論されるなら最初のオーバーロードが選ばれる

func getValue(_: KeyPath<String, Int> & Sendable) {}
func getValue(_: KeyPath<String, Int>) {}
getValue(\.utf8.count) // & Sendable 版が選ばれる

また、宣言から @Sendable を外して推論に任せると、そのメソッドのマングリングが変わる点には注意が必要です(Sendable はmarker protocolなので & Sendable の付け外し自体はABI中立です)。

Future Directions

アクセサ(getter / setter)は今回のスコープに含まれていません。将来的には、少なくとも getter に対して同じ仕組みで @Sendable 推論を及ぼす拡張が考えられます(speculativeであり、実現を約束するものではありません)。