Swift Digest
SE-0258 | Swift Evolution

Property Wrappers

Proposal
SE-0258
Authors
Doug Gregor, Joe Groff
Review Manager
John McCall
Status
Implemented (Swift 5.1)

01 何が問題だったのか

Swift には lazy@NSCopying のように、特定のプロパティ実装パターンを言語機能として組み込んだ仕組みがいくつかあります。しかし、プロパティまわりでよく使われるパターンは他にもたくさんあり、それらを一つひとつコンパイラに組み込んでいくのは現実的ではありません。

典型的なパターンを書くと大量のボイラープレートになる

たとえば lazy を自分で手書きすると、次のようなコードを毎回書く必要があります。

struct Foo {
  // lazy var foo = 1738 に相当する手書き版
  private var _foo: Int?
  var foo: Int {
    get {
      if let value = _foo { return value }
      let initialValue = 1738
      _foo = initialValue
      return initialValue
    }
    set {
      _foo = newValue
    }
  }
}

同様に、「一度だけ代入できて、それ以降はイミュータブルに振る舞う遅延初期化プロパティ」や、代入時に NSCopying.copy() を呼んでコピーを作るプロパティなども、どれも似たような定型コードを必要とします。Implicitly-Unwrapped Optional で代用することもできますが、non-optional な let と比べると安全性を大きく損ないます。

言語機能として組み込む方法には限界がある

lazy のような仕組みを言語機能として増やしていくと、コンパイラと言語仕様はどんどん複雑かつ非直交的になります。遅延初期化ひとつ取っても「一度きり初期化」「リセット可能」「スレッドセーフ」などバリエーションが豊富で、そのすべてを言語機能として用意することはできません。

また、SwiftUI の @State@EnvironmentObject、Combine の @Published、データベースのフィールドを束ねる @Field、UserDefaults を型付きで扱うラッパーなど、ライブラリ側で定義したい「プロパティの実装パターン」は無数にあります。ここで必要なのは、こうしたパターンをライブラリとして定義できる一般的な仕組みです。

2015-2016 年の property behaviors 提案との関係

同じ問題意識で 2015-2016 年に property behaviors という提案がありましたが、専用の宣言構文やアクセサの拡張など設計が大きく、合意に至らず deferred になりました。今回の提案は同じ動機を引き継ぎつつ、既存の型(ジェネリックな構造体・enum・クラス)をラッパーとして使うという、より小さく実装可能な形に落とし込むものです。

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

プロパティの実装パターンをライブラリとして定義できる汎用的な仕組みとして、property wrappers を導入します。ラッパーの本体は通常のジェネリック型として定義し、それを属性構文 @WrapperName でプロパティに適用します。

@propertyWrapper 属性と基本要件

property wrapper 型は、次の 2 つの要件を満たす必要があります。

  • 型宣言に @propertyWrapper 属性を付ける
  • wrappedValue という名前のプロパティを持ち、そのアクセスレベルが型自身と同じである

wrappedValue がラップ対象の値への実際のアクセスを提供します。たとえば Lazy は次のように書けます。

@propertyWrapper
enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(wrappedValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(wrappedValue)
  }

  var wrappedValue: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

使う側の書き換えルール

@Lazy var foo = 1738 のような宣言は、コンパイラによって次のように展開されます。

private var _foo: Lazy<Int> = Lazy<Int>(wrappedValue: 1738)
var foo: Int {
  get { return _foo.wrappedValue }
  set { _foo.wrappedValue = newValue }
}
  • アンダースコア付きの _foo合成された stored property(バッキングストレージ)で、常に private
  • foo 自身は computed property になり、wrappedValue 経由で値にアクセスする
  • ラップされたプロパティに対して、明示的な getter / setter は書けないが、willSet / didSet は書ける

property wrapper はグローバル・ローカル・型スコープのいずれでも使えますが、プロトコル内や enum の instance property、extension の instance property には使えません。また、lazy / @NSCopying / @NSManaged / weak / unowned との併用や、class 内でのオーバーライドもできません。

3 通りの初期化

合成された stored property は次の 3 通りで初期化できます。

  1. 元のプロパティ型の値から(init(wrappedValue:) 経由) — 初期値を = で書いた場合は常にこの形で糖衣化されます。init(wrappedValue:)wrappedValue と同じ型の単一パラメータ(または @autoclosure 版)を取り、型と同じアクセスレベルを持つ必要があります。

    @Lazy var foo = 17
    // 展開:
    private var _foo: Lazy = Lazy(wrappedValue: 17)
    
  2. ラッパー型のイニシャライザ引数を直接指定 — 属性名の後ろにカッコで引数を書くと、その引数でラッパーインスタンスを直接生成できます。

    @UnsafeMutablePointer(mutating: addressOfInt)
    var someInt: Int
    // 展開:
    private var _someInt: UnsafeMutablePointer<Int> = UnsafeMutablePointer(mutating: addressOfInt)
    
  3. 暗黙の init() — 初期値も引数もなく、ラッパー型に無引数の init() があれば、それで初期化されます。

    @DelayedMutable var x: Int
    // 展開:
    private var _x: DelayedMutable<Int> = DelayedMutable<Int>()
    

初期値とカスタム引数は併用できます。その場合、init(wrappedValue:)wrappedValue: 引数が先頭に置かれ、他の引数が続きます。

@propertyWrapper
struct Clamping<V: Comparable> {
  var value: V
  let min: V
  let max: V

  init(wrappedValue: V, min: V, max: V) {
    value = wrappedValue
    self.min = min
    self.max = max
  }

  var wrappedValue: V {
    get { value }
    set {
      if newValue < min { value = min }
      else if newValue > max { value = max }
      else { value = newValue }
    }
  }
}

struct Color {
  @Clamping(min: 0, max: 255) var red: Int = 127
  @Clamping(min: 0, max: 255) var green: Int = 127
  @Clamping(min: 0, max: 255) var blue: Int = 127
  @Clamping(min: 0, max: 255) var alpha: Int = 255
}

projectedValue$ プロジェクション

ラッパー型が projectedValue プロパティを定義すると、$ プレフィックス付きの「プロジェクション」プロパティが合成されます。これはラッパー自身が持つ追加 API を公開するための仕組みです。

@propertyWrapper
public struct Field<Value: DatabaseValue> {
  public let name: String
  public var wrappedValue: Value { /* ... */ }
  public var projectedValue: Self {
    get { self }
    set { self = newValue }
  }
  public func flush() { /* ... */ }
}

public struct Person {
  @Field(name: "first_name") public var firstName: String
}

// 利用側:
somePerson.firstName = "Taylor" // wrappedValue 経由
$somePerson.firstName.flush()   // projectedValue 経由

$firstName は元のプロパティと同じアクセスレベルを持ちます。_firstName(バッキングストレージ)は常に private なので、クライアントには firstName$firstName の 2 つが見える形になります。複数のラッパーを合成した場合、projectedValue一番外側のラッパーのものだけが使われます。

$ で始まる識別子は従来 Swift では使えませんでしたが、このプロジェクションのためにコンパイラ側が導入する名前として解禁されます。ユーザーコードで $ 始まりの識別子を新たに宣言することは引き続きできません。

型推論

ラッパー型がジェネリックな場合、型引数は属性に明示するか、コンパイラに推論させられます。推論の優先順は次の通りです。

  1. 初期値式がある場合、A(wrappedValue: E, argsA...) の呼び出し結果から型を決める
  2. 属性に直接の初期化引数がある場合、A(E...) から決める
  3. どちらもなく型注釈がある場合、ラッパー型の wrappedValue の型がその注釈と一致するように決める
@Lazy var foo = 17            // Lazy<Int>
@Lazy<Int> var bar: Int       // OK
@Lazy<Int> var baz: Double    // エラー: wrappedValue は Int

ミュータビリティ

合成される getter / setter のミュータビリティは、wrappedValue の宣言に従います。

  • wrappedValue の getter が mutating なら、struct 上のプロパティの getter も mutating になる
  • wrappedValue の setter が nonmutating なら setter も nonmutating
  • ラッパー型がクラスなら、getter / setter は常に nonmutating
  • wrappedValue に setter がなければ、プロパティは読み取り専用になる

合成(composition)

複数のラッパーを重ねて書くと、ラッパー型がネストされた形に展開されます。

@DelayedMutable @Copying var path: UIBezierPath
// バッキングストレージは DelayedMutable<Copying<UIBezierPath>>
// path の get/set は _path.wrappedValue.wrappedValue を経由する

この設計上、合成は可換ではありません。順序を入れ替えると別の型になり、意味も変わります。意味的に不適切な合成でも型システムで検出できないケースはあり得るため、組み合わせの意図は利用側で確認する必要があります。複数のラッパーに初期化引数を付けられるのは一番外側のラッパーだけで、init(wrappedValue:) による糖衣を使う場合はすべてのラッパーに init(wrappedValue:) が必要です。

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

struct の合成メンバワイズイニシャライザには、wrapper 付きプロパティも含まれます。パラメータの型は次のように決まります。

  • 初期値が = で与えられているか、ラッパー型に init(wrappedValue:) がある場合 → 元のプロパティ型
  • それ以外 → ラッパー型そのもの
struct Foo {
  @UserDefault(key: "FOO", defaultValue: false) var x: Bool
  @Lazy var y: Int = 17
  @Lazy(closure: { getBool() }) var z: Bool
  @CopyOnWrite var w: Image

  // 合成イニシャライザ:
  init(x: UserDefault<Bool> = UserDefault(key: "FOO", defaultValue: false),
       y: Int = 17,
       z: Lazy<Bool> = Lazy(closure: { getBool() }),
       w: Image) {
    self._x = x
    self._y = Lazy(wrappedValue: y)
    self._z = z
    self._w = CopyOnWrite(wrappedValue: w)
  }
}

Codable / Hashable / Equatable の自動合成

Encodable / Decodable / Hashable / Equatable の自動合成は、バッキングストレージ(_foo)の方を基準に行われます。これにより、ラッパー型自身が自分のシリアライズや等価性の挙動を決められます。キーアーカイブ時のキー名には、先頭の _ を除いた元のプロパティ名が使われます。

後から初期化する

wrapper 付きプロパティは、宣言後に初期化することもできます。init(wrappedValue:) があれば元のプロパティ名経由で、なければ _foo 経由で直接初期化できます。

@Lazy var x: Int
// ...
x = 17   // _x = Lazy(wrappedValue: 17) と同じ

@UnsafeMutable var y: Int
// ...
_y = UnsafeMutable<Int>(pointer: addressOfInt) // 直接初期化

definite initialization のルールはそのまま適用されます。

典型的な用例

property wrappers は、次のような場面で特に威力を発揮します。

  • 遅延初期化LazyDelayedMutable / DelayedImmutable で、IUO に頼らない多段階初期化を表現
  • NSCopying 相当init(wrappedValue:) と setter で copy() を呼ぶ Copying ラッパー
  • UserDefaults の型付きアクセス — キーとデフォルト値を持ち、wrappedValue で UserDefaults 経由の読み書きを隠蔽
  • Copy-on-writewrappedValueprivate(set) で直接書き込みを禁じ、projectedValueisKnownUniquelyReferenced 経由のコピーを噛ませる
  • SwiftUI@State / @Binding / @EnvironmentObject / @Environment などのローカル状態・データ依存関係の宣言。BindingprojectedValue を通じて可変参照を渡す手段として使われる
  • Combine の @Published — プロパティ変更を購読できる Publisher をプロジェクションとして公開

将来の拡張(Future Directions)

今回のスコープ外だが今後検討されうる方向性として、次のものが挙げられています。実現を約束するものではありません。

  • 細かいアクセス制御 — バッキングストレージやプロジェクションのアクセスレベルを、プロパティごと、あるいはラッパー型側の宣言で変える仕組み(例: public(storage) private(projection) のような指定)
  • 外側の self への参照 — ラッパーから囲っている型の self にアクセスできるようにする拡張。static subscript(instanceSelf:wrapped:storage:) の形でラッパー型が opt-in する案が検討されており、これによりプロパティ変更通知のような「外側 self を参照するラッパー」を書けるようになる可能性がある
  • 既存プロパティへの委譲 — 合成ストレージを作るのではなく、既存のプロパティを裏側のストレージとして使う @wrapper(to: existingProperty) 形の委譲