Swift Digest
SE-0411 | Swift Evolution

Isolated default value expressions

Proposal
SE-0411
Authors
Holly Borla
Review Manager
Doug Gregor
Status
Implemented (Swift 5.10)

01 何が問題だったのか

デフォルト値の式(関数のデフォルト引数や stored property のデフォルト値)に対するactor isolationのルールには、以下の問題がありました。

  • stored property のデフォルト値は、現状のルールではデータ競合を許してしまいます。
  • デフォルト引数の値は常に nonisolated として扱われ、過度に制約が強くなっています。
  • デフォルト引数と stored property でルールが食い違っており、isolationモデル全体が理解しづらくなっています。

stored property のデフォルト値でデータ競合が起きる例

次のコードは現状(本提案前)では通ってしまいます。

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() {} // okay???
}

nonisolated init() はどこからでも同期的に呼べるにもかかわらず、その内部で暗黙に requiresMainActor()requiresAnotherActor() を呼び出します。これらは @MainActor / @AnotherActor にisolateされた関数なので、別スレッドで動いている他のコードと同時に実行されてしまい、データ競合になります。

デフォルト引数の制約が強すぎる例

一方、デフォルト引数の値は常に nonisolated として評価されるため、次のように安全なコードまで拒否されます。

@MainActor class C {}

@MainActor func f(c: C = C()) {} // error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

@MainActor func useFromMainActor() {
  f()
}

fC.init()@MainActor にisolateされており、呼び出し元も @MainActor なのでデフォルト引数の評価は安全に行えるはずですが、現状のルールでは書けません。

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

デフォルト値の式が、外側の関数・型・対応する stored property と同じisolationを持てるようにします。呼び出し元がそのisolationのドメインに居ない場合、呼び出しは非同期になり明示的に await を付ける必要があります。stored property のisolated defaultは、同じisolationを持つ init の本体でのみ暗黙に初期化されます。

これにより、冒頭の2つの例はそれぞれ次のように扱われます。

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() {} // error: 'self.x1' and 'self.x2' are not initialized
}

データ競合を起こしていた nonisolated init() は、self.x1 / self.x2 を初期化していないとしてエラーになります。自分で明示的に初期化するなら async にしたうえで await を使います。

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() async {
    self.x1 = await requiresMainActor()
    self.x2 = await requiresAnotherActor()
  }
}

デフォルト引数の例は、f とデフォルト値のどちらも @MainActor にisolateされているので、そのまま有効になります。

デフォルト値のisolationの推論

デフォルト値の式は常に同期的な文脈で評価されるので、その中で行う呼び出しもすべて同期です。よって、デフォルト値の式に推論されるisolationは「その部分式が必要とするisolation」と一致します。

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefault(value: Int = requiresMainActor()) { ... }

value のデフォルト値は @MainActor-isolatedな関数を呼び出しているので、デフォルト値自体も @MainActor を必要とすると推論されます。

クロージャリテラルは生成自体はどのドメインでも行えますが、ボディで呼び出すisolated関数の集合からクロージャ自体のisolationが推論されます。

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefaultClosure(
  closure: () -> Void = {
    requiresMainActor()
  }
) {}

ただし、デフォルト引数のクロージャリテラルは値をキャプチャできないため、アクターインスタンスへのisolationは isolated パラメータを明示的に書いた場合に限られます。推論によってアクターインスタンスにisolateされることはありません。

isolationの制約

  • 関数や型自身がisolateされている場合、デフォルト値の式が要求するisolationはそれと一致しなければなりません。たとえば @MainActor-isolated関数のデフォルト引数を @AnotherActor-isolatedにすることはできません。nonisolated なデフォルト値と混在させるのは常に問題ありません。
  • 関数や型が nonisolated の場合、デフォルト値の式も nonisolated である必要があります。

デフォルト引数の評価と await

デフォルト引数に対するisolationは呼び出し側で強制されます。呼び出し元が必要なisolationのドメインに居なければ、デフォルト引数の評価は非同期になり、await が必要です。

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefault(value: Int = requiresMainActor()) { ... }

@MainActor func mainActorCaller() {
  useDefault() // okay
}

func nonisolatedCaller() async {
  await useDefault() // okay

  useDefault() // error: call is implicitly async and must be marked with 'await'
}

引数の評価順序

通常の関数呼び出しでは、引数は次の順序で評価されます。

  1. 明示的なr-value引数を左から右に評価
  2. デフォルト引数と formal access の引数を左から右に評価

isolationを必要とするデフォルト引数がある場合、デフォルト引数はcalleeのisolationドメイン内で評価される必要があります。そのため順序が次のように変わります。

  1. 明示的なr-value引数を左から右に評価
  2. formal access の引数を左から右に評価
  3. calleeのisolationドメインへホップ
  4. デフォルト引数を左から右に評価

例えば次のコードでは、

@MainActor var defaultVal: Int { print("defaultVal"); return 0 }
nonisolated var explicitVal: Int { print("explicitVal"); return 0 }
nonisolated var explicitFormalVal: Int {
  get { print("explicitFormalVal"); return 0 }
  set {}
}

@MainActor func evaluate(x: Int = defaultVal, y: Int = defaultVal, z: inout Int) {}

nonisolated func nonisolatedCaller() {
  await evaluate(y: explicitVal, z: &explicitFormalVal)
}

出力は explicitValexplicitFormalValdefaultVal の順になります。formal accessの &explicitFormalVal の方が、@MainActor にホップしてから評価されるデフォルト引数より先に評価される点に注意してください。

stored property のデフォルト値と init

stored property のデフォルト値に対するisolationは、init の本体で強制されます。デフォルト値のisolationに合致しない init では、その stored property の暗黙初期化は行われず、本体で明示的に初期化する必要があります。

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1: Int = requiresMainActor()
  @AnotherActor var x2: Int = requiresAnotherActor()

  nonisolated init() {} // error: 'self.x1' and 'self.x2' aren't initialized

  nonisolated init(x1: Int, x2: Int) { // okay
    self.x1 = x1
    self.x2 = x2
  }

  @MainActor init(x2: Int) { // okay
    // 'self.x1' gets assigned to the default value 'requiresMainActor()'
    self.x2 = x2
  }
}

isolation boundary を越えた isolated stored property の初期化

non-Sendable な値を、isolation boundary を越えて isolated stored property に渡すことはできません。

class NonSendable {}

class C {
  @MainActor var ns: NonSendable

  init(ns: NonSendable) {
    self.ns = ns // error: passing non-Sendable value 'ns' to a MainActor-isolated context.
  }
}

これはデータ競合を防ぐためのルールで、global actor にisolated な stored property を初期化する init は、その global actor にisolateされている必要があります。

コンパイラが合成する init

struct のメンバワイズ init や、パラメータなし init() が合成されるケースでは、stored property のデフォルト値がそのままデフォルト引数・本体で使われます。

  • いずれかの isolated stored property が non-Sendable 型、または isolated なデフォルト値を持つ場合、合成される init もそのisolationを持ちます。
  • そうでなければ、合成される initnonisolated になります。
class NonSendable {}

@MainActor struct MyModel {
  // @MainActor inferred from annotation on enclosing struct
  var value: NonSendable = .init()

  /* compiler-synthesized memberwise init is @MainActor
  @MainActor
  init(value: NonSendable = .init()) {
    self.value = value
  }
  */
}

@MainActor struct MyView {
  // @MainActor inferred from annotation on enclosing struct
  var value: Int = 0

  /* compiler-synthesized 'init's are 'nonisolated'

  nonisolated init() {
    self.value = 0
  }

  nonisolated init(value: Int = 0) {
    self.value = value
  }
  */

  // @MainActor inferred from the annotation on the enclosing struct
  var body: some View { ... }
}

2つの stored property が別々のisolationを要求する場合は、合成された init では両立できないためエラーになります。そのようなケースでは、async initawait を使って両方を初期化する必要があります。

有効化と移行

stored property のデフォルト値に対する新しいルールはSwift 5モードで受け入れられるコードより厳しくなっているため、upcoming feature flag IsolatedDefaultValues の下で段階的に導入されます。Swift 6では既定で有効になります。