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
内ででてきた _age
は Person
では明示的に定義していません。
使うときに出てきた person.$age
も Person
では明示的に定義してません。
また、PropertyPublished
内で定義した wrappedValue
や projectedValue
は他の名前では代替できません。
これらは全て 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
はただ BehaviorSubject
を Observable
として公開しているだけです。
こうすることにより、
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 しか定義しませんでした。
当たり前と言えば当たり前ですが、 wrappedValue
や projectedValue
が get-only で setter がない場合や、 private(set)
などで setter が外部に公開されていない場合は同じく展開されたプロパティにも setter は生えません。
上記までの実装で必要なプロパティは揃えることができました。
あとは実際に新しい値がセットされたとき、PropertyPublished
の subject
を発火させてあげればいいだけです。
ということで発火させる用のメソッドを 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
メソッドを通して欲しいため wrappedValue
を private(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
、@NSManaged
、weak
、unowned
をつけられません - ラップしたプロパティはそれを囲む宣言内で唯一のプロパティでなければなりません
-
@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 に置き直せるかもしれません。