はじめに
本内容は具体例の紹介のために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
-
他にも
&&などで評価を連結した場合や配列を+で連結した場合にその連結数によって指数的にコンパイル時間が長くなります。 ↩