LoginSignup
64
26

More than 3 years have passed since last update.

Property Wrapper 入門

Posted at

Rx を使ってプロパティの変更通知をしたい、だけどクラス外からは値の変更を許したくないというユースケースがよくあると思います。

そうしたとき、以前までは以下のように書いていました。

struct Person {
  private let _age: BehaviorSubject<Int>
  var age: Observable<Int> { _age.asObservable() } 

  init(initialAge: Int) {
    _age = BehaviorSubject(initialAge)
  }
}

しかし、変更通知をしたいだけなのにわざわざこんなに書くのは冗長極まりないです(個人の感想です)。

そこで、 Swift 5.1 で追加された新たな機能、 Property Wrappers の出番です。

最初に以下のようなオブジェクトを用意します。

@propertyWrapper
struct PropertyPublished<Value> {
  private(set) var wrappedValue: Value {
    didSet {
      subject.onNext(wrappedValue)
    }
  }

  var projectedValue: Observable<Value> {
      subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
      self.wrappedValue = wrappedValue
      subject = BehaviorSubject(value: wrappedValue)
  }

  mutating func onNext(_ newValue: Value) {
      wrappedValue = newValue
  }
}

あとは変更通知したいプロパティの前に @PropertyPublished をつけるだけです。

struct Person {
  @PropertyPublished var age: Int

  init(initialAge: Int) {
    _age = PropertyPublished(wrappedValue: initialAge)
  }

  mutating func increment() {
    _age.onNext(age + 1)
  }
}

// 使い方
var person = Person(initialAge: 10)
person.$age.subscribe(onNext: { print("Age: \($0)") })
print(person.age)
person.increment()

// Output:
//   Age: 10
//   10
//   Age: 11

ずいぶんすっきりしました。


さて、すごくシンプルに書けたのはいいのですが、上の例で Swift 5.1 未満では謎な文法がありました。

Person 内ででてきた _agePerson では明示的に定義していません。
使うときに出てきた person.$agePerson では明示的に定義してません。
また、PropertyPublished 内で定義した wrappedValueprojectedValue は他の名前では代替できません。

これらは全て Property Wrapper の機能として組み込まれており、とても柔軟で便利な機能を実現することができます。

ここでは Property Wrapper の基本的な動作について、見ていきます。

基本

Property Wrapper の最低限の用件は以下です。

  • Property Wrapper 型にしたい型に @propertyWrapper をつける
  • Property Wrapper 型は wrappedValue というインスタンスプロパティを持つ。

これだけです。

実際に作っていきましょう。
まず Property Wrapper 型にしたい型に @propertyWrapper をつけます。

こうすることでコンパイラーに「この型は Property Wrapper 型です」ということを伝えます。

@propertyWrapper
struct PropertyPublished {
}

この時点でコンパイルすると、

Property wrapper type 'PropertyPublished' does not contain a non-static property named 'wrappedValue'

と怒られます。
Property Wrapper 型は wrappedValue というプロパティを持つことが最低条件でした。
なので wrappedValue を追加します。

@propertyWrapper
struct PropertyPublished<Value> {
  var wrappedValue: Value
}

これで最低条件は満たせました。
実際に使ってみましょう。
使い方も簡単でプロパティの前に @{Property Wrapper 型の名前} をつけるだけです。

struct Person {
  @PropertyPublished(wrappedValue: 10) var age: Int
}

こうすることで上のコードをコンパイラーは以下のように展開します。

struct Person {
  private var _age: PropertyPublished<Int> = PropertyPublished(wrappedValue: 10)
  var age: Int {
    get { _age.wrappedValue }
    set { _age.wrappedValue = newValue }
  }
}

まさに Property を Wrap してますね!

ちなみに @{Property Wrapper 型の名前} の後に () をつけることでその Property Wrapper 型のイニシャライザを呼び出すことができるのですが、 .init(wrappedValue:) の呼び出しだけは糖衣構文として以下が用意されています。

@PropertyPublished var age: Int = 10
// ↑ は ↓ と等価
// @PropertyPublished(wrappedValue: 10) var age: Int

とてもシンプルですね!

ただこのままではプロパティの変更を検知できません。
なので Observable を外に見える形で公開しなければなりません。

ここで Property Wrapper で便利な機能である projectedValue の出番です。

以下のように projectedValue: Observable<Value> なプロパティを追加します。

@propertyWrapper
struct PropertyPublished<Value> {
  var wrappedValue: Value

  var projectedValue: Observable<Value> {
    subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
    self.subject = BehaviorSubject(value: wrappedValue)
  }
}

値の更新を購読可能にするために BehaviorSubject も一緒に追加しました。
そして projectedValue はただ BehaviorSubjectObservable として公開しているだけです。

こうすることにより、

struct Person {
  @PropertyPublished(wrappedValue: 10) var age: Int
}

は以下のように展開されます。

struct Person {
  private var _age: PropertyPublished<Int> = PropertyPublished(wrappedValue: 10)

  var $age: Observable<Value> {
    _age.projectedValue
  }

  var age: Int {
    get { _age.wrappedValue }
    set { _age.wrappedValue = newValue }
  }
}

$age という変数が追加されました。
また、このときのプロパティの可視性は age と同じく internal です。
これにより、もともとラップしたプロパティの型は変えずに新たにプロパティの型を外に公開できます。

また、今回は projectedValue には getter しか定義しませんでした。
当たり前と言えば当たり前ですが、 wrappedValueprojectedValue が get-only で setter がない場合や、 private(set) などで setter が外部に公開されていない場合は同じく展開されたプロパティにも setter は生えません。

上記までの実装で必要なプロパティは揃えることができました。
あとは実際に新しい値がセットされたとき、PropertyPublishedsubject を発火させてあげればいいだけです。

ということで発火させる用のメソッドを PropertyPublished に追加してあげます。

@propertyWrapper
struct PropertyPublished<Value> {
  private(set) var wrappedValue: Value {
    didSet {
      subject.onNext(wrappedValue)
    }
  }

  var projectedValue: Observable<Value> {
    subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
    self.subject = BehaviorSubject(value: wrappedValue)
  }

  func onNext(_ newValue: Value) {
    self.wrappedValue = newValue
  }
}

変更通知を飛ばすのは onNext メソッドを通して欲しいため wrappedValueprivate(set) にしています。

これにより age が get-only になりました。

また、 最初から自動で生成されていた _age は private な変数なのでもちろん Person 内からならアクセスできます。

struct Person {
  @PropertyPublished var age: Int = 10

  mutating func increment() {
    _age.onNext(age + 1)
  }
}

この Person.age を監視する方法は以下です。

let person = Person()
person.$age.subscribe(onNext: { /* 処理 */})

ずいぶん短いコードで

  • 本来見せたいプロパティの値である Value
  • 外部からは見える Observable<Value>
  • 外部からは見えない Subject<Value>

を達成することができました!

Swift 5.0 以前でこれを実装しようとすると以下のように書かなければなりません。

struct Person {
  private let _age: BehaviorSubject<Int>
  // `$` はユーザー定義変数では使えない
  var observableAge: Observable<Int> { _age.asObservable() } 
  var age: Int = 10 {
    didSet {
      _age.onNext(age)
    }
  }

  init() {
   _age = BehaviorSubject(value: age)
  }

  mutating func increment() {
    age = age + 1
  }
}

長いですね。
また、これはプロパティが増えるごとに追加しなければなりません。
Property Wrappers を使えばただ @PropertyPublished を追加していけばいいだけです。

struct Person {
  @PropertyPublished var age: Int
  @PropertyPublished var name: String

  init(age: Int, name: String) {
    self._age = PropertyPublished(wrappedValue: age)
    self._name = PropertyPublished(wrappedValue: name)
  }
}

シンプルでいいですね!

ただ、ちょっと残念なのが、

self.age = age

と書けずに

self._age = PropertyPublished(wrappedValue: age)

になっている点です。

Property Wrapper の Proposal には

@Lazy var x: Int
// ...
x = 17   // okay, treated as _x = .init(wrappedValue: 17)

とあるので、 init 内ならいけないかなーと思ったのですが、

Cannot assign to property: 'age' is a get-only property

でコンパイルエラーになるのでダメみたいですね。。。
なので大人しく直接 _age を初期化しています。

ただ、前までのように大量にプロパティを追加していく必要もなく、同じような処理を何度も書かなくていいので非常に便利です。

制限

Property Wrapper はとても便利なのですが、便利が故にいろいろ制限があります。

  • ラップしたプロパティは protocol で使用できません
  • ラップしたインスタンスプロパティを extension では宣言できません
  • ラップしたインスタンスプロパティを Enum では宣言できません
  • class 内で宣言されたラップしたプロパティは override できません
  • ラップしたプロパティには lazy@NSCopying@NSManagedweakunowned をつけられません
  • ラップしたプロパティはそれを囲む宣言内で唯一のプロパティでなければなりません
    • @PropertyPublished var (x, y) = ... は不正
  • ラップしたプロパティは getter と setter を持てません
  • Property Wrapper 型と wrappedValue は、同じアクセス権を持ちます
    • PropertyPublished を public にしたなら wrappedValue も public にしないとダメ
  • init(wrappedValue:) を宣言した場合、Property Wrapper 型と init(wrappedValue:) は、同じアクセス権を持ちます
  • projectedValue を宣言した場合、Property Wrapper 型と projectedValue は、同じアクセス権を持ちます
  • init() を宣言した場合、Property Wrapper 型と init() は、同じアクセス権を持ちます

将来追加される(と思われる)機能

Property Wrappers は今でも結構便利ですが、まだまだフル機能ではありません。
Proposal に将来実装される(予定)機能があるので紹介します。

より細かいアクセス制御

現状、_age$age のアクセス権は固定になっており、制御できません(_age は private、 $age はラップしたプロパティと同じ)。

なのでこれらも使う側で制御できるようにしようという提案があります。

現状考えられているのは以下のような構文。

@PropertyPublished
public internal(storage) private(projection) var foo: Int = 0

上のように書くと、以下のように展開される予定です。

internal var _foo: PropertyPublished<Int> = PropertyPublished(wrappedValue: 0)
private var $foo: PropertyPublished<Int> { _foo.projectedValue }
public var foo: Int { _foo.wrappedValue }

便利なのですが、初見では困惑しそうですね。

ラップされたプロパティを持つインスタンスを参照する

現状の Property Wrapper 型はラップされたプロパティを所持しているインスタンスにアクセスできません。

なので現状手動で PropertyWrapper 型へ伝える必要があります。

protocol Observed {
  func broadcastValueWillChange<Value>(newValue: Value)
}

@propertyWrapper
struct BroadcastObservable<Value: Equatable> {
  private var stored: Value

  var wrappedValue: Value {
    get { stored }
    set {
      if newValue != stored {
        observed?.broadcastValueWillChange(newValue: newValue)
      }

      stored = newValue
    }
  }

  private var observed: Observed? = nil

  init(wrappedValue: Value) {
    self.stored = wrappedValue
  }

  mutating func register(_ observed: Observed) {
    self.observed = observed
  }
}

// 今回は話の都合上 class になります
class Person: Observed {
  @BroadcastObservable var age: Int = 0

  init() {
    _age.register(self)
  }

  func broadcastValueWillChange<Value>(newValue: Int) {
    // action
  }
}

ただこのコードには様々な問題があります。
まず _age.register(self) を手動で呼び出す必要があるため、忘れる可能性があります。

また、broadcastValueWillChange<Value>(newValue:) 内で age プロパティを読むとメモリの排他アクセス違反が起きる可能性があるため、アクセスしてはいけません。

試しに以下のようなコードを実行してみます。

class Person: Observed {
  @BroadcastObservable var age: Int = 0

  init() {
    _age.register(self)
  }

  func broadcastValueWillChange<Value>(newValue: Int) {
    print(age)
  }
}

var person = Person()

for i in Array(1...5) {
  person.age = i
}

person.age が変更されるたびに print するようにしています。

これを実行すると以下のようなエラーを吐いてクラッシュしてしまいます。

Simultaneous accesses to 0x100cba3a0, but modification requires exclusive access.
Previous access (a modification) started at `Person.age.setter + 85 (0x100002db5).
Current access (a read) started at:
...

Person.age の setter 内で Person.age を読むのはダメですよと言われていますね。

これらの問題を解決するために、以下のような構文が提案されています。

@propertyWrapper
struct BroadcastObservable<Value> {
  private var stored: Value

  init(wrappedValue: Value) {
    self.stored = wrappedValue
  }

  static subscript<OuterSelf: Observed>(
      instanceSelf observed: OuterSelf,
      wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
      storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value {
    get {
      observed[keyPath: storageKeyPath].stored
    }
    set {
      let oldValue = observed[keyPath: storageKeyPath].stored
      if newValue != oldValue {
        observed.broadcastValueWillChange(newValue: newValue)
      }
      observed[keyPath: storageKeyPath].stored = newValue
    }
  }
}

var wrappedValue: Value が消えて代わりに static subscript<OuterSelf: Observed>(instanceSelf:wrapped:storage:) が生えました。

そして BroadcastObservable へのアクセスは observed (ラップしたプロパティを持っているインスタンス) と storageKeyPath (e.g. _age プロパティの KeyPath) を使ってアクセスします。

これで少し複雑になりましたが、wrappedValue の setter アクセス時に getter が呼ばれることがなくなったため、メモリの排他アクセス違反が起きなくなりました。

上のような構文を書くと使う側では以下のように展開されます。

class Person: Observed {
  @BroadcastObservable var age: Int = 0

  // ↑ は ↓ に展開される
  private var _age: BroadcastObservable<Int> = BroadcastObservable(wrappedValue: 0)
  public var age: Int {
    get { BroadcastObservable<Int>[instanceSelf: self, wrapped: \Person.age, storage: \Person._age] }
    set { BroadcastObservable<Int>[instanceSelf: self, wrapped: \Person.age, storage: \Person._age] = newValue }
  }
}

これで安全にラップされたプロパティを持つインスタンスへのアクセスができるようになりますね!

また、副次的な効果として、static subscript<OuterSelf: Observed>(instanceSelf:wrapped:storage:) は展開されると self を引数にとるため、 static プロパティに対して適用できません。
また、現状 ReferenceWritableKeyPath を引数にとるため、 class 以外に適用できません(たぶん)。

なので、もし Property Wrapper 型を static プロパティや class 以外に適用されて欲しくない場合は、以下のように書けます。

@availability(*, unavailable) 
var wrappedValue: Value {
  get { fatalError("only works on instance properties of classes") }
  set { fatalError("only works on instance properties of classes") }
}

たまにラップされたプロパティを持つインスタンスにアクセスしたくなるときがあるので、ぜひ実装されて欲しいです。

他のプロパティに移譲する

現状、 @SomeWrapper を宣言すると暗黙的に _ がついたバッキングフィールドが追加されます。
ただ、もう既にあるプロパティをバッキングフィールドに使いたい場合があるかもしれません。

そのときに以下のように指定できるようにしようぜという提案。

lazy var fooBacking: SomeWrapper<Int>
@wrapper(to: fooBacking) var foo: Int

上の例では直接プロパティを入れていますが、 KeyPath のように \.someProperty.someOtherProperty のようにも指定できるようにするかなども考えられています。


以上、軽くですが Property Wrapper について解説しました。
Property Wrapper は他のプログラミング言語ではあまり見かけない、しかしテンプレート的な記述を減らせる可能性を秘めていると思います。

もしプロジェクトでバッキングフィールドを駆使しているプロパティがあったなら、それは Property Wrapper に置き直せるかもしれません。

64
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
64
26