Swift Digest
SE-0400 | Swift Evolution

Init Accessors

Proposal
SE-0400
Authors
Holly Borla, Doug Gregor
Review Manager
Frederick Kellison-Linn
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftは stored property に対して definite initialization(確定代入)解析を行い、初期化前の領域が読まれないことを保証しています。一方、実際のコードでは「ひとつの stored property を裏側のストレージにしつつ、表向きは computed property としてアクセスさせる」というパターンが頻出します。property wrapper や attached macro は、このパターンを後押しする仕組みです。

このとき、利用者は表側の computed property の名前で初期化したいのが自然です。property wrapper については、@propertyWrapperwrappedValue を通じた初期化が特別扱いされており、self.value = value のような代入は暗黙的に self._value = Wrapper(wrappedValue: value) に書き換えられていました。

@propertyWrapper
struct Wrapper<T> {
  var wrappedValue: T
}

struct S {
  @Wrapper var value: Int

  init(value: Int) {
    self.value = value  // self._value = Wrapper(wrappedValue: value) に書き換えられる
  }
}

しかし、この書き換えは property wrapper 専用にハードコードされたアドホックな仕組みでした。そのため次のような限界がありました。

  • 追加の引数を持つ property wrapper は、イニシャライザ本体からこの方法で初期化できない。
  • property-wrapper に似たマクロ(たとえば @Observable)は、同じような使い勝手を実現できない。マクロが stored property を computed property に変換してしまうと、元の名前で初期化しようとしても「まだ初期化されていない」と診断されてしまう。
@Observable
struct Proposal {
  var title: String
  var text: String

  init(title: String, text: String) {
    self.title = title // error: 'self' used before all stored properties are initialized
    self.text = text   // error: 'self' used before all stored properties are initialized
  }
}

つまり、definite initialization のゲートをくぐれるのは stored property と property wrapper だけで、ユーザー定義の computed property やマクロによる変換後の computed property には、同じ扱いを得る手段がありませんでした。

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

computed property に対して新しい種類のアクセサである init accessor を定義できるようにします。init accessor は、その computed property に対する代入を「初期化」として扱えるようにし、指定した stored property の初期化を肩代わり(subsume)します。これにより、computed property であっても definite initialization に参加できるようになります。

struct Angle {
  var degrees: Double
  var radians: Double {
    @storageRestrictions(initializes: degrees)
    init(initialValue) {
      degrees = initialValue * 180 / .pi
    }

    get { degrees * .pi / 180 }
    set { degrees = newValue * 180 / .pi }
  }

  init(degrees: Double) {
    self.degrees = degrees // degrees を直接初期化
  }

  init(radiansParam: Double) {
    self.radians = radiansParam // radians の init accessor を呼び出す
  }
}

構文と @storageRestrictions

init accessor は computed property のアクセサブロックの中に init として書きます。パラメータを省略した場合、初期値は newValue という名前で参照できます。

struct Minimal {
  var value: Int {
    init {
      print("init accessor called with \(newValue)")
    }

    get { 0 }
  }
}

init accessor に付けられる @storageRestrictions 属性で、次の2つのストレージへの関わり方を宣言します。

  • initializes: この init accessor が初期化する stored property の一覧。本体のすべての制御フロー上で、これらの property が初期化されなければなりません。
  • accesses: この init accessor が読み書きできる stored property の一覧。これらは init accessor が呼ばれる時点で初期化済みである必要があります。

@storageRestrictions で指定された以外の self のメンバにはアクセスできませんし、self 自体をメソッド呼び出しなどで丸ごと使うこともできません。これは「self 全体がまだ初期化されていない段階で呼ばれる関数」という init accessor の性質からくる制約です。

accesses を使うと、computed property を別の stored property の中身として初期化するようなパターンも書けます。

struct ProposalViaDictionary {
  private var dictionary: [String: String]

  var title: String {
    @storageRestrictions(accesses: dictionary)
    init(newValue) {
      dictionary["title"] = newValue
    }

    get { dictionary["title"]! }
    set { dictionary["title"] = newValue }
  }

  var text: String {
    @storageRestrictions(accesses: dictionary)
    init(newValue) {
      dictionary["text"] = newValue
    }

    get { dictionary["text"]! }
    set { dictionary["text"] = newValue }
  }

  init(title: String, text: String) {
    self.dictionary = [:] // init accessor が触る前に初期化しておく
    self.title = title    // init accessor 経由で辞書に入る
    self.text = text      // 同上
  }
}

イニシャライザ本体での扱い

イニシャライザ内の代入は、self 全体が初期化済みかどうかで意味が変わります。

  • self が全パスで初期化済みになる前の、init accessor を持つ computed property への代入は init accessor 呼び出し に書き換えられます。このとき initializes に挙げた stored property もまとめて初期化済みになります。
  • self が初期化済みになった後の代入は通常通り setter 呼び出し になります。
struct S {
  var x: Int
  var y: Int

  var point: (Int, Int) {
    @storageRestrictions(initializes: x, y)
    init(newValue) {
      (self.x, self.y) = newValue
    }
    get { (x, y) }
    set { (x, y) = newValue }
  }

  init(x: Int, y: Int) {
    if x == y {
      self.point = (x, x) // init accessor 呼び出し
    }
    // ここでは point はまだ全パスで初期化されていない
    self.point = (x, y) // init accessor 呼び出し
    // ここから先は point も初期化済み
  }
}

stored property を直接初期化していき、init accessor の initializes に挙がっている stored property がすべて揃った時点で、その computed property も初期化済みと見なされます。

struct S {
  var x1: Int
  var x2: Int
  var x3: Int

  var computed: Int {
    @storageRestrictions(initializes: x1, x2)
    init(newValue) { /* ... */ }
  }

  init() {
    self.x1 = 1 // x1 のみ初期化
    self.x2 = 1 // x2 と computed が初期化済みになる
    self.x3 = 1 // self 全体が初期化済み
  }
}

一方、initializes の stored property の一部だけを先に直接初期化してから、同じ computed property へ代入することはできません。これは、基となる stored property が二重に初期化されてしまうのを防ぐための制約です。

メンバワイズイニシャライザ

構造体が自前のイニシャライザを宣言していない場合、Swiftは stored property をもとにメンバワイズイニシャライザを合成します。init accessor を持つ computed property がある場合、このイニシャライザには computed property 側 がパラメータとして含まれ、initializes に挙げられて肩代わりされている stored property はパラメータから外れます。property wrapper ではこの目的のためにアドホックな仕組みが用意されていましたが、今回 init accessor に統合されます。

struct S {
  var _x: Int

  var x: Int {
    @storageRestrictions(initializes: _x)
    init(newValue) { _x = newValue }

    get { _x }
    set { _x = newValue }
  }

  var y: Int
}

S(x: 10, y: 100) // x, y を受け取る初期化子が合成される

accesses によって他の stored property への依存がある場合、メンバワイズイニシャライザの中ではその順序を守るように初期化順がコンパイラによって入れ替えられます。パラメータの順序自体はソース順のままです。

また、init accessor は setter を持たない read-only な computed property にも付けられ、この場合は let のように「ちょうど一度だけ初期化できる」プロパティとして振る舞います。

初期値との組み合わせ

init accessor を持つプロパティに初期値を書くこともできます。合成されるメンバワイズイニシャライザでは、その初期値がパラメータのデフォルト引数として使われます。

struct WithInitialValues {
  var _x: Int

  var x: Int = 0 {
    @storageRestrictions(initializes: _x)
    init(initialValue) { _x = initialValue }
    get { /* ... */ }
    set { /* ... */ }
  }

  var y: Int
}

// 合成されるメンバワイズイニシャライザ:
// init(x: Int = 0, y: Int) {
//   self.x = x  // init accessor 経由で _x を初期化
//   self.y = y
// }

手書きのイニシャライザでは、初期値がまずユーザーコードに先立って適用され、その後に書かれた同じプロパティへの代入は setter 呼び出しになります。

property wrapper の位置付けの整理

この提案により、property wrapper のためのアドホックな definite initialization / メンバワイズ初期化の仕組みはなくなります。init(wrappedValue:) を持つ property wrapper は、内部的に init accessor を持つ computed property と等価な形に desugar される形に統一されます。これによって、property wrapper に似た振る舞いをマクロで自前で実現する際にも、同じ品質のイニシャライザが書けるようになります。

Future Directions(speculative)

以下は将来的な拡張として提案内で言及されている方向性で、実現を約束するものではありません。

  • ローカル変数に対する init accessor のサポート。self の初期化状態ではなく、ローカル変数自身の初期化状態をもとに init / set の書き換えを行う必要があり、クロージャによるキャプチャの扱いと合わせて別途検討が必要とされています。
  • @storageRestrictions を init accessor 以外の関数にも一般化し、イニシャライザの断片としての「初期化ヘルパー関数」を書けるようにする方向性。たとえばクラスで共有される初期化コードを、initializes / accesses を指定した通常のメソッドとして切り出せるようになる可能性があります。