Isolated default value expressions
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()
}
f も C.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'
}
引数の評価順序
通常の関数呼び出しでは、引数は次の順序で評価されます。
- 明示的なr-value引数を左から右に評価
- デフォルト引数と formal access の引数を左から右に評価
isolationを必要とするデフォルト引数がある場合、デフォルト引数はcalleeのisolationドメイン内で評価される必要があります。そのため順序が次のように変わります。
- 明示的なr-value引数を左から右に評価
- formal access の引数を左から右に評価
- calleeのisolationドメインへホップ
- デフォルト引数を左から右に評価
例えば次のコードでは、
@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)
}
出力は explicitVal → explicitFormalVal → defaultVal の順になります。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を持ちます。 - そうでなければ、合成される
initはnonisolatedになります。
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 init で await を使って両方を初期化する必要があります。
有効化と移行
stored property のデフォルト値に対する新しいルールはSwift 5モードで受け入れられるコードより厳しくなっているため、upcoming feature flag IsolatedDefaultValues の下で段階的に導入されます。Swift 6では既定で有効になります。