Swiftのfilter
やmap
を使うとやりたいことを宣言的に書けるのでコードを書くのも楽ですしメンテナンス性もよくなる場面が多いですね。ただ、filterやmapを連続で呼び出して実行時間的に問題がないのかよくわかっていなかったので、実行時間も気にしたベストプラクティスを調べてみました。
lazy
というやつで遅延評価できるらしいですが、これがどのように実行時間に影響してくるのかも調べてみました。
計測環境
私はiOSのアプリ開発のためにSwiftを使うことが多いので、iPhone 6sにて実行速度を計測しました。(もちろんデバッグビルドではなくReleaseビルドで)
計測プログラムは下記のリポジトリで公開しています。
結論
自前でfor
やforEach
するのが最速です。
でもうまくlazy
を使えば自前ループに比べても大きな遜色はなさそうです。
計測内容の説明
計測に使ったデータ
どこかのJSONからユーザーデータをとってきたような雰囲気で、下記のようなデータを5000件
処理するような前提にしました。
struct UserData {
init(userId: Int) {
self.userId = userId
}
var userId: Int
var userName: String = "rikusouda"
var userProfileIconURL = "https://pbs.twimg.com/profile_images/842040493827534848/YBx-Bbdy_400x400.jpg"
var userProfile = "I am iOS application developer"
}
let sourceArray = [Int](0..<5000).map { UserData(userId: $0) }
行った計測
mapやfilterの処理の重さがあまり影響しないように何も変換しないmap
とuserIdが偶数だけにするfilter
をするようにしました。
元の配列から下記のようなものを求めるケースで計測しました。
- 結果を新しいArrayにするケース
- 結果のuserId合計を求めるケース
結果を新しいArrayにするケース
下記のようなパターンでどれが最速なのか計測しました
// 下記のようなクラス内で計測します
class PerformenceTester {
let sourceArray = [Int](0..<5000).map { UserData(userId: $0) }
// mapで使用する関数
private static func mapFunction(_ data: UserData) -> UserData {
return data
}
}
// [ケース1] そのままmapとfilter
let newArray = sourceArray
.map(PerformenceTester.mapFunction)
.filter { ($0.userId % 2) == 0 }
// [ケース2] lazyにしてからmapとfilter。それをArrayに変換
let newArray = Array(sourceArray
.lazy
.filter { ($0.userId % 2) == 0 }
.map(PerformenceTester.mapFunction)
)
// [ケース3] lazyにしてからmapとfilter。それをreduceでArrayにする
let newArray = sourceArray
.lazy
.filter { ($0.userId % 2) == 0 }
.map(PerformenceTester.mapFunction)
.reduce(into: [UserData]()) { (result: inout [UserData], data: UserData) in
result.append(data)
}
// [ケース4] forでArrayを作る
var newArray = [UserData]()
for value in sourceArray {
if value.userId % 2 == 0 {
newArray.append(PerformenceTester.mapFunction(value))
}
}
計測結果
ケース | 実行時間(ミリ秒) |
---|---|
ケース1 | 1.01 |
ケース2 | 0.66 |
ケース3 | 0.75 |
ケース4 | 0.70 |
もっとチェーンをつないだときの計測のためにmapの回数を3回にして計測してみました。
計測結果(map3回する版)
ケース | 実行時間(ミリ秒) |
---|---|
ケース1 | 1.73 |
ケース2 | 3.53 |
ケース3 | 0.74 |
ケース4 | 0.70 |
なぜかケース2がめちゃくちゃ遅い。Arrayのイニシャライザでなにか非効率的なことでもやっているのかもしれません。これを勘案すると、Arrayのイニシャライザで新しいArrayを作るのはおすすめできないと思いました。
読みやすさも考慮すると ケース3
(lazyで処理してreduceでArrayを作る)が一番良さそうです。
結果のuserId合計を求めるケース
下記のようなパターンでどれが最速なのか計測しました
// 下記のようなクラス内で計測します
class PerformenceTester {
let sourceArray = [Int](0..<5000).map { UserData(userId: $0) }
// mapで使用する関数
private static func mapFunction(_ data: UserData) -> UserData {
return data
}
}
// [ケース1] そのままmapとfilterして合計算出
let result = sourceArray
.map(PerformenceTester.mapFunction)
.filter { ($0.userId % 2) == 0 }
.reduce(0, { (result, val) -> Int in
return result + val.userId
})
// [ケース2] lazyにしてからmapとfilter。それから合計算出
let result = Array(sourceArray
.lazy
.filter { ($0.userId % 2) == 0 }
.map(PerformenceTester.mapFunction)
.reduce(0, { (result, val) -> Int in
return result + val.userId
})
)
// [ケース3] forで合計算出
var result = 0
for value in sourceArray {
if value.userId % 2 == 0 {
result +=
PerformenceTester.mapFunction(
PerformenceTester.mapFunction(
PerformenceTester.mapFunction(value)
)
).userId
}
}
計測結果
ケース | 実行時間(ミリ秒) |
---|---|
ケース1 | 1.13 |
ケース2 | 0.092 |
ケース3 | 0.079 |
もっとチェーンをつないだときの計測のためにmapの回数を3回にして計測してみました。
計測結果(map3回する版)
ケース | 実行時間(ミリ秒) |
---|---|
ケース1 | 1.87 |
ケース2 | 0.092 |
ケース3 | 0.080 |
こちらも、自前のループが最速でしたがlazyにしてから処理してreduceなどの終端処理で終わる場合はほとんど遜色ありませんでした。
思ったこと
filter
やmap
など、今回のようなケースだと1ミリ秒とかの話なのであまりアプリの使い勝手に影響する場面は少ないのかもしれません。
ですがlazy
にしてから処理してreduce
などの終端処理で終わるように書くだけで実行速度は格段に速くなる(自前でforを書いたときに近くなる)ので、少し気にして普段から書くようにするとよいと思います。
余談ですが、Arrayにするときに使っているreduceですがSwift4で追加された機能です。これはresultのArrayがinoutなの要素ごとにコピーすることなくappendで値を追加していくことができるので高速です。
おまけ: lazyについて調べてみた
lazyが何をしているのか気になって調べてみました。
下記のドキュメントを参考にしました。
ここを見ると、scan
という新しいmap的なメソッドをlazy対応する例が載っています。実際にLazyScanIterator.next()
が呼び出されるまで、元sequenceの要素を実際に取得しないようになっています。実際に要素にアクセスされたときに、その要素を計算するための関数が実行されていくようなイメージだと思います。