最近から 自動で強制アンラップされるオプショナル
について眺めてみてますけれど、そんな前回に話題にのぼった「初期化を遅らせる」ための方法の話。話題の中心であるオプショナルの他にも、挙げてもらった lazy
と クロージャー
を併用する方法とか、他にもいくつかありそうな気がして、それぞれに特色があったりしそう。そんな辺りを眺めてみるのも良さそうなので、今回はそこから始めていってみることにしますね。
今回もゆめみ社外に向けた参加者公募がされまして、ゆめみの外の人も幾名か来てくださっての開催になります。
—————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #175
00:00 開始 00:32 今回の展望 01:23 初期化を遅らせる可能性のある場面 03:04 @IBOutlet に見る遅延初期化 04:43 デリゲート利用時に見る遅延初期化 07:37 初期化フェーズで self を他のプロパティーに設定できる理由 08:29 初期化を遅らせる方法は、他に何があるだろう 09:06 lazy var とクロージャーを併用するのは? 09:59 クロージャー内の self が評価されるタイミングは? 13:04 lazy var は初期化のタイミングと呼出回数に注意 14:27 lazy var とクロージャーの併用は妥当? 15:39 このサンプルコードが論理的に間違っている様子 19:18 明示アンラップと暗黙アンラップとでの主張の違い 19:57 プログラマーが実行順序を制御できることが重要 25:47 lazy var はスレッドセーフ? 26:58 lazy var を気軽には使いづらい 27:15 lazy var の中で self は使える様子 28:43 Objective-C クラスでは self が定義されている 29:14 構造体は再帰的に保持できない 31:22 lazy var でも didSet の併用が可能に 32:06 lazy var における初期値の評価タイミング 33:57 以前はもっと複雑な動きを見せていた気がする 35:42 lazy var って使ってる? 36:54 タイミングを図れる限りは lazy var は避けるのをオススメ 38:24 lazy var での値の反映が遅れる場面 39:53 今回のまとめ 41:44 クロージング ——————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #175
では、始めていきますね。今日はオプショナルについて特に暗黙的に自動アンラップされるオプショナルの話をしていきます。前々回から見てきましたが、その中でもまずはこれについて見ていきます。
この自動アンラップのオプショナルは、初期化がイニシャライズフェーズではできないけれど、少し遅れて初期化をしたいときに使う機能です。例えば、AGBARの話が出てきました。この遅らせるというのは、初期化を遅らせる方法がいくつかあるというところで、それぞれについて、まず見ていきたいと思います。
では、プレイグラウンドで見ていきます。初期化を遅らせる手段として、自分自身がデリゲートを持つときの方法があります。たとえば、NSViewController
クラスで、タイトルラベルNSTextField
を初期化する場合がありますね。ストーリーボードを使ったときには、イニシャライザーの時点ではNSTextField
を渡すことができません。
ここで強制アンラップのオプショナルを使い、初期化が終わった段階で設定を行います。たとえば、ビューが読み込まれたときにタイトルラベルのテキストを初期のタイトルにセットする場合です。これはデリゲートパターンの話と絡めていろいろとやってみます。
たとえば、クラスとしてプロトコルを定義しておきます。デリゲートオブジェクトに対して初期化を行う場合に、初期化の遅延について考えます。例えば、NSViewController
がNSTableViewDelegate
に準拠する状況において、イニシャライザー内でデリゲートにself
を渡す形です。
class MyViewController: NSViewController, NSTableViewDelegate {
var delegate: NSTableViewDelegate!
override init() {
super.init()
self.delegate = self
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.delegate = self
}
}
しかし、この場合には、デリゲートを初期化したいけれども、自分自身がまだ初期化されていないため、エラーが発生します。そこで、オプショナルを使って初期化を行います。例えば以下のようにします。
class MyViewController: NSViewController, NSTableViewDelegate {
var delegate: NSTableViewDelegate?
override init() {
super.init()
self.delegate = self
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.delegate = self
}
}
これで、すべての保存型プロパティの初期化が終わり、super.init
が呼ばれて全体が初期化されてから、デリゲートにself
を渡すことができます。これが初期化を遅らせないといけないパターンの一つです。
他にも初期化を遅らせる方法として、lazy var
を使う方法があります。以下のようにできます。
class MyViewController: NSViewController, NSTableViewDelegate {
lazy var delegate: NSTableViewDelegate = self
override init() {
super.init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
}
これにより、delegate
プロパティは初めて参照されたときに初期化されます。この方法であれば、初期化のタイミングが予測できるようになります。例えば、実際にデリゲートを使うときに初期化されるので、初期化の順序に関する問題を避けることができます。
ただし、lazy var
の初期化には注意が必要です。例えば、以下のようにコードを実行すると、delegate
が初期化されるタイミングがわかりやすいと思います。
class MyViewController: NSViewController, NSTableViewDelegate {
lazy var delegate: NSTableViewDelegate = {
print("Delegate initialized")
return self
}()
override init() {
super.init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
func someAction() {
print("Action started")
print(delegate) // ここで初めてdelegateが初期化される
print("Action ended")
}
}
let myVC = MyViewController()
myVC.someAction()
このコードでは、someAction
メソッドを呼び出すときに初めてdelegate
が初期化されます。Action started
とDelegate initialized
、そしてAction ended
の順に出力されます。
lazy var
による初期化の遅延は便利ですが、初期化タイミングをしっかりコントロールすることが重要です。また、その使用場面によって適切な初期化方法を選ぶことが大切です。 もちろん、与えていただいたテキストを適切に修正して、自然な文章に整えます。必要に応じて句読点も正しく挿入し、読みやすい文体にします。いつでもテキストを提供してください。 なので、初期化を遅延する際には、使用するまでには初期化されているという意味合いで、強制アンラップを使用します。この場合、遅延保存(lazy var
)と強制アンラップ(自動アンラップされるオプショナル)の大きな違いは、プログラマーがデリゲートに代入したときに初めて初期化が完了する点です。明示的に初期化フェーズが終了した後に、延長された初期化が行われます。コード中で明示的にどこかしらで初期化が行われており、初めて初期化が完了するという感じです。そのため、プログラマーが初期化のタイミングを完全に制御できる状況が保たれています。したがって、遅延保存を使うよりもまずは強制アンラップを検討するほうが良いでしょう。
遅延保存(lazy var
)の怖いところは、初めて使用されるまで初期化が遅延される点です。例えば、ユーザーがボタンを押すまでその変数は初期化されない可能性があります。また、タイマーによってある時点でやっと動作する場合や、マルチスレッドで動作している場合には、いつ初期化が実行されるのか予測がつきません。このような状況では、初期化がどのタイミングで行われるのかが不明確になります。
一目瞭然かもしれませんが、デリゲートがどこで設定されているのかがコードの中で明確に示されるのは、以下の部分だけです。デリゲートが遅延保存で初期化されていると記述されていますが、これが実際にいつ使われるのかわかりづらい状況になります。実際の初期化のタイミングは、コントローラーのアクションが初めて呼ばれたときのタイミングとなります。 こういったところを辿っていかないと分からないアクションがある場合、単純な例だと理解しやすいですが、そうでない場合は非常に難しいものになります。それだけプログラムの難度が高くなっています。ですので、個人的にはレイジーバー(遅延評価された変数)は極力使わないほうがいいのではないかと思います。
もう一つの例ですが、さっきオブザーバーを代入したことによって、レイジーバーの初期化が走っていなかったですよね。コントローラーにオブザーバーを入れた際も同じことが言えます。パスが表示されていないのがその証拠です。つまり、レイジーバーはこの初期値をパスで表示していないのです。このように初期値は設定されていても、他の初期値が代入されると、この初期値の式が動かないことがあります。これも問題ですね。そういった可能性を意識した上でレイジーバーを使わないと、万全を期すためにはプライベートセットなどを使用して外部からのセッターを保護する必要があります。このような手間をかけて初めて初期値の代入が保証されますので、初期化を遅らせるためだけにレイジーバーを使うのは避けたほうが良いです。
さらに、他のコメントで、初期化と同時に別のメソッドを呼んでいてそのメソッドにデリゲートを使っている場合、無限ループになる可能性があるという話がありました。そのタイミングについては完全には理解できないですが、デリゲートに値を設定する場面とレイジーバーに値を設定する場面でバッティングが起こる可能性があります。
そして、レイジーバーがスレッドセーフなのかどうかについても考慮が必要です。私が調べた限り、スレッドセーフではないようです。スタックオーバーフローにもそのように書かれていました。特に、スタティックバーは途中からスレッドセーフになったと記憶しています。レイジーバーはスレッドセーフでないため、使い方によっては厄介な問題が発生しやすいです。
Swiftプラスに関してですが、レイジーバーの中でセルフを使うこともできるようです。例えば、次のようなコードはコンパイルが通ります。
class MyClass {
lazy var lazyVar: MyClass = {
return self
}()
}
これにより、レイジーバーのクロージャー内でセルフを使うことは昔はできなかったりしましたが、今はできるようになっています。ですので、スタティックバーの場合は注意が必要です。例えば、次のコードは動作しません。
class MyClass {
static var myStaticVar: MyClass = {
return self
}()
}
このように、レイジーバーやスタティックバーの使用には十分な注意が必要です。安易に使わず、慎重に設計することが大切です。 なんかオブジェクトキャブのときは「セルフ」っていうメソッドがありましたが、NSオブジェクトの中に定義されていて、これによってうまくいく動きがあったと思ったんですが、忘れてしまいました。ともあれ、ストラクトでは let
だとできないんです。ストラクトはそもそも自分自身を入れるのが苦手なんですよね。ストラクトが自分自身を持つとメモリーサイズが決まらないからエラーになってしまいます。そのため、このときには強制アンラップのオプショナルとかを使うことになるかなと思います。
ストラクトはしばしば自分自身を持つのが難しいとされています。たとえば、インダイレクトで対応する方法もあります。これは一部の抜け道のようなもので、オプショナルではサイズが決まらないために使えないことが多いです。しかし、ストラクトはよくできているので、少し工夫が必要かもしれません。
また、前回の話にあった @$
に対する didSet
も、昔はできなかったんですが、今はできるようになりました。Swift
クラスのプロパティ変更タイミングに応じて、例えば以下のように設定することができます。
class Example {
var myVar: Int = 0 {
didSet {
print("myVar updated to \\(myVar)")
}
}
}
このコードで、myVar
に新しい値を代入したときに didSet
が呼ばれ、値が更新されたことを通知してくれます。
一方で、willSet
を使う場合、現在の値がどのように動作するのかを確認することができます。次のコードスニペットでは、willSet
を使用して現在の値を表示します。
class Example {
var myVar: Int = 0 {
willSet {
print("myVar will be set to \\(newValue)")
}
didSet {
print("myVar updated to \\(myVar)")
}
}
}
これにより、willSet
の段階で新しい値 newValue
にアクセスでき、didSet
の段階でプロパティが更新されたことを確認できます。
過去のバージョンでは、初期化フェーズという概念がやや不明瞭だったため、タイミングによっては意図した動作をしないことがありました。しかし、現在では初期化フェーズが必ず完了してから didSet
が動作するようになっているため、このような問題は少なくなっています。
実際、初期化式が動かない可能性があるポイントを正確に把握しておけば、Swift
のプロパティ監視機能をより有効に使えます。なので、初期化関連の挙動には注意が必要ですが、それさえ覚えておけば問題は少ないでしょう。 使っている方は、まずレイジバー(lazy var
)を積極的に使っているという状況によって、こういう状況であればレイジバーは良いというのを知っている方がいれば、ぜひ教えてほしいです。自分はレイジバーを最初の頃は好んで使っていたのですが、それだとタイミングが測れなくなってしまいました。できる限り初期化のタイミングをコードで明示できるのが第一ですね。それを達成するために使えるのが共同アナウンスのオプションになるかと思います。
その次に考えるのがレイジバーです。レイジバーの出番は今言ったような状況ではないのですが、昔はまったこともあります。そういう恐怖心が出たり、時々うまく伝わらなかったりします。正しいほうがいいと感じますが、お勧めではありますね。
次に、UIViewController
が表示される前に初期化したい場合、具体的にどうなるか考えます。UIKitを使っている場合、やはりawakeFromNib
とかがいいのではないでしょうか。フレームワークによってライフサイクルが決まっていて、初期化のタイミングも前もって整備されている場合が多いです。例えば、初めてラベルに触った時に初期化を走らせるよりは、IBOutlet
を使っておいて、そのタイミングで初期化するほうが良いと思います。
エントリーポイントがないような場合、レイジバーが役立つこともあります。タイミングが測れないときは、来たときに対応するしかないので、そういう場合にはレイジバーが確かに役立ちますね。
2017年のPlaygroundの例を見てみましょう。マネージャーがあって、データ管理をして、セルフイントデータを返して、データインクリメントでプリントして、インクリメントがコンプリーションハンドラで立ち上げたものをコンプリーションで返すという動作です。これをレイジバーで試してみると、先の値が表示されることが分かります。ライトバック的な動きですね。
今時点でレイジバーについて真剣に取り組んでみると、新しい使い方が見つかるかもしれませんが、昔の苦い経験があるので慎重にやっていくべきです。確定初期化について紹介しようと思いましたが、それは次の機会にすることにします。
オプショナル、直接代入、確定初期化、プロパティバーなどの初期化方法についても触れましたが、プロパティバーの初期値を事前に設定しておいて、後でゆっくり初期化するという手法もあります。プロパティバーの前に考えるべきこともありますが、最後にどうしようもないときや、参照されたときに初期化したい場合はレイジバーを考えても良いでしょう。
こんな感じで時間となりましたので、今日の勉強会はこれで終わりにします。お疲れ様でした。ありがとうございました。