[Swift] その配列の足し算は本当に必要ですか? / chainのススメ
Swiftではしばしば、配列を2つ繋げて処理したいことがあります。例えば、以下のような場合です。
let array1 = getArrayFromSource1()
let array2 = getArrayFromSource2()
for item in array1 {
doSomething(with: item)
}
for item in array2 {
doSomething(with: item)
}
このような場合、array1 + array2
に対してループを書くことで、コードがスッキリします。
// コードがスッキリ!
for item in array1 + array2 {
doSomething(with: item)
}
これで良くなったように見えるのですが、実際には問題があります。
配列の足し算は重い
配列の生成は重たい操作です。というのも、要素数分のメモリを確保して書き込む必要があるからです。このため、特に大きな配列の足し算は非常に重たい操作になります。
そこで、上記のように配列を足し算してしまうと、愚直にループを2回書くのに比べて低速になってしまう、ということが起こります。
// 実はこう書いた方が高速
for item in array1 {
doSomething(with: item)
}
for item in array2 {
doSomething(with: item)
}
綺麗に書くだけのために足し算すると、熱ばかり無意味に生み出すことになり、あまり良くありません。では、こういう時はどうするのが良いのでしょうか。
chain()
一般に、for
ループに必要なのはSequence
への適合です。Sequence
はイテレータの実装を必要としますが、配列を作り直す必要は本来ありません。
もしも「最初にarray1
のイテレータを実行し、それがnil
に到達したらarray2
のイテレータを実行する」ことができれば、配列の足し算は不要です。
これをやってくれるのがSwift Algorithmsのchain()
です。
import Algorithms
for item in chain(array1, array2) {
doSomething(with: item)
}
chain
は2つのS1: Sequence, S2: Sequence
を合成して、1つのChain2Sequence<S1, S2>
型の値を返してくれます。Chain2Sequence<S1, S2>
はSequence
に適合しているので、上のようにfor
ループで使うことができます。
イメージとしては、並列に繋ぐのがzip
なら直列に繋ぐのがchain
です。
chain
では余分な領域を確保することがないため、ループを高速に実行することができます。
chain
をプロジェクトに導入する
chain
はSwiftの標準ライブラリには含まれておらず、AppleのOSSであるSwift Algorithmsをインポートして使う必要があります。
導入方法は一般的なSwift Packageと変わりませんが、細かい手順が知りたい場合はこちらの記事などが参考になりそうです。
chain
のパフォーマンスを測定する
chain
のパフォーマンスを測定するため、簡単なテストを入れたパッケージを用意しました。クローンしてコマンド一発なのでぜひお試しください。
ここでは、比較的小さな配列を足し算した場合とchain
を用いて結合した場合でパフォーマンスを比較します。
// 2つの配列を足し算して用いる場合
func testTake2ArrayAdditionPerformance() throws {
measure {
let a = Array(0 ..< 100)
let b = Array(100 ..< 200)
for _ in 0..<1000000 {
takeSequence(a + b)
}
}
}
// 2つの配列をchainで結合して用いる場合
func testTake2ArrayAdditionPerformance() throws {
measure {
let a = Array(0 ..< 100)
let b = Array(100 ..< 200)
for _ in 0..<1000000 {
takeSequence(chain(a + b))
}
}
}
takeSequence
の実装は次のようになっていて、単純にループを回して軽微な操作を行っています。
func takeSequence(_ sequence: some Sequence<Int>) {
for i in sequence {
_ = i + 1
}
}
まず、2つの配列を結合する場合です。
* testTake2ArrayAdditionPerformance
measured [Time, seconds] average: 0.227, relative standard deviation: 3.636%, values: [
0.232912,
0.233811,
0.224064,
0.219582,
0.211700,
0.234103,
0.215850,
0.237818,
0.230090,
0.226787
]
* testTake2ArrayChainPerformance
measured [Time, seconds] average: 0.0000233, relative standard deviation: 94.828%, values: [
0.000089,
0.000023,
0.000016,
0.000015,
0.000015,
0.000015,
0.000015,
0.000015,
0.000015,
0.000015
]
約9700倍高速でした。思ったより効いてきますね。
では、chain
のパフォーマンスと、単にループを複数並べて実行する場合のパフォーマンスを比べるとどうなるでしょうか。次のようなテストを行ってみます。
func testTake2ArrayPerformance() throws {
measure {
let a = Array(0 ..< 100)
let b = Array(100 ..< 200)
for _ in 0..<1000000 {
takeSequence(a)
takeSequence(b)
}
}
}
結果は以下のようになりました。
* testTake2ArrayPerformance
measured [Time, seconds] average: 0.0000262, relative standard deviation: 94.323%, values: [
0.000097,
0.000022,
0.000016,
0.000015,
0.000015,
0.000039,
0.000016,
0.000014,
0.000014,
0.000014
]
なんと、chain
を使った場合とほとんど変わりません。chain
を使うことで、コードのシンプルさを犠牲にせず、良いパフォーマンスを得られることがわかります。
次に、3つの配列を結合する場合です。Chain3Sequence
は存在しないので、Chain2Sequence<Chain2Sequence<A, B>, C>
みたいな型になります。
* testTake3ArrayAdditionPerformance
measured [Time, seconds] average: 0.574, relative standard deviation: 6.311%, values: [
0.571852,
0.609838,
0.535366,
0.533511,
0.517794,
0.587197,
0.644619,
0.573711,
0.593577,
0.573296
]
* testTake3ArrayChainPerformance
measured [Time, seconds] average: 0.0000257, relative standard deviation: 77.464%, values: [
0.000085,
0.000024,
0.000020,
0.000019,
0.000019,
0.000018,
0.000018,
0.000018,
0.000018,
0.000018
]
こちらも約22000倍高速です。面白いように効きますね。
ところが、実は4つ以上の配列になるとchain
のパフォーマンスが急激に悪化します。
* testTake4ArrayAdditionPerformance
measured [Time, seconds] average: 0.919, relative standard deviation: 6.813%, values: [
0.890148,
0.869612,
0.909080,
0.890426,
0.892907,
1.090301,
0.968163,
0.916662,
0.882951,
0.882945
]
* testTake4ArrayChainPerformance_b
measured [Time, seconds] average: 0.276, relative standard deviation: 4.387%, values: [
0.286580,
0.273148,
0.266839,
0.295965,
0.270032,
0.276245,
0.264713,
0.295149,
0.265702,
0.261002
]
この場合、パフォーマンスの向上は3.3倍にとどまります。これでも十分良いのですが、3つまでの結果がほとんどゼロに近い値だったことを考えるとやや不思議な結果です。実際、単に4回ループを書く形なら先ほどと同様ほとんどゼロに近い値になるので、chain
が4つ以上の配列の結合を苦手とする様子が伺えます。
逆転こそしないものの、5個の配列の和になるとパフォーマンスの向上は約2倍まで小さくなります。
まとめ
この記事では配列の足し算の代わりにchain()
を利用することをオススメしました。2、3個の配列の足し算においては劇的にパフォーマンスが向上します。4個以上の場合のパフォーマンス向上の恩恵が小さくなるものの、それでも十分にパフォーマンスが向上しています。もちろん、通常の利用では2、3個での性能向上の時点で十分有用だと思います。
標準ライブラリに入っていてほしいレベルの使い勝手なので、ぜひぜひお試しください。
Discussion