JavaScript

何故for文は許されるのか?反省会会場

祖父母記事: なぜfor文は禁止なのか?ポエム版
親記事: でもやっぱりfor文は使っていいと思うよ

だって遅いんだもん

こんなに遅いとは思いませんでした。
まさか1/6の速度しか出ないだなんて軽くショックです。

折角良いサイトhttp://jsben.chを教えてもらったので速度改善を目指して色々試行錯誤してみましょう。

この記事(反省会)では、速度も意識したメソッドチェーンのあり方を考えていきます。

  • メソッドチェーンはチューニングでどの程度速くなるのか
  • それによる可読性はどの程度影響するのか
  • 結局JSerはどう生きていけば良いのか

配列を生成するイディオムの速度比較

JSは配列作るのが下手くそですが、
メソッドチェーンや関数型プログラミング的な事をしたければ、
まずは速い数列(配列)を作るイディオムが必要です。

とりあえず4パターン用意してみました。

// length指定: 別の所で見たイディオム、シンプルでかっこいい
Array.from({length: 100}, (_, i) => i)

// 0埋め: 私が使っているイディオム
Array(100).fill(0).map((_, i) => i)

// keysルート: for文禁止の人が使っていたイディオム
Array.from(Array(100).keys())

// for: 命令型で関数作れば最速じゃね?
const range = (s, e) => {
  const arr = []
  for (let i = s; i <= e; i++) arr.push(i)
  return arr
}
range(0, 99)

Node.jsなら直感for文でrange関数作るロジックですね。
必要になったらconst range = require('./range.js')という風に呼び出して使えば良いだけの話ですからね。

そう言えば、数値を代入する箇所でi|0みたいに縦棒0を記入すると、
JITコンパイラが効いて多少速くなるとかなんとか…
後はインクリメントのi++も遅いみたいなんで普通に代入することにします。

for文禁止の人のイディオムは代入する所が無いので、
残った3箇所にこのチューニング入れて速度を測ってみましょう!

折角のチューニングテストなので要素数100から10,000に増やして…出来ました。
http://jsben.ch/J2hK3

結果は下記のようになりました。
大方の予想どおりfor文とpushを使った手法が最速で、意外にも0埋めをしたロジックが食い下がるという結果になっています。

スクリーンショット 2018-04-12 8.53.49.png

mapを使ったオーバーヘッドが大体こんなもんらしいので、
0埋めルートは1行で書けるイディオムとしては有力なようです。
(因みに0の代わりにnullvoid 0を突っ込むと僅かに遅くなりました、0が良さそうです)

聞いてきたチューニング方法は……そこまで影響しないようですね。
特に悪くはありませんが、100個程度では誤差ということはわかりました。

Array.fromを使った2つのコードは不甲斐ない結果になってしまいましたね。
中の実装や動き方を見たわけではありませんが、どうやらArray.fromは配列っぽいもの全てをリッチな動作で変換してくれる手厚さが逆に速度面で悪影響のようです。

{length: 100}のイディオムは一見キレイですが、
JSはlengthというプロパティがあるオブジェクト全てを配列的な存在とみなす慣習を使ってゴリ押ししたもののようです。

Array(100).keys()も同様で、
不完全な配列をArray.fromに突っ込んで無理やり生成しているようです。
みなさん、Array.fromの乱用には気をつけて下さい。

0から100未満のうち、2の倍数の累計を求めるコードのチューニング

私のポエム記事は実は釣り…というほどでもないんですが、
ALGOLで書いた方はコーディング規約的にちょっとキツめの書き方をしました。

あの命題はちょっとズルすれば別に命令型でも6行で収まります。
ついでにifや三項演算子でより分けずに2の倍数を最初から出力するようにすれば1行で収まります。
(現場によってはコーディング規約的にアウトになるかもしれませんが)

const main = () => {
  let sum = 0
  for (let i = 0; i < 99; i = (i + 2)|0) sum = (sum + i)|0
  return sum
}
console.log(main())

従って、ポエム記事ではかなり煽ったものの、
命令型でも工夫次第で状態変数やコード量をかなり抑えられます。
まともなエンジニアなら綺麗に書く事も十分可能です。
(一段階抽象化すれば良いだけの話ですからね)

さて、メソッドチェーン側もチューニングしますか。
メソッドチェーン側はarr.filter(_ => true)みたいなコードを書いて一切絞り込もうとしなかった場合でもシャローコピーは確実に行います。
途中で処理を止めて結果を変数にキャッシュしたりしたいですからね。

その辺の時間でどうしても不利になるのはしょうがありません。
まぁ、あまり良くないですが、reduceが三項演算子を書くようにしたり、再帰関数で書くようにしたり色々と手段はあります。
(JSの再帰関数は数千でコールスタックオーバーフローのエラーを引き起こします)

チューニング案は下記です。

// reduceで三項演算子を使う
const main = () =>
  Array(100).fill(0).map((_, i) => i)
    .reduce((a, b) => b % 2 === 0 ? a + b : a, 0)
main() // 2450

// 配列を1段飛ばしで宣言
const main = () =>
  Array(50).fill(0).map((_, i) => i * 2)
    .reduce((a, b) => a + b, 0)
main() // 2450

// 再帰関数を使う
const main = (it, end) => {
  if (it >= end) return 0
  return it % 2 === 0 ? it + main(it + 2, end) : main(it + 1, end)
}
main(0, 100) // 2450

因みに再起は一見複雑ですが、漸化式のように何時完了するかを1行目に書いておけば意外と分かりやすく簡単に書けるんですよ。

コードを書いてURL出力…http://jsben.ch/u4Zt1
結果はご覧の通り!

スクリーンショット 2018-04-12 2.01.47.png

へー、意外とfilterの手順を省いても変わらないものなのですね。
一発目の配列生成がかなりのダメージになっているようですね。

やはり処理速度に着目した場合、
メソッドチェーン側に配列を作らせる所からやらせるとかなり不利ということが分かります。

次回予告

RDBMSやAjaxで取ってきた最初から配列のパースならメソッドチェーン側有利になるかもしれないですね。
次回はその辺を追っていきたいと思います。