引き続き、お気に入りな技術ブログ「その Swift コード、こう書き換えてみないか」を眺めていっています。その中で前回に話題に挙げた「get する時までには必ず set する」のところの lazy
がもう少し気になるところですので、今日はそこに着目して眺めてみようと思ってます。よろしくお願いしますね。
——————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #272
00:00 開始 00:37 今回は、lazy も安易に使わない方が良いのでは?という話 01:51 lazy var で初期化を遅らせる実例 06:43 ライフサイクルを踏まえて解決する案 11:30 環境に合わせた初期化の流れを考える 12:19 カスタマイズフェーズの活用 14:38 クラス継承でもカスタマイズフェーズは活用される 17:25 ライフサイクルを生かして暗黙アンラップを安全に使う 18:34 lazy はいつ初期化されるかわからない 19:05 制御の利かないコードはなるべく避ける 19:56 lazy の初期化式が使われない可能性 22:32 手に負えないものはなるべく使わない 24:33 lazy var が便利な場面は? 25:29 幽霊型による初期化の保証 33:13 lazy var も手段のひとつ 33:34 クロージングと次回の展望 ———————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #272
ブログを見るのが好きで、特にSwiftコードに関するブログを楽しんでいます。前回の勉強会で話題になった内容を振り返ると、プロパティの設定について重要なポイントがありました。すぐにオプショナルを使うのではなく、できるだけ get
を使い、非オプショナルにしましょうという話です。
インスタンスプロパティをオプショナルにしないというのは、後から初期化することが決まっている場合でも、安易にオプショナルを使用しないという話ですね。この場合、どうやって lazy
を使うべきかという問題が浮かび上がります。しかし、lazy
も安易に使うべきではないと感じます。もう少し意図をはっきりさせたいところですが、具体例を使って説明したいと思います。
lazy
は遅延初期化に使います。これは、初期化の段階で何かしらの条件が整っていない場合に有効です。例えば、プロパティとしてデータソースを持つ場合です。
var dataSource: SomeDataSource?
このデータソースは後から設定されることが多いです。UIKitを使用すると、ライフサイクル的に初期化ができない場合があります。このような時に lazy var
を使うと、値が設定されるタイミングで初期化されます。
lazy var x: Int = {
// データソースが設定されていればその値を使う
return self.dataSource?.value ?? 0
}()
しかし、この方式がいつも適切とは限りません。安易に lazy
を使わず、ライフサイクルをしっかり考えるべきです。例えば、ViewControllerの場合は通常 awakeFromNib
でプロパティの初期化を行います。
import UIKit
class ViewController: UIViewController {
var dataSource: SomeDataSource?
var x: Int = 0
override func awakeFromNib() {
super.awakeFromNib()
// データソースから値を設定
if let dataSource = dataSource {
self.x = dataSource.value
}
}
}
dataSource
が設定されていれば、x
の値も適切に設定されるようにすることで lazy
を使わずに済みます。このように、ライフサイクルを理解し、適切に初期化を行うことで、コードがより明確で安定することが多いです。 ここでは、強弱のオプショナルを使う方法について説明します。まず、データソース
からX
を取得します。オプショナルなので、適切なエラー処理も行いながら進めます。この際、リクワイアドイニシャライザー
が求められる場合がありますが、一般的にはその処理を以下のように行います。
required init() {
// 初期化処理
}
次に、強制アンラップではなく暗黙アンラップのオプショナルを使用する場面があります。これは参照する時点で値が入っていることを保証するものです。ただし、公式見解かどうかの確信はありませんが、仮に暗黙アンラップのオプショナルに代入しようとする場合、それが正しいアプローチか再検討する必要があります。
通常のオプショナルなら問題ありませんが、強制アンラップのオプショナル(!
)は扱いに注意が必要です。コードの一部を以下のように修正してみましょう。
var x: Int? // 通常のオプショナル
x = 5 // 値を代入
これにより、x
に値を代入する際、適切にnil
の可能性を考慮することができます。ただし、強制アンラップのオプショナル(Int!
)は、値が参照されるまでに必ず値が入っていることを前提としています。このため、nil
の可能性がある場合には使用しない方が良いでしょう。
例えば、以下のように定義した場合、
var x: Int! // 強制アンラップのオプショナル
x = 5 // 値を代入
エラーが出ないことを確認した後でも注意が必要です。この例では、x
に値が確実に入ることを保証する必要がありますが、もしnil
が入る可能性があるならば、強制アンラップのオプショナルを使うのは不適切です。
また、lazy
を使って初期化を遅延させる方法もありますが、この場合でも適切なエラーハンドリングが重要です。以下のように、lazy
を使って変数を初期化する方法もあります。
lazy var y: Int = {
return 10
}()
このように、初期化が必要なタイミングでのみ変数に値を代入することができます。しかし、やみくもにlazy
を使わないようにし、初期化が可能な限り イニシャライザー
で行うように心がけるべきです。
最後に、初期化フェーズとカスタマイズフェーズについても確認しておきます。初期化フェーズでは、可能な限り イニシャライザー
で初期化を行い、必要に応じてカスタマイズフェーズで追加の設定を行うようにします。場面によっては、初期化が難しい場合もありますが、その際は慎重に対応策を検討してください。 とりあえず、初期値を入れた後で何かをして、最後に完成するというアイデアも一つあります。一応これも可能ですが、これはlet
を使った場合にはうまくいかないんです。let
を使うと、カスタマイズフェーズが使えなくなるためです。しかし、var
を使えば後で変更が可能です。
次にビルドの過程についてです。もっと簡単な例に直してみます。この辺はあまり重要ではないので省略します。プロトコルやUIKit
についても今は省略しましょう。ビルドすると、エラーが出る場合もありますが、カスタマイズフェーズを活用することで、初期化を遅らせることなく、全体的な初期化フェーズは保てます。
クラスの場合、var a = 0
のように初期化することがよくあります。例えば、クラスAが初期化時に特定の値しか渡せない場合、クラスBを使うときにはその値を変更することが求められることがあります。この場合、親クラスの初期化後に、サブクラスであるBの初期化フェーズをカスタマイズすることになります。具体的には、親クラスのsuper.init
を呼び出した後に、インスタンスプロパティa
に特定の値(例えば-1)を設定するのです。
クラスBが独自のプロパティを持っている場合、それを初期化フェーズで設定しなければなりません。この初期化が終わると、カスタマイズフェーズに移行し、全体を調整していきます。こうして、イニシャライザーの初期化フェーズ全体が完了します。このプロセスを「オード対バン」として理解できます。
初期化ライフサイクルに従って、しっかりとイニシャライザーを定めたり、クラスのライフサイクルに合わせてprepare
メソッドなどを用意することで、より適切に初期化を行うことができます。これを確実に行うためには一定のルールが必要です。例えば、ビューコントローラーの初期化には特別な方法が必要になるかもしれません。
レイジー初期化もありますが、ここで重要なのは初めて参照されるタイミングです。レイジーな初期化は便利ですが、ライフサイクルを理解していないと、初めて参照されるタイミングが分からない問題も生じます。 例えば、ローチャーでプリペアしてから値が参照時に決まるような構造になっている場合、いつそのコードが呼ばれるかわからないためにデバッグが難しくなります。デバッグが難しくなるのは、特にLazyなプロパティの場合です。Lazyなプロパティは、初期化のタイミングが把握しづらいので問題が発生しがちです。APIを設計するという観点からも、デバッグが難しいコードは避けた方が良いです。
あらかじめライフサイクルが決まっているコードの方が把握しやすいため、予測できない動きを避けるべきです。Lazyなプロパティには、予測できない怖さがあるので、慎重に扱う必要があります。
例えば、バリューがインスタンス化されたときに、参照前に値を代入するということは、varではできます。しかし、このとき評価式が動かないという問題が生じるのです。具体例として、このx
が必ず必要なプロパティだった場合を考えてみましょう。必ず使うものとして、初期化を完了させるために以下のようにコードを書いたとします。
prepare()
return x
こういったコードで初期化を完了させる場合、さらに代入を行うとprepare
が呼ばれないはずです。実際に試してみます。例えば以下のコードです。
print("path_A")
print("value_x")
これでビルドして実行すると、10
が表示されます。これがなくなると、ビルドして実行したときにprepare
が呼ばれて初期化が進んでいない状況が確認できます。
もし初期化がprepare
がちゃんと終わっていないのに初期化が進んでしまうと、デバッグが非常に難しくなります。具体的なシンプルなケースだと回避できますが、複雑になると、Lazyなプロパティの使用が原因で必要な処理が動かないまま進んでしまうこともあります。このような場合に問題を見つけるのは大変です。
例えば他のプロパティがprepare
でしか初期化されない場合、prepare
が呼ばれていることが確実であることが確認できます。しかし、将来的な変更や仕様変更でその前提が崩れた場合、コード全体が機能しなくなります。
以上のことから、Lazyなプロパティは使い方をはかり、なるべく、自分の手に負えない場合は使わないようにするのが良いでしょう。 心構えとして「絶対にこれを守る」と言っておくのも良いかもしれませんが、現実的には「万全を期す」といった感じでしょうか。レイジーな初期化を使うことで、潜在的なリスクを防ぐためにプライベートセットなどを活用することも考えられます。これは、問題が発生する可能性を少しでも減らすための対策です。
レイジーな初期化が便利だったシチュエーションについて話をしました。個人的には、今までレイジーな初期化が「とても良かった」と感じたことはあまりありません。ただ、使用する場所を適切に考えれば、その価値を十分に発揮するでしょう。
例えば、初期化が必須なインスタンスプロパティをオプショナルにしないという手法は非常に良いです。また、特定の時点で特定のメソッド(例:「prepare」を呼ばなければならない時)には、バリュー型を使うのも一つの方法です。この場合、強制アンラップのオプショナルを使うことが有効かもしれません。しかし、値が入るまでに初期化されない場合があるため、このアプローチには注意が必要です。
型パラメータやプロトコルを使って、この問題を解決する方法も紹介しました。例えば、以下のようにプロトコルや enum
を使って状態を管理する手法です。
protocol BeforeInitializing {}
protocol Prepared {}
class Value<State> {
private var x: Int?
init() where State: BeforeInitializing {
// 初期化処理
}
convenience init(x: Int) where State: Prepared {
self.init()
self.x = x
}
var value: Int? {
guard let x = x else {
// エラーハンドリング
return nil
}
return x
}
}
enum MyState: BeforeInitializing, Prepared {
case beforeInitializing
case prepared
}
extension Value where State: Prepared {
func getValue() -> Int? {
return x
}
}
このようにすると、初期化状態を明確に管理できます。クラスや構造体がどの状態にあるのかを型として表現できるため、誤った使い方を防げます。
ただし、これはまだ試行錯誤が必要な段階の方法です。具体的には、デジグネイティブイニシャライザーとコンビニエンスイニシャライザーの使い方や、イニシャライザーの公開範囲をどのように保護するかなどを調整する必要があります。時間の関係で途中までの説明にはなりますが、こういった考え方をベースに工夫すると良いでしょう。 今回は、まず準備を整え、prepared
にして戻り値としてvalue
のprepared
を返す方法について説明します。このようにすると、初期化されていないvalue
がbeforeinitializing
のままになっているため、これをprepared
に変換する必要があります。
具体例を使って説明します。例えば、value = value.prepared
と設定すると、ここでx
が使えるようになるはずです。しかし、この処理ではx
を変換し、value.prepared
を返さなければならないことがあります。そのため、prepared
を正しく初期化する必要があります。
ここで振り返るのはそれほど難しくありません。unsafeBitCast
を使ってself
をto: self
にキャストします。これで問題なく動作するでしょう。unsafe
なダウンキャストを使用するように警告が出るかもしれませんが、この方法でビルドが正常に動作します。
このように、ジェネリックスを活用してインターフェイスを構築し、prepared
が終わったものだけに限定する方法もあります。この方法でうまく処理を行うことができます。
このような手段は状況に応じて最適なものを選ぶ必要があります。今回はこれで終了しますが、次回も引き続きこのあたりを見ていきましょう。お疲れ様でした。ありがとうございました。