大規模リファクタリングで痛感したSwiftのOptionalとの正しい付き合い方

f:id:vasilyjp:20180927112657j:plain

iOSアプリチームの@hiragramです。

最近、ファーストリリース時からあった画面の大規模なリファクタリングを担当しました。 コードは遅かれ早かれ賞味期限が切れて少しずつ腐っていくものですが、その賞味期限を少しでも伸ばすために、普段コードを書く時にSwiftのOptionalについて意識していることを記事にします。

f:id:vasilyjp:20180202153329j:plain

「とりあえずOptional」をやめる

SwiftのOptionalは便利ですが、「Optionalを使えば、nilを安全に扱えて良い」と捉えてしまうと、気づくとモデルのプロパティがOptionalだらけになっていて使う側で毎回アンラップをしなくてはいけないような状況に必ずなります。

そうではなく、「Optionalの存在のおかげで、非Optionalなところにnilが絶対入ってこないことが保証されて良い」と捉えるべきだと思っています。 nilに口なしといいますが、Optionalなプロパティにnilが入っている時、そのnilは初期値がセットされる前の未初期化値なのか、データを提供しないことをユーザーが明示的に選択した空値なのか、はたまたエラーによって本来の値が得られなかったnilなのかを判断することはできません。 データ構造の中にnilが生まれそうになった時、それは何か設計が間違っているかもしれません。 nilは無くせるなら無い方がいいのです。

例えば、ViewControllerのあるプロパティはviewDidLoadで値がセットされるとすると、ViewControllerのイニシャライズ時点では値が無いため、安易にOptionalにすることができます。

class ViewController: UIViewController {
    var hogehoge: String?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        hogehoge = createHogehoge()
    }
    
    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated: animated)
        
        if let hogehoge = self.hogehoge {
            // アンラップしないといけない
        } else {
            // 値が取れなかったらどうする??????????
        }
    }
}

しかし、「初期値を与えるのを遅延させたい」というのを実現するために、それ以降の読み出しでいちいちguardif letを使ってアンラップする必要がでてきます。 「初期値が与えられる前にその値を参照することはそもそも間違っていて、かつ初期値が与えられて以降はnilに戻ることはない」というデータ構造なのであれば、以下のようなテクニックが使えます。

// 初期値が与えられる前に参照されたらアプリをクラッシュさせる
func uninitialized<T>() -> T { fatalError() }

class ViewController: UIViewController {
    lazy var hogehoge: String = uninitialized()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        hogehoge = createHogehoge()
    }
    
    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated: animated)
        
        hogehoge // ここでアンラップする必要がない
    }
}

こうすることにより、プロパティがOptionalだった時に考えなければいけなかった「値が取れなかった時にどうするか」を考える必要がなくなります。

エラーやデータ不整合を握りつぶさない、回復不能ならアプリを落とすことも検討

上述のように、Optionalを多用してしまうと「型はOptionalだけど、実装上ここがnilになることは無いはずだから、アンラップできなかった時はreturnでいいや」みたいな判断をしがちです。 しかし、その後の改修や仕様変更でnilになるシーンが出てきた時に、以下のような問題を生む可能性があります。

  • アンラップで失敗を握りつぶされてしまった結果、データの不整合に気がつかない
  • 動いてほしいメソッドがguardで落ちて実際の処理をしてくれない

私は「実装上ここがnilになることは無いはずなら、もし将来nilが入ってきた時に必ず対応する必要がある」と考え、必ずアンラップできるはずのOptional変数がnilだった場合、fatalError()preconditionFailure()で明示的にアプリをクラッシュさせることを検討します(!でアンラップするのと変わらなくなってしまいますが、それは治安が悪いのでやりたくありませんよね)。

「アプリがクラッシュしない」というのは大事なことですが、「アプリ内のデータや状態が不整合を起こしていてもなんとなく動き続けてしまう」よりは潔くクラッシュしたほうが健全だと思います。 クラッシュすればクラッシュログが溜まって気づけますが、本番環境で壊れながらのらりくらり動き続けるアプリはそもそも問題として気づかれないことが多く、ついに崩壊してユーザーの目に明らかな壊れ方をした時には、すでにその状態から遡ってバグの原因を突き止めるのは非常に困難なはずです。

まとめ

以上2点は、Swiftを書く上で意識したいOptionalの本質であると私は考えます。 「クラッシュしないこと」がいちばん大事なのではなく、「正しく動き続けること」が大事なのです。 Optionalにすることでクラッシュしないアプリを手軽に作ることができるようになりましたが、それは実は本質的でなく、将来の耐改修性を損ねている可能性があるということを常に頭にいれて、上手にOptionalと付き合っていきたいものです。


VASILYではiOSエンジニアを募集しています。 この記事に共感してもらえるような、Swiftの機能の本質を考えて最良の設計ができるエンジニアと一緒に働きたいです。 ご応募お待ちしております。

https://www.wantedly.com/projects/88978www.wantedly.com

カテゴリー