https://www.youtube.com/watch?v=FB5v_rEdE0I
今回は、プロパティーに値を設定したときの追加処理、いわゆる willSet
と didSet
について眺めていきます。この内容は A Swift Tour
の「オブジェクトとクラス」に分類されていますけれど、構造体についても同様というか僅かに独特な動きを見せたりするので、そんな辺りをゆっくりと眺められたらいいなと思ってます。
そしてそれを見終えた後は オプショナルチェイニング
について確認していく予定です。よろしくお願いしますね。
———————————————————————————— 熊谷さんのやさしい Swift 勉強会 #51
00:00 開始 00:18 値設定における追加処理 03:09 全項目イニシャライザー 06:46 全項目イニシャライザーのアクセスコントロール 10:01 全項目イニシャライザーと型拡張 11:04 クラスのイニシャライザー 13:46 ファイナルクラス 14:25 既定イニシャライザー 17:19 didSet と willSet の基本 19:54 変更前の値や変更予定の値を取得 23:56 didSet で値を書き換える 25:50 willSet で値を書き換える 27:49 Copy-In Copy-Out(検証失敗) 41:15 検証失敗 42:49 検証失敗を受けての検討 44:57 didSet と初期化フェーズの関係 50:07 次回の展望 ————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #51
はい、それでは始めていきますね。
今日は、今映しているのが前回終わった内容ですね。これが今回のお話、「値の設定における追加処理」のお話になります。要は、皆さんご存じの方も多いと思いますが、「willSet」と「didSet」についてです。ですので、これはプロパティについて、特に保存型プロパティについてのお話です。このセクションは「オブジェクトとクラス」に関する内容ですが、クラスに限定した話ではなく、保存型プロパティを持てる型全般に使える「willSet」と「didSet」についてのお話です。
まずはスライドをざっくりと見ていきますね。保存型プロパティでは「willSet」や「didSet」が使えます、とSwiftの仕様でなっていますね。プロパティを計算する必要がない場合に、新しい値を設定する前後で追加コードを実行したいときに使うものです。この追加処理は、イニシャライザー以外で値が変更されたときに実行されるというところがポイントですね。
では、まず「willSet」と「didSet」の基本的な動きを見ていきましょう。せっかくクラスの話ですから、クラスでやってみましょう。プロパティがあって、例えば Int
型でも何型でもいいのですが、とにかく保存型プロパティであれば使えます。
ここで構造体に話をずらします。このコードの1行目から4行目には特に変わったところはないように見えますが、これをクラスで書いたときにはエラーになります。クラスにはイニシャライザーが必要だからです。構造体の場合、イニシャライザーが自動的に実装されるという特徴があります。これをメンバーワイズイニシャライザーと言います。全プロパティを初期化するための仕組みです。
例えば、Int
型のプロパティがある場合、構造体では自動的に次のようなイニシャライザーが実装されます。
struct SomeStruct {
var someProperty: Int
}
let instance = SomeStruct(someProperty: 10)
プロパティがすべてデフォルト値を持つ場合、デフォルトのイニシャライザーも生成されます。
struct SomeStruct {
var someProperty: Int = 0
}
let instance = SomeStruct() // デフォルトイニシャライザーが使える
let anotherInstance = SomeStruct(someProperty: 10)
このメンバーワイズイニシャライザーは通常インターナルで、モジュール外からは呼び出せません。アクセスコントロールによってパブリックにすることもできますが、基本的には内部でのみ利用可能にして、安全を確保しています。これは非常に興味深い動きです。
以上のように Swift ではプロパティの設定時に追加処理を行うための willSet
と didSet
を使うことができ、構造体では自動的にイニシャライザーが生成されるという便利な機能も提供されているのです。この後で、さらに詳細な例も見ていきましょう。 もしパブリックのイニシャライザー、つまりメンバーワイズイニシャライザーを搭載したい場合、この型のところでプレイグラウンドだとメニューが違うようです。エディターにあるかと思うのですが、リファクタリングのメニューが見当たらない場合、ちょっとヘルプを頼むかもしれません。
リファクタリングは通常のXcode環境で利用できるはずです。その場合、メンバーワイズイニシャライザーを自動実装するメニューがあるので、それを使ってもらえれば、自動的に追加されます。ただ、それが見つからないかもしれません。Xcodeの通常モードには確かに存在するので、それを選ぶとイニシャライザーに internal
が付いた状態でメンバーワイズイニシャライザーが生成されるようになります。
パブリックにしたい場合、自分で書く必要がありますが、それ以外の場合、6行目から10行目までを書く手間が省けます。これは構造体の場合で、クラスでは省けないという感じです。
もう一つ興味深い点として、独自のイニシャライザーを定義した場合、そのオブジェクトに対してメンバーワイズイニシャライザーが実装されなくなり、自分が定義したイニシャライザーだけが有効になります。ただし、型拡張の場合は違います。エクステンションで構造体を拡張しイニシャライザーを追加した場合には、メンバーワイズイニシャライザーと独自のイニシャライザーの両方が使用可能になります。
次にクラスのイニシャライザーについてですが、クラスには少なくとも1つのイニットを定義しなければなりません。メンバーワイズイニシャライザーは自動実装されないため、自分で定義しないとコンパイルエラーになります。
さて、エクステンションを使ってオブジェクトにイニシャライザーを追加する場合に、普通のイニシャライザーを追加し、 self
を初期化しようとするとエラーになります。これはクラスの型拡張の場合、イニシャライザーは必ず convenience
イニシャライザーである必要があるためです。そのため、 self.init
を呼び出す必要があります。この特徴は前回説明した内容と一致します。
構造体の場合、型拡張で普通に自分自身を初期化することができますが、クラスの型拡張ではコンビニエンスイニシャライザーを使って最終的にそのクラスのデザインイテッドイニシャライザーによって処理しなければなりません。これは、クラスの継承時に値が壊れる可能性を考慮しての仕様です。
ちなみに、ファイナルクラスの場合、コンビニエンスイニシャライザーである必要はなかったはずです。しかし、試してみると分かりますが、セルフのバリューが正しいかどうか、大丈夫かどうか……結果、やはりコンビニエンスイニシャライザーである必要があるようです。ファイナルクラスに関する議論は少し長くなるので、今回は省略しましょう。
ファイナルクラスにすると継承が不可能になるため、構造体に似た特徴を持つことになります。この点を考慮すると、クラスの扱い方が少し変わってくるかもしれません。それもまた興味深いことだと思います。 あともう一個、クラスのイニシャライザーの話をしますね。クラスではなくても良いのですが、メンバーワイズ・イニシャライザーは自動実装されません。しかし、クラスのプロパティが全てデフォルト値を持っている場合、イニシャライザーを明示的に定義しなくても大丈夫です。厳密に言うと、自動的にイニシャライザーが提供されるので、そのパラメーターなしのイニシャライザーが使用できるようになります。この状態でコンパイルがエラーなしで通ります。
また、クラスのイニシャライザーにはもう一つ面白い特徴があります。サブクラスを作成したときに、親クラスのイニシャライザーが使えるようになりますので、ぜひ確認してみてください。
さて、willSet
とdidSet
の話に戻りますね。willSet
とdidSet
は保存型プロパティに適用されます。willSet
は値が保存される直前に、didSet
は値が保存された直後に特定の処理を実行することができます。一方、計算型プロパティにはwillSet
やdidSet
は存在しません。その代わりにゲッター(get
)とセッター(set
)がありますので、その中で自由に処理を書くことができます。
保存型プロパティのwillSet
とdidSet
は、以下のようなイメージで捉えると良いでしょう。システムがプロパティの値を実際に保存する処理の前後に割り込んで特定の処理を追加することができる機能です。これにより、カプセル化を強化しつつ、追加処理を簡単に行うことが可能になります。
例えば、willSet
では新しい値がnewValue
として受け取れますので、古い値(プロパティの現在の値)と新しい値を比較してログを出力することができます。以下のコードのようになります。
var value: Int {
willSet {
print("現在の値: \\(value)、新しい値: \\(newValue)")
}
didSet {
print("古い値: \\(oldValue)、現在の値: \\(value)")
}
}
保存される直前の値をnewValue
で、新しい値をvalue
で参照できます。didSet
では保存が完了した後の値がvalue
に入っているため、古い値はoldValue
で参照できます。
実際に試してみましょう。例えば、最初に値を3に設定し、その後新しい値を20に変更すると、willSet
とdidSet
の各出力がそれぞれのログに現れるはずです。以下のような感じです。
var value: Int = 3 {
willSet {
print("現在の値: \\(value)、新しい値: \\(newValue)")
}
didSet {
print("古い値: \\(oldValue)、現在の値: \\(value)")
}
}
value = 20
実行すると、次のように表示されます。
現在の値: 3、新しい値: 20
古い値: 3、現在の値: 20
このようにして、willSet
とdidSet
を活用してプロパティの変更前後の状態を追跡することができます。それでは、次回の動画でお会いしましょう。 こうして実行してみると、こんな感じになります。ウィルセットかどうかも書いておいたほうがいいですね。こちらがウィルセットで、次がディドセットです。これで動かすと分かりやすいですよね。
ウィルセットのほうは、最初に初期化した 3
が現在値として取得され、newValue
として今度代入する予定のものが手に入ります。ディドセットのほうも、すでに設定された値は newValue
、つまり新しい値 20
が入っていて、oldValue
に対して 3
が渡ってきます。こういうふうなことが確認できるわけです。
また、パラメータ名を変えることもできます。例えば、newValue
を単に new
にすることもできます。するとコード上では new
という名前で扱われることになります。このように、変数名を明示的に指定することも可能です。
さらに、ディドセットの中で値を書き換えることもできます。例えば「変更させたくない」という場合には、現在値を oldValue
に書き換えることができます。このとき、print
で object.value
とかを出力すると、20
を代入しているにもかかわらず、最終的な値は 3
になります。ここで2倍すると 6
になるといったこともできます。このように、すでに設定済みの値をディドセットで書き直すことも可能です。
ただし、注意すべき点があります。ディドセットやウィルセットの中で自身に対して値の再設定を行った場合、これらが追加で呼ばれることはありません。つまり、安心して動作を行うことができます。ただし、使い方を誤ると思いがけない動作をすることもあるので注意が必要です。
また、willSet
で value
に値を設定できるかも確認してみたくなりますが、コンパイル自体は通りますが、警告が出ます。willSet
の場合、その後で newValue
が value
に代入されるため、ここで代入した値は無駄になります。この点については、コンパイルエラーにしても良いのではないかと思いますが、現状は警告が出るにとどまっています。
こんな感じで、ウィルセットとディドセットの使い方について説明しました。これにより、プロパティの値を設定する前後での処理をカスタマイズできることが分かります。
では、次のお話に移りましょう。えっと、ウィルセットとディドセットについては以上です。もし何かコメントがあれば受け付けますので、ぜひお知らせください。 挟みます。これでね、どんな動きをするのかを見てみたいと思います。
まず、簡単にSwiftの構造体(struct)を使った例をお見せします。以下のように、バリュー(value)という変数とメソッドを持つ構造体を作ります。
struct MyStruct {
var value: Int
}
次に、インアウトパラメータ(inout
)を使った関数を定義します。この関数は、渡された変数の値を変更します。
func modifyValue(_ value: inout Int) {
value = 5
}
この関数を使うと、以下のように、関数呼び出し前後で値が変更されることを確認できます。
var myValue = 10
print("Before: \\(myValue)") // 出力は 10
modifyValue(&myValue)
print("After: \\(myValue)") // 出力は 5
次に、これをマルチスレッドで試してみましょう。まずはDispatchQueue
を使います。
import Dispatch
var structInstance = MyStruct(value: 10)
print("Initial value: \\(structInstance.value)") // 出力は 10
DispatchQueue.global().async {
modifyValue(&structInstance.value)
print("Value in thread: \\(structInstance.value)") // 値が変わっていることを期待
}
sleep(1)
print("Final value: \\(structInstance.value)") // ここでも値が変わっているか確認
注意点として、inout
パラメータは関数が終わるまでライトバック(値の書き戻し)が行われないため、途中の値の読み込みには注意が必要です。
さらに、スレッドスリープを使って、関数の実行中に値を読むとどうなるかを検証します。
func modifyValueWithDelay(_ value: inout Int) {
print("Start modifying...")
sleep(5)
value = 5
print("End modifying...")
}
DispatchQueue.global().async {
modifyValueWithDelay(&structInstance.value)
}
print("Before sleep: \\(structInstance.value)")
sleep(1)
print("After 1s sleep: \\(structInstance.value)")
sleep(5)
print("After 6s sleep: \\(structInstance.value)")
この例では、modifyValueWithDelay
関数が開始され、5秒間スリープしてから値を変更するため、スレッドスリープの間に値を読み込むとどのような動きをするかが確認できます。
以上が、Swiftでのインアウトパラメータとマルチスレッドの基本的な扱い方です。コンカレンシー(並行処理)は複雑ですが、このように基本的な動作を理解することで、より安全に使うことができます。 とりあえず、ちゃんと8秒ぐらい待てばオッケーですね。これで実行すると、まずはオッケーです。だんだんと、ここでバリューを表示したほうがよかったかもしれません。「バリューのA」を表示して、それでオッケーです。これでプレイグラウンドが動いてない気がします。これを実験したときに、まずはちゃんと動くんです。didSet
とか入れてないので、ちゃんと動きます。でもどっちがちゃんと動くかは人によりますから、動かしてからお話ししましょう。今度はプレイグラウンドが動かなくなったかもしれません。
とりあえず、何の変哲もない保存型プロパティの場合、最適化が図られます。インアウトのライトバックという手法がありますが、そのライトバックを採用しないで、ダイレクトに読み書きをする動きを今書いているコードでは見せるはずです。これはあくまで最適化された動きとは違うということなので、本当はこちらの動きのほうが注意すべき動きなんですが、動かないですね。どうすれば動くのか、待っているよりコマンドラインツールでも作って試しましょうか。ここでね。
今、もしかしてコンソールプログラムなんかも選べるんですね。これで動かすと、動いているかな。今ビルドして同じ動きが確認できます。これがまず直感的な動きのはずなんですけど、バリューにまず7をセットした状態で初期化して、async
でモディファイを呼び出してあげます。
これで実行されるのか、設定前の値として7が代入されて、実行されると5になります。5になったのがこの13行目です。それから5秒待つ間、段が出るのが5秒後なんですが、その間にメインスレッドで1秒待った後にチェック命令が走ります。そして、Aの値は5になっている。ここですね、5です。そして5秒経った後、セカンダリスレッドが終了してプログラムが終わるというふうになります。このチェックの5、ここが気にしておきたいところです。
これが、didSet
を例えば用意してあげると、何も実装していなくてもいいんですが、保存型プロパティにdidSet
を持たせると、このチェックが7になるはずだったんです。あれ、チェックが5だ。7だったはずなんです。プリントでdidSet
の中のoldValue
とnewValue
を表示するようにしてみます。どうなるでしょうか。
あれ、7と5になっている。自分の勘違いですかね。確かここがダメだったはずなんですけど、最適化されたんですね。でも、この最適化のプロセスが気になりますね。didSet
でAを書き換えるとどうなるのでしょうか。やっぱり変わるんですね。なるほど、自分の勘違いなのかもしれませんが、このあたりが『The Swift Programming Language』の中にも最適化が図られると書かれています。今は「Swiftツアー」ですが、ツアーじゃないほうに出てきていたはずです。時間が迫ってきたので、次に進みましょう。 とりあえず、今のうちに自分の勘違いしているポイントがないかを誰か見つけてください。こちらは「The Swift Programming Language」のランゲージガイドを確認します。
ランゲージガイドを見つけたらいいかなと思います。これがね、これがね、とか話してもしょうがないですね。自分のイメージと全然違う動きを見せているので、シンプルに動いたかな。まあいいや、せっかく時間がもったいないので、このあたりをもう少し調べて次回お話しします。確かにこうではない動きを見せたと思うんですけど。
じゃあ次、すごい歯切れの悪い話になりますけど、次にいきますね。あともう一つ伝えておきたいことがあります。これは確実なお話なので見ていきたいと思います。
didSet
でね、 willSet
でもいいんですけど、 print
とか何でもいいんですけど、処理をスタートするとします。この段階で値を初期化しても print
はされないんです。この段階では何も処理されずにインスタンスだけが作られて終わります。例えば print(v.a)
とやれば、それだけが表示されるような動きを見せます。
これで v
の a
に対して値を入れると didSet
が動くというのはお話しした通りですね。これも混乱しがちなポイントがあって、 didSet
が第二位に動いているんですけど、これを例えば値型として v = Value(a: ...)
そのままやった場合、この形だとそれほどでもないか。初期値を入れると didSet
になるんだっけ?初期値を設定しなければ後の確定初期化が didSet
が入ると使えなくなるという特徴があります。
これを構造体や型で持たせるとなると、確定初期化のほかにイニシャライザーで初期化をずらすことができます。このとき、 didSet
が発生しないというのが面白いところです。 Value
を初期化したときに didSet
が一度も動きません。この16行目の段階で a
をもう一回入れても didSet
は動きません。
別のイニシャライザーで self.init(...)
に対して呼び出して、ここで a
に入れられない初期化のカスタマイズとして実行しても didSet
は動きません。1, 2 だけで didSet
が呼ばれ、 a
が実行されます。
このように、イニシャライズフェーズの中では didSet
や willSet
は呼ばれないという特徴があります。このあたりを把握していないと、 didSet
がいつ呼ばれるのかがよくわからなくなります。16行目で呼ばれそうに思うかもしれませんが、このあたりが didSet
を扱う上での注意点です。
クラス継承をやったときに、また違った動きを見せます。イニシャライザーの中なのに didSet
が呼ばれることもありますが、それはまた別途お話しすることにします。
はい、では今日はこれで一旦終わりにしましょうかね。コメントで「ファイターアクセス周りの仕様が変わったかも」という情報をいただいたので、それについてはまた次回お話ししますね。
はい、ではこんなところで宿題を自分に残しつつ、今日はこれぐらいで終わりにしようと思います。お疲れ様でした。ありがとうございました。