Swiftのプロパティ監視(Property Observers)

SwiftのProperty Observers

プロパティ監視(Property Observers)は、プロパティにセットされる値が、どのように変更されるかをモニター・応答する機能です。プロパティ監視には2種類あって、willSetdidSetです。

このページでは、プロパティ監視とは何か?から始めて、willSetdidSet、格納プロパティを使った具体的な使用例について説明します。

プロパティ監視(Property Observers)って何?

プロパティ監視(Property Observers)とは、プロパティの値を監視(observe)して、その変更に対して何かしらの応答をするような機能です。「プロパティオブザーバ」と読みをそのままカタカナにしても通じるかもしれませんが、ここでは「プロパティ監視」と呼ぶことにします。

基本的には格納プロパティに付随する機能で、プロパティの値がセットされると、毎回プロパティ監視が呼び出されます。この時、セットされた値が現在の値と同じでも関係ありません。次のセクションで、具体的な機能について実例を使って説明します。

プロパティ監視が使えるプロパティを列挙すると、

  • 格納プロパティ(lazyプロパティはダメ)
  • サブクラス中で継承した(オーバーライドした)プロパティ(保存または計算プロパティ)

となります。プロパティ監視が真価を発揮するのは、オーバーライドしたプロパティの場合だと思います。オーバーライド(override)に関しては、クラスの継承に関するページで詳しく紹介する予定です。

willSetとdidSet

willSetとdidSetの名前の覚え方

プロパティ監視にはwillSetdidSetがあります。これらは、どちらか一方だけ定義しても問題ありません。また、

  • willSetは、値がセットされる直前に呼び出される
  • didSetは、値がセットされた直後に呼び出される

という特徴があります。名前の覚え方としては、プロパティ監視呼び出しの瞬間を現在として、値渡し(値のセット)を見ると分かりやすいかもしれません。図示してみると、下のようになります。

[図解]willSetとdidSet

willSetから見ると、プロパティに値渡しするのは未来なので、willです。また、didSetから見ると、プロパティに値渡しをしたのは過去なので、didになります。

willSetとdidSetの特徴

willSetとdidSetの機能や特徴をまとめると、下のテーブルのようになります。

Observer 呼び出し場所 デフォルトパラメータ
willSet 値渡しの前 newValue
didSet 値渡しの後 oldValue

プロパティ監視は、内部パラメータとして定数を取ります。デフォルトでは、willSetnewValuedidSetoldValueという名前のパラメータを取ります。ここでのnewやoldも、上に載せた絵の時間軸上で意味を考えると分かりやすいと思います。また、計算プロパティ(computed properties)のsetterのように、自分でパラメータの名前を指定することも可能です。

サブクラスでのプロパティ監視

公式マニュアルでは以下のような注意書きがあります。

“NOTE

The willSet and didSet observers of superclass properties are called when a property is set in a subclass initializer, after the superclass initializer has been called. They are not called while a class is setting its own properties, before the superclass initializer has been called.

For more information about initializer delegation, see Initializer Delegation for Value Types and Initializer Delegation for Class Types.”

抜粋:: Apple Inc. “The Swift Programming Language (Swift 2.2)”。 iBooks https://itun.es/jp/jEUH0.l

これは、プロパティ監視が初期化のどの時点で呼び出されるか、ということを述べたものですが、実際に試してみます。継承(inheritance)初期化(initialization)に関しては、別のページで詳しく説明します。

// Property observersがサブクラス初期化でどのように呼び出されるか?
class SuperClass {
    var property: String = "" {
        willSet {
            print("これから値\(newValue)をセットします")
        }
        didSet {
            print("\(oldValue)を\(property)で書き換え")
        }
    }

    init() {
        property = "superclass"
    }
}

class SubClass: SuperClass {
    let subProperty: Int
    init(number: Int) {
        subProperty = number
        print("Call super.init()")
        super.init()
        print("Set new value to property")
        property = "test"
    }
}

let subClass = SubClass(number: 1)

//結果:
//Call super.init()
//Set new value to property
//これから値testをセットします
//superclassをtestで書き換え

継承や初期化が絡むので細部には触れませんが、結果を見るとプロパティ監視(willSetdidSet)が呼び出されているのはproperty = "test"の部分のようです。つまり、スーパークラスの初期化時点(super.init())では呼び出されていません。別の言い方をすると、プロパティ監視が呼び出されるのは、初期化後にプロパティへ値渡しする時になります。

確かに、初期化する際にプロパティ監視が呼び出されると(困るわけじゃありませんが)、ちょっと煩わしいかもしれません。

関数(またはメソッド)のin-outパラメータがプロパティ監視を持っている場合

Swift 2.2のドキュメントから追加された(正確に言うと、書き換えられた)注意書きですが、

“NOTE

If you pass a property that has observers to a function as an in-out parameter, the willSet and didSet observers are always called. This is because of the copy-in copy-out memory model for in-out parameters: The value is always written back to the property at the end of the function. For a detailed discussion of the behavior of in-out parameters, see In-Out Parameters.”

抜粋:: Apple Inc. “The Swift Programming Language”。 iBooks https://itun.es/jp/jEUH0.l

とあります。要約すると

In-out parameterの場合、必ずプロパティ監視が呼び出される

ということのようです。

先程のクラスを使って実際に試してみると、

let subClass = SubClass(number: 1)

func addHello(inout string: String) {
    string = "Hello " + string
}

addHello(&subClass.property)
//これから値Hello testをセットします
//testをHello testで書き換え

この例では実用性はありませんが、確かにin-out parameterとしてプロパティを関数に渡すと、プロパティ監視が呼び出されていることが分かります。

プロパティ監視の使用例

ここでは、パスワード(または文字列)を監視するクラスを作ってみます。

// パスワード管理クラス
class PasswordManager {
    var password: String = "" {
        willSet(newPassword) {
            print("-------------------------------------------------------")
            print("新しいパスワード、\(newPassword)をこれからセットします")
        }
        didSet {
            print("新パスワード:\(password)、旧パスワード:\(oldValue)")
            print("-------------------------------------------------------")
        }
    }
}

let passwordManager = PasswordManager()
passwordManager.password = "123456"
passwordManager.password = "abcdef"
passwordManager.password = "abcdef"
// 出力
//-------------------------------------------------------
//新しいパスワード、123456をこれからセットします
//新パスワード:123456、旧パスワード:
//-------------------------------------------------------
//-------------------------------------------------------
//新しいパスワード、abcdefをこれからセットします
//新パスワード:abcdef、旧パスワード:123456
//-------------------------------------------------------
//-------------------------------------------------------
//新しいパスワード、abcdefをこれからセットします
//新パスワード:abcdef、旧パスワード:abcdef
//-------------------------------------------------------

PasswordManagerクラスは、String型のpasswordプロパティを宣言しています。このpasswordは格納プロパティ(stored property)で、プロパティ監視(willSet, didSet)を持っています。

この例から、入力した文字列に関わらず、常にプロパティ監視が呼び出されているのが分かります。今、willSetは、内部パラメータとしてnewPasswordという定数を使っています。また、didSetは、パラメータ名を指定していませんので、デフォルトのoldValueが使われています。

これだけだと味気ないので、少し条件を足して遊んでみます。

// 良いパスワードを要求
// 条件
// - 8文字以上
// - 大文字を1つ以上含む
// - 数字を1つ以上含む
// - 特殊文字を1つ以上含む
class PasswordManager {
    var isStrong = false
    var password: String = "defaultpassword" {
        willSet(newPassword) {
            // 文字列の長さから条件判定
            if newPassword.characters.count < 8 {
                print("パスワードが短すぎます(\(newPassword.characters.count)文字)。8文字以上のパスワードを設定して下さい")
                return
            }

            var isCapital = false // 大文字を少なくとも1つ含む
            var isNumber  = false // 数字を少なくとも1つ含む
            var isSpecial = false // 特殊文字を少なくとも1つ含む
            for word in newPassword.characters {
                switch word {
                    case "A"..."Z":
                        isCapital = true
                    case "1"..."9":
                        isNumber  = true
                    case "~","!","@","#","$","%","^","&","*","(",")",
                         "-","_","=","+","[","{","]","}",";",":",",",
                         "<",".",">","/","?":
                        isSpecial = true
                    default:
                        break
                }
            }

            if !isCapital {
                print("大文字が少なくとも1つ必要です")
                return
            }

            if !isNumber {
                print("数字が少なくとも1つ必要です")
                return
            }

            if !isSpecial {
                print("特殊文字が少なくとも1つ必要です")
                return
            }

            print("-------------------------------------------------------")
            print("新しいパスワード、\(newPassword)をこれからセットします")
            isStrong = true
        }
        didSet {
            if !isStrong {
                // パスワードを元に戻す
                password = oldValue
                return
            }

            print("新パスワード:\(password)、旧パスワード:\(oldValue)")
            print("-------------------------------------------------------")
        }
    }
}

let passwordManager = PasswordManager()
passwordManager.password = "123456"
passwordManager.password = "a1234567"
passwordManager.password = "a12B4567"
passwordManager.password = "a12B456%"
// 出力
//パスワードが短すぎます(6文字)。8文字以上のパスワードを設定して下さい
//大文字が少なくとも1つ必要です
//特殊文字が少なくとも1つ必要です
//-------------------------------------------------------
//新しいパスワード、a12B456%をこれからセットします
//新パスワード:a12B456%、旧パスワード:defaultpassword
//-------------------------------------------------------

少し長いですが、やってることは難しくありません。「良い」パスワードの条件として、文字列の長さ(8文字以上)、大文字・数字・特殊文字を少なくとも1つずつ要求しています。基本的には、これらの条件をwillSetで判定し、条件を満たさなければ、入力した値を代入しないという処理をしています。didSetで何もしないと、willSetでの条件判定に関わらず値を代入してしまうので、パスワードを元に戻す操作を入れています。これを実装するために、別の論理変数isStrongを格納プロパティとして用意しています。

実行例を見てもらえると分かると思いますが、要求した条件を満たさない場合は、passwordプロパティは初期値"defaultpassword"のままです(print()関数で出力するとはっきり分かります)。全ての条件を満たして、isStrongtrueになった場合のみ、新しいパスワードを設定しています。

まとめ

  • プロパティ監視(Property Observers)は、プロパティへの変更を監視(observe)したり、その変更に対して応答する機能
  • willSetは値渡しの前、didSetは値渡しの後
  • デフォルト内部パラメータ(定数)の名前は、newValuewillSet)とoldValuedidSet
  • 内部パラメータには好きな名前を付けることも可能