LoginSignup
69

More than 5 years have passed since last update.

NO MORE ビルド時間泥棒 ☕️❌【RxSwift編】

Last updated at Posted at 2017-06-25

はじめに

本内容は具体例の紹介のためにRxSwift編とありますがシチュエーションによってはSwift全体で言えることかもしれません。本内容で紹介する内容の効果はケースによりかなり差がでますので、そのケースの見極めのためのTIPSになります。

RxSwiftとは

RxSwiftではObservableに抽象化されたオブジェクトに対して宣言的な記述をすることで、その関係性と処理を決めることができます。

一方で内部的な抽象化の表現のためにジェネリクスなどが多く用いられており、メソッドチェーンを多用します。そのため、書き方次第ではコンパイル時間が100倍違うケースも存在します。

TL;DR

  • a == false!aの書き方の違いでコンパイル時間が数千倍変わるケースがある。

  • オペレータ内でBoolの評価は論理値のみで評価する

.filter { $0.isOk == false } // Bad
.filter { !$0.isOk } // Good
.filter { $0.isOk } // Good
  • 上記のように評価できない型(Int, Double, ...)は評価値を用意する
let limit: Double = 10
...
// $0がDouble型で渡ってくる場合
.filter { $0 <= 10 } // Bad
.filter { $0 <= limit } // Good

なぜこう書いた方がいいのか?

まずは例を出しながらコンパイル時間が遅くなってしまうケースの紹介をしたいと思います。

例としてよく使う、かつ、内部でジェネリクスを多用しているObservable.combineLatestで事例を紹介します。

combineLatestはどちらかの値が変更されたときに両方の値の最新をまとめて送出するオペレータで、かなり実用性が高いため頻繁に使用されます。

import UIKit
import RxSwift

class ViewController: UIViewController {

    let oi = Variable<Int>(0)
    let ob = Variable<Bool>(false)
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        // どちらかに変更があった時にoiが0、かつ、obがfalseの時に処理をしたい
        Observable.combineLatest(oi.asObservable(), ob.asObservable()) { $0.0 == 0 && $0.1 == false  }
            .subscribe(onNext: {
                print($0)
            })
            .disposed(by: disposeBag)
    }

}

一見どこにでもありそうなコードですがコンパイルしてみるとviewDidLoad内だけで2331.3msかかります。

Observableの型は指定してないですが5行のコードとは思えないコンパイル時間です。

このパターンの解決方法は以下の方法です。

import UIKit
import RxSwift

class ViewController: UIViewController {

    let oi = Variable<Int>(0)
    let ob = Variable<Bool>(false)
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        Observable.combineLatest(oi.asObservable(), ob.asObservable()) { $0.0 == 0 && !$0.1 }
            .subscribe(onNext: {
                print($0)
            })
            .disposed(by: disposeBag)
    }

}

コンパイル時間: 49.7ms

$0.1 == false!$0.1にするだけでコンパイル時間が47倍も違います。

なぜこんなにもコンパイル時間に差が生じるのか?

しかし、そのまえにまずこの例においてコンパイル時間にほぼ関係ない要因を列挙します。

  • 省略引数($0)を使用しているから
  • Variableの値がIntBoolの組み合わせだから
  • combineLatestがstatic関数だから
  • A && BB && Aのようなオペランド位置の違い

ということで上記のものは一見怪しそうに見えますが無罪です。

そして気になる答えは...

リテラル(Literal)で評価をしているからです。
これがもっともコンパイル時間に寄与しています。1

リテラルとは10truenilなどを指します。

let isOk = false // `false is a boolean literal`
let num = 10 // `10` is a integer literal

この場合の$0.1 == falsefalseもリテラルです。
勘違いされがちなケースとしてはtruefalseはBool型であるという認識です。
また同様に10などもInt型ではありません。
正しい認識としてはfalseはBool型として受けれられるということです。
またlet isOk = falseのように型情報が明記されない場合にはデフォルトでBool型に解釈されるというだけです。

つまり何が言いたいのか?

  • リテラルは型情報をもっていない
  • リテラルを型にはめるにはコンパイラがコンテキスト(文脈)を読む必要がある

ここまでくると$0.1== false!$0.1でコンパイル時間が異なる理由がわかります。

combineLatestを例にすると、まず宣言は以下のようになっています。

public static func combineLatest<O1: ObservableType, O2: ObservableType>
        (_ source1: O1, _ source2: O2, resultSelector: @escaping (O1.E, O2.E) throws -> E)
            -> Observable<E> 

$0.1 == falseが遅い理由

まず$0.1 == falseは$0で使用されるジェネリクスのObservableTypeの解釈から始まりassociatedtypeであるEの型が判明されるまでfalseが何型なのか決定することはできません。
$0.1の型が決定してからはじめてfalseは変換イニシャライザを通してBool型になることで比較(==)が可能となります。
よって$0.1が判明したあとに再度falseの型推論が待ち受けているということになり、これがコンパイル時間を長くする要因だと考えられます。

!$0.1が早い理由

一方!$0.1での評価はどうでしょうか?
まず、真の評価をしたいときには$0.1だけを記述することで$0.1Bool型と判定後に、すぐに評価が行えます。
次に、偽の評価をしたいときに使われる!(論理反転演算子)はBool型のstatic関数です。

prefix public static func !(a: Bool) -> Bool

いずれの場合もBool型と判定された場合にはそれ以上、型推論やプロトコル準拠を確認する必要がないということになりコンパイル時間が短くなったと考えられます。

このような点を考慮するとBool型に関してはリーダビリティもあまり変わらないため一貫して論理値で評価するのが良いと思います。

テストしてみる

コンパイル時間を長くしてしまうケースと今回の解決策でどれくらいトータルのビルド時間が変わるか試してみました。
例で出したcombineLatestだけが特別というわけでもなくメソッドチェーンなどにある何気ないfiltermapも対象になります。

型推論される式でリテラルを多用する

Observable.combineLatest(oi.asObservable(), ob.asObservable()) { $0 }
            .filter { $0.1 == true }
            .filter { $0.1 == true }
            .filter { $0.1 == true }
            .filter { $0.1 == true }
            .filter { $0.1 == true }
            .subscribe(onNext: {
                print($0)
            })
            .disposed(by: disposeBag)

199958.1ms -> 3分10秒 :scream:

型情報のある評価値で評価する

let isOk = true
Observable.combineLatest(oi.asObservable(), ob.asObservable()) { $0 }
            .filter { $0.1 == isOk }
            .filter { $0.1 == isOk }
            .filter { $0.1 == isOk }
            .filter { $0.1 == isOk }
            .filter { $0.1 == isOk }
            .subscribe(onNext: {
                print($0)
            })
            .disposed(by: disposeBag)

46395.5ms -> 46秒 :fearful:

論理値だけで評価する

Observable.combineLatest(oi.asObservable(), ob.asObservable()) { $0 }
            .filter { $0.1 }
            .filter { $0.1 }
            .filter { $0.1 }
            .filter { $0.1 }
            .filter { $0.1 }
            .subscribe(onNext: {
                print($0)
            })
            .disposed(by: disposeBag)

29.1ms -> 0.029秒 :sunglasses:

この場合で言えば論理値で評価するのとリテラルで評価するのではコンパイル時間に6500倍の差が出ます。(極端な例ではありますが)

まとめ

これらの内容はRxSwiftに限った内容ではないですが、RxSwiftを利用してコーディングしてるときには、こんな些細なことでコンパイル時間が変わるんだ程度に覚えといていただけたら幸いです。

ジェネリクスなども使用しないケースでは実際a == false!aも気になる程コンパイル時間に差はありませんので、特に好きな方で記述していいと思います(!aとした方がわずかに早くなりますが)。ですが、プロジェクトなどでコーディング規約などとなると、こういったケースが存在するとなるとif a == true {}などの書き方はif a {}で統一しておくのが無難かと思います。

塵も積もればコーヒータイムとなってしまいます。特にRxSwiftを導入しているプロジェクトなどでは一度ビルド時間の調査を行ってみてこのようなケースに陥ってないか確認してみるのもいいかもしれません。

最後まで見ていただき、ありがとうございました。

参考:


  1. 他にも&&などで評価を連結した場合や配列を+で連結した場合にその連結数によって指数的にコンパイル時間が長くなります。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
69