はじめに
本内容は具体例の紹介のために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の値が
Int
とBool
の組み合わせだから -
combineLatest
がstatic関数だから -
A && B
とB && A
のようなオペランド位置の違い
ということで上記のものは一見怪しそうに見えますが無罪です。
そして気になる答えは...
リテラル(Literal)
で評価をしているからです。
これがもっともコンパイル時間に寄与しています。1
リテラルとは10
やtrue
やnil
などを指します。
let isOk = false // `false is a boolean literal`
let num = 10 // `10` is a integer literal
この場合の$0.1 == false
のfalse
もリテラルです。
勘違いされがちなケースとしてはtrue
やfalse
は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.1
がBool型
と判定後に、すぐに評価が行えます。
次に、偽の評価をしたいときに使われる!
(論理反転演算子)はBool型のstatic関数です。
prefix public static func !(a: Bool) -> Bool
いずれの場合もBool型
と判定された場合にはそれ以上、型推論やプロトコル準拠を確認する必要がないということになりコンパイル時間が短くなったと考えられます。
このような点を考慮するとBool型
に関してはリーダビリティもあまり変わらないため一貫して論理値で評価するのが良いと思います。
テストしてみる
コンパイル時間を長くしてしまうケースと今回の解決策でどれくらいトータルのビルド時間が変わるか試してみました。
例で出したcombineLatest
だけが特別というわけでもなくメソッドチェーンなどにある何気ないfilter
やmap
も対象になります。
型推論される式でリテラルを多用する
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秒
型情報のある評価値で評価する
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秒
論理値だけで評価する
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秒
この場合で言えば論理値で評価するのとリテラルで評価するのではコンパイル時間に6500倍
の差が出ます。(極端な例ではありますが)
まとめ
これらの内容はRxSwiftに限った内容ではないですが、RxSwiftを利用してコーディングしてるときには、こんな些細なことでコンパイル時間が変わるんだ程度に覚えといていただけたら幸いです。
ジェネリクスなども使用しないケースでは実際a == false
も!a
も気になる程コンパイル時間に差はありませんので、特に好きな方で記述していいと思います(!a
とした方がわずかに早くなりますが)。ですが、プロジェクトなどでコーディング規約などとなると、こういったケースが存在するとなるとif a == true {}
などの書き方はif a {}
で統一しておくのが無難かと思います。
塵も積もればコーヒータイムとなってしまいます。特にRxSwift
を導入しているプロジェクトなどでは一度ビルド時間の調査を行ってみてこのようなケースに陥ってないか確認してみるのもいいかもしれません。
最後まで見ていただき、ありがとうございました。
参考:
- Swift language documentation: Literal
- BuildTimeAnalyzer-for-Xcode
- XCodeコンパイル時間短縮
- 型推論でビルドに時間がかかっている場合の解決法
- リテラルと型の話
- RxSwift
- Improve compile time in RxSwift
-
他にも
&&
などで評価を連結した場合や配列を+
で連結した場合にその連結数によって指数的にコンパイル時間が長くなります。 ↩