JavaScript いつの間にかずいぶん違う言語になったなぁ、と思うけど、
— Takuo Kihira (@tkihira) June 6, 2019
for(let i = 0; i < 100; i++) { /.../ }
これはまだこう書くしかないのかな?const使えない?
このツイートを起点に、パフォーマンスの話が出て、紀平さんも計測されていたんですが自分でも思うところがあって計測して考察してみました。
実測前の僕の予想(というか過去の経験)は
- 普通のforが最速
- for-inは速度以前に使ってはいけない
- for-ofとforEachは関数呼び出しがループごとに挟まるのでどちらも遅いが同じ水準
- for ofは言語標準なので最適化が行われる期待!
でした。さて、結果はいかに?
それぞれのループの解説
伝統的なfor
伝統的なループが一番軽いというのはみんなが認めるところです。というのは、ループごとに新たなスコープ(名前空間)が作られることがないからですね。
他の手法は何かしらの関数呼び出しが入ります。関数を実行するということはスタックを操作し、名前空間ができ、変数の巻き上げが行なわれたりもろもろ発生します。
そういうペナルティがないところが強みです。一方、ループ変数が必要だったり書き方がダルい(個人の感想です)とか、ループ変数を書き間違って終わらないループを作ってデバッグに苦労したことが過去にあってイヤだ(個人の感想です)とか、気分的に敬遠されがちです。
for-in
そもそも、配列の要素を舐めるための機能ではないので、使うべきではないというのは置いといて、これは配列オブジェクトのプロトタイプまで探しに行っちゃったりと余計なことまでするあたりが速度的に期待できないポイントですね。
まあ、かつては辞書型のように使えるデータ構造がオブジェクトしかなく、それの列挙はこれしかなかったので仕方ない点はありますが今どきは選択の俎上に挙げる必要もないでしょう。
forEach
AirBnBのコーディング規約ではこれを推奨しており、eslintの設定済みのconfigが手に入るということでそれなりに人気のある選択肢ですね。JSで関数型にかぶれたスカした人が支持しているイメージ(個人の感想です)がありますが、実装上は毎回関数が実行されるので、パフォーマンス上は期待できない方法です。
for-of
ESの規格にあるイテレータプロトコルを使ったループです。出自とか実装はPythonっぽい感じがします。
このfor ofをサポートするオブジェクトは、array[Symbol.iterator]()
という感じでメソッドを呼び出すと、イテレータオブジェクトが帰ってきます。これにはnext()というメソッドがあります。ループごとにこれを呼ぶと、イテレータリザルトオブジェクトが帰ってきます。valueで要素が、doneにループが終了したかどうかのフラグが入っています。
まあ、関数呼び出しはあるし、新しいオブジェクトは発生するしで速度的には遅そうに思うかもしれませんが、このあたりの処理は処理系内部でネイティブコードで処理されるはずなので、最適化が進めば遅さは解消されることが期待されます。
Babel/TypeScript
今どきは、ES2018とかの最新機能を使っても、ES5/2015相当に変換して書き出すというのが一般的なウェブのJSの実態です。
Babelのサイトで変換してみたfor ofのコードの結果がこれです。イテレータプロトコルなのに、配列だと分かっているからか、配列専用コードになっていますね。TypeScriptの方も同じ結果になります。
for (var _i = 0, _array = array; _i < _array.length; _i++) {
var value = _array[_i];
sum += value;
}
おいおい、そんなはずはないだろ、前に見たお前はもっと素直なコードにしていただろ?ということで即時実行関数で型をごまかしてみました。
(function(array) {
let sum = 0;
for (const value of array) {
sum += value;
}
})(array)
その結果がこれです。仕様通りの素直なコードが出てきました。なお、TypeScriptはここまでやっても最適化されたコードが出てきたので、静的型付け言語は最適化に強いですね。
(function (array) {
var sum = 0;
var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;
try {
for (var _iterator = array[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
var value = _step.value;
sum += value;
}
} catch (err) {
_didIteratorError = true;
_iteratorError = err;
} finally {
try {
if (!_iteratorNormalCompletion && _iterator.return != null) {
_iterator.return();
}
} finally {
if (_didIteratorError) {
throw _iteratorError;
}
}
}
})(array);
結果
計測コードは以下のところにあります。
実際の結果を各ブラウザとかごとに集計したのがこれです。伝統的なforを100%とした相対値にしています。forEachは、通常の関数、1行のアロー関数、複数行のアロー関数(returnが自動でされないので少し早いかもと期待)の3通りやってみました。macOS版EdgeのDeveloper Preview版もやってみましたが、Chromeとほぼ同じだったので割愛します。
for | for-in | forEach(1) | forEach(2) | forEach(3) | for-of | for-of (Babel/TS) | for-of (Babel) | |
---|---|---|---|---|---|---|---|---|
Chrome x86 | 100% | 0.6% | 7.0% | 7.0% | 6.8% | 64.7% | 113% | 27.5% |
Chrome ARM | 100% | 1.1% | 8.6% | 8.8% | 8.9% | 9.6% | 99.5% | 9.0% |
Safari x86 | 100% | 0.2% | 10.7% | 10.7% | 10.9% | 1.4% | 120.8% | 9.2% |
Safari ARM | 100% | 0.3% | 15.2% | 15.2% | 15.3% | 2.0% | 54.5% | 11.8% |
Firefox | 100% | 0.2% | 7.5% | 7.2% | 7.4% | 9.5% | 105.6% | 2.5% |
for inが圧倒的に遅いですね。for ofはPC版のChromeはなかなか良い結果が出ています。しかし、スマホ(Huawei P20 Lite)とFirefoxではforEachとだいたい同じ(気持ち早い)になりました。超予想外だったのがSafariで、for of惨敗です。
最適化後のBabelおよびTypeScriptの変換結果は伝統的なforと遜色ない、場合によっては何故か早い結果となりました。
考察
ChromeはARM版の速度がforEachと同等だったので、本来のV8のエンジンの処理型の速度はこの水準だったのではと思われます。PC版Chromeが速いのはアーキテクチャ固有のJIT実装が効いたんじゃないかと思います。
for ofのSafariの結果はショッキングですね。これはコントリビュートのチャンスでは?というぐらい。
そして一番多くの人をがっかりさせるかもなのがBabel/TypeScriptの結果ですね。EdgeがIEを吸収してくれるのでPCブラウザがみんなモダンになるし、Google botのバージョンアップも発表されて、Babelとか捨てられる(or 出力ターゲットバージョンを上げられる)じゃんと期待で夢を膨らませていた人は多いと思いますが、まだまだ最適化が進んでなくて、枯れた最適化されたコードへの変換が有効な場面があるということです。まあ、ループだけじゃないので、そこまで大げさに考える必要もないかもですが。
今後もブラウザの最適化には期待しています。