Swift Digest
SE-0449 | Swift Evolution

Allow nonisolated to prevent global actor inference

Proposal
SE-0449
Authors
Sima Nerush, Holly Borla
Review Manager
Tony Allevato
Status
Implemented (Swift 6.1)

01 何が問題だったのか

グローバルアクターの isolation は、さまざまな経路で暗黙に推論されます。たとえば、@MainActor を付けたプロトコルに適合する型は、自分で書いていなくても暗黙に @MainActor-isolated になります。

@MainActor
protocol GloballyIsolated {}

struct S: GloballyIsolated {} // 暗黙に globally-isolated

この例のように 1 段だけなら挙動は単純ですが、適合リストが長くなったり、プロトコルのリファイン関係やスーパークラス経由で連鎖的に推論が起きたりすると、「どこから isolation が来ているのか」を読み手が追いかけるのが難しくなります。

一方で、プロトコルがグローバルアクターに isolated でも、それに適合する型は nonisolated のままにしておきたい、というケースも珍しくありません。ところが、型全体のグローバルアクター推論を止める方法が、これまで用意されていませんでした。個々のメソッドに nonisolated を付けていくことはできますが、型レベルでまとめて opt out する手段がないのです。

これまで推論を「遮断 (cut-off)」するには、不格好な回避策に頼るしかありませんでした。1 つ目の方法は、適合をエクステンションで書いたうえで、そのエクステンション内の要件をすべて nonisolated で明示する、というものです。

@MainActor
protocol P {
  var x: Int { get }
}

struct S {}

extension S: P {
  nonisolated var x: Int {
    get { 1 }
  }
  nonisolated func test() {
    print(x)
  }
}

この書き方では、S はグローバルアクターに isolated なプロトコル P に適合しつつも自身は nonisolated でいられますが、要件ごとに nonisolated を書き続けなければならない負担があります。

2 つ目は、プロトコル自体のグローバルアクター推論を止めたいときの回避策です。コンパイラは、グローバルアクターの推論元が複数あって互いに食い違う場合、「どれにも決められない」としてグローバルアクターを推論しません。これを逆手にとり、別のグローバルアクターに isolated なダミーのプロトコルを用意し、両方をリファインすることで推論を打ち消す、というテクニックが知られていました。

@MainActor
protocol GloballyIsolated {}

@FakeGlobalActor
protocol RemoveGlobalActor {}

protocol RefinedProtocol: GloballyIsolated, RemoveGlobalActor {} // 'RefinedProtocol' は non-isolated

RefinedProtocol@MainActor@FakeGlobalActor という競合する推論元を持つため、結果としてどのグローバルアクターにも isolated にならずに済みますが、このためだけにダミーのアクターとプロトコルを用意する必要があり、明らかに本筋から外れた書き方です。

加えて、stored property に nonisolated を書けるかどうかのルールも部分的で、「型としては Sendable な値型なのに、プロトコル要件の isolation がそのまま witness に残ってしまう」といった使い勝手の悪さが残っていました。

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

nonisolated を型・プロトコル・エクステンションの宣言にも書けるようにし、その宣言についてはグローバルアクターの推論を打ち切ることができるようにします。併せて、stored property に nonisolated を書けるルールも拡張し、使い勝手を改善します。

型・プロトコル・エクステンションへの nonisolated

class / struct / enum / protocol / extension の宣言に nonisolated を書くと、その宣言はグローバルアクターの推論の対象外になります。

nonisolated struct S: GloballyIsolated, NonIsolatedProto {} // 'S' は 'GloballyIsolated' から isolation を継承しない

プロトコルに付けた場合も同様で、リファイン元のグローバルアクター isolation を引き継ぎません。要件自体はそのまま残りますが、isolated ではなくなります。

nonisolated protocol Refined: GloballyIsolated {}

struct A: Refined {
  var x: NonSendable
  nonisolated func printX() {
    print(x) // OK, 'x' は non-isolated
  }
}

エクステンションに付けると、そのエクステンション内のメンバーすべてに nonisolated が波及します。これまでメンバーごとに nonisolated を付けていたのが、1 か所で済むようになります。

nonisolated extension GloballyIsolated {
  var x: NonSendable { .init() }
  func implicitlyNonisolated() {}
}

struct C: GloballyIsolated {
  nonisolated func explicitlyNonisolated() {
    let _ = x // OK
    implicitlyNonisolated() // OK
  }
}

class / struct / enum に付けた場合も、そのメンバーはすべて nonisolated になります。

nonisolated class K: GloballyIsolated {
  var x: NonSendable
  init(x: NonSendable) {
    self.x = x // OK, 'x' は non-isolated
  }
}

nonisolated struct S: GloballyIsolated {
  var x: NonSendable
  init(x: NonSendable) {
    self.x = x // OK, 'x' は non-isolated
  }
}

ただし、nonisolated 宣言の内部にネストされた型は、自分自身の適合リストから独立して isolation を推論します。外側の nonisolated は内側のネスト型には波及しません。これは @MainActor の推論がネスト型に波及しない従来の挙動と揃っています。

nonisolated struct S: GloballyIsolated {
  var value: NotSendable // 'value' は non-isolated
  struct Nested: GloballyIsolated {} // 'Nested' は @MainActor-isolated のまま
}

stored property への nonisolated

stored property に nonisolated を書けるルールも広がります。

non-Sendable 型の stored property: 元々 non-Sendable 型の stored property は暗黙に non-isolated として扱われていましたが、今後はこれを明示的に nonisolated と書けます。non-Sendable な基底インスタンス自体が同時に 1 つの isolation domain からしかアクセスされないため、メソッドやプロパティを nonisolated にしても安全です。

class MyClass {
  nonisolated var x: NonSendable = NonSendable() // OK
}

Sendable 値型の mutable な Sendable storage: SE-0434 でグローバルアクターに isolated な値型の var プロパティについて導入されたルールを、すべての Sendable 値型へ拡張します。モジュール内では nonisolated が暗黙に推論され、モジュール外からも同期的にアクセスさせたいときは明示的に nonisolated を付けます。

protocol P {
  @MainActor var y: Int { get }
}

struct S: P {
  var y: Int // モジュール内では 'nonisolated' が推論される
}

struct F {
  nonisolated func getS(_ s: S) {
    let x = s.y // OK
  }
}

Sendable 値型は isolation domain を跨ぐ際にコピーされるので、各 domain は独立した複製を持ちます。プロパティ型も Sendable であれば、var であっても代入が他の domain のコピーに影響することはなく、同期アクセスは安全です。モジュール外へこの緩和を公開したいときは、明示的に nonisolated を書きます。

// Module A
public protocol P {
  @MainActor var y: Int { get }
}

public struct S: P {
  public nonisolated var y: Int // 'y' は明示的に non-isolated
}
// Module B
import A

struct F {
  nonisolated func getS(_ s: S) {
    let x = s.y // OK
  }
}

明示的な nonisolated を付けていない場合、モジュール外からは従来どおり @MainActor-isolated として扱われます。

nonisolated を書けない場所

次のケースでは nonisolated を書くことができません。

  • 他の isolation と同時には書けない。グローバルアクター属性や isolated パラメータと nonisolated を同じ宣言に併記することはできません。

    @MainActor
    nonisolated struct Conflict {} // error: isolation attribute が複数ある
    
  • Sendable 型のプロパティで、プロパティ型が non-Sendable のときSendable な外側を他の domain に送ったあと、そこから non-Sendable なプロパティへ同期アクセスできてしまうと Sendable 性と矛盾するためです。

    @MainActor
    struct InvalidStruct /* 暗黙に Sendable */ {
      nonisolated let x: NonSendable // error
    }
    
  • Sendable クラスの var プロパティ。参照型は複数の domain が同じインスタンスを共有できるため、varnonisolated にすると同期的な並行アクセスが起きてデータ競合になり得ます。

    @MainActor
    final class InvalidClass /* 暗黙に Sendable */ {
      nonisolated var test: Int = 1 // error
    }
    

導入時の注意

nonisolated を付けることでグローバルアクター推論が遮断されると、それに連動していた暗黙の Sendable 適合も失われる点に注意が必要です。たとえば class C: GloballyIsolated {}@MainActor 推論の結果として暗黙に Sendable になりますが、次のように nonisolated を付けると、推論ごと打ち切られて Sendable ではなくなります。

nonisolated class C: GloballyIsolated {}

これまで CSendable 性に依存していた呼び出し側のコードは、必要なら明示的に Sendable に適合させるなどの対応が必要になります。