qsonaのブログ

プログラマーです。

Function#length と、そのlodashにおける使用例

JavaScriptにおいて、関数の中でarguments.lengthは良く目にする/使うと思うのですが、関数fn自体のfn.lengthを利用することはまれではないでしょうか。
(これ以降fnは全て、定義された関数を表すことにします)

async.angelFallを社内に布教しようと思ったのですが、そういえば前のエントリで言及しかけていたので、どうせならfn.lengthの話題とまとめてブログに書こうと思った次第。

fn.lengthとは

Function.length - JavaScript | MDN

arguments.lengthが関数が実際に呼ばれた時の引数の個数なのに対して、fn.lengthはその関数fnが定義されたときの仮引数の個数を表します。

var fn = function(a, b, c) {};
fn.length // 3

Function#bindで第2引数以降を渡すといわゆるカリー化ができますが、きちんとこのlengthの値も変わったりします。頭良いですね。

var fn = function(a, b, c) {};
// 引数aに1を部分適用した新しい関数を作る
var fn_curried = fn.bind(null, 1); // b, cの2つの引数をとる関数になっている
fn_curried.length // 2

使用例

コイツの使用例をいくつか紹介したいと思います。

lodash

まずは大御所lodashにおける使用例です。関連するissueはコチラ。(読まなくても下で全部説明しますが、読んで分かったら多分このエントリにそれ以上のことは無いです。)

https://github.com/lodash/lodash/issues/944

https://github.com/lodash/lodash/issues/997

さて、この問題の背景としていくつか説明する必要があります。

iterateeに渡る引数

まず、lodashのeachやmapなど(多数)のメソッドに第2引数として渡す、関数(lodashではiterateeと呼ばれています)は、次のように引数を3つ取ることが出来ます。

var arr = ['a', 'b', 'c'];
_.map(arr, function(value, index, array) {
  // value: 配列の値。 順に 'a'/'b'/'c' が入る
  // index: 配列のindex。 順に 0/1/2が入る
  // array: 配列全体。 ここでは毎度 ['a', 'b', 'c'] が入ってくる
});

これはJavaScriptArray#forEachなどと同じ仕様です。

lodashのメソッドチェーン

次に、lodashにはメソッドチェーンをする書き方があります。例としてはこんな感じ。(少し煮え切らない例ですが)

var arr = [1, 2, 3, 4, 5];
var result = _(arr) // _.chain(arr)でも同じ
  .map(function(value, index, array) {
    return value * value; // 2乗
  })
  .filter(function(value, index, array) {
    return value % 2 === 1; // 奇数
  })
  .take(2) // 配列の最初から2つを取得
  .value(); // 実際の結果を取得

result // [1, 9]

lodash v2までは、単に順番に _.map, _.filter, _.takeを適用していくだけでした。すなわち、以下と基本同じです。

var arr = [1, 2, 3, 4, 5];
var mapped = _.map(arr, function(value, index, array) {
  return value * value;
});
var filtered = _.filter(mapped, function(value, index, array) {
  return value % 2 === 1;
});
var result = _.take(filtered, 2);

result // [1, 9]

lodashの遅延評価

しかしlodash v3で遅延評価(lazy evaluation)が登場したことで少し話は変わります。

上記の例でいうと、配列をまずmapして[1, 4, 9, 16, 25]を得て、その後filterして[1, 9, 25]を得たうえで、takeで頭2つを取って[1, 9]を得ています。しかしながら、最初から「頭2つを取る」とわかっていれば、全て計算しなくとも、map・filterを手前からしていって[1, 9]まで得られた時点で打ち切っても良いことになります。

メソッドチェーンの方法で書けば、文として切れ目がないので、必ずしも[1, 4, 9, 16, 25]とか[1, 9, 25]という結果を得る必要はないわけです。そこで、メソッドチェーンの書き方をされた時に、可能であれば遅延評価をするような実装がなされました。

一方、後者の例ではmappedやfilteredという変数に一旦代入しているため、当然結果を計算する必要があります。(残念ながら、JSのエンジンレベルの遅延評価はありませんから・・)。これがlodash v3で実装された遅延評価の優位性です。

ちなみに、遅延評価時の実行の流れは大体こんな感じです。map=>filter=>takeの時点では、内部的に状態を持つだけで、計算はしません。そして、value()が実行されたタイミングで、以下のような手順で計算されます。

  1. arr[0]である 1 をmap(2乗)する。1
  2. 結果の1 にfilter(奇数)の関数をかける。 true
  3. trueなので、結果の配列にpushする。[1]。※take 2なのでまだおわらない。
  4. arr[1]である 2 をmapする。 4
  5. 結果の4 にfilterの関数をかける。 false
  6. falseなのでなにもしない。
  7. arr[2]である 3 をmapする。 9
  8. 結果の9 にfilterの関数をかける。 true
  9. trueなので、結果の配列にpushする。[1, 9]。 take 2なのでここで終了。[1, 9]を返す。

これにより、頭3つ分しか計算せずにすんでいます。

普通は1回ずつ配列を生成して次に進むのに対して、
遅延評価が効く場合は、配列の要素1つずつがchainを通り抜けていくイメージですね。

さて、これで背景の説明は終わりです。すでにお気づきでしょうか?

遅延評価で生じた、iteratee第2・第3引数の問題

map=>filter=>takeの真ん中、 filter に渡したiterateeの第3引数arrayに注目してみます。

本来ならばここには、直前でmapした結果である [1, 4, 9, 16, 25] が入るのが正しいはずです。 しかし、上の遅延評価の動作を見れば分かるように、mapした全体の結果 [1, 4, 9, 16, 25] は作っていません。ということは、第3引数のarrayには正しい値が渡ってこないことになります。(作ってないものは渡しようがない!)

普段はこの第3引数などまず使いませんが、_.uniqを遅延評価に対応させるPRを送ろうと実装をしていてたまたまこのことに気付き、issueを立てました。(拙い英語ですが)

その後、もう片方のissueで、第2引数のindexも正しさが保証されないことがわかりました(より重要な問題)。

fn.lengthを用いて修正される

これに対して、jdalton 氏が機転の利いた対応を行いました。

上の例では、遅延評価してしまうと、そもそもiterateeに第2引数のindexや第3引数のarrayを正しく渡すことができないのです。しかし、ほとんどの用途では第1引数のvalueしかとらず、そのときは問題ない。第2引数以降を渡さなければいけないケースだけ、遅延評価をとりやめれば良いのです。すなわち、iterateeの仮引数の数を見れば良い。それがこのコミットです。

Disable lazy optimizations if the iteratee has more than one param. [… · lodash/lodash@b872bc9 · GitHub

iteratee.length > 1のとき、isLazyfalseになっているのがわかります。

これを見た当時、fn.lengthはこうやって使えるのかと、心の底から感心しました。自分がissueを立てた時点ではこの方法は全く思いつかなかったです。

以上、lodashにおけるfn.lengthの利用例の紹介でした。(長かった。)

fn.lengthを利用する潜在的な問題

次の2つのコードの違いはなんでしょうか。

var fn = function(a) { return a; }
var fn = function() { return arguments[0]; }

答えはもちろん「fn.lengthの値が違う」です。(他にもfn.toString()の結果が違うなどありますが)

fn.lengthはあくまで仮引数の個数なのですが、argumentsを使えば、仮引数として宣言されていなくてもその引数にアクセスすることは可能です。
lodashの例でいけば、仮引数を宣言せずにiteratee内で第2・第3引数にarguments[1] arguments[2]などとアクセスした場合には結局、期待された値が入らないことがあります。

この問題はfn.lengthを使うと常に潜在してしまいます。

最後に

普通にプログラミングしてても絶対使わないようなものも、ライブラリ書くときにはたまに出くわすよね、という良い例だと思います。実際のプロジェクトでも、基盤的な機能の整備をするときには色々知っておくと便利そうです。
あと、lodash は結構面白いなと思います。

async.angelFallの話を書こうとしてたはずなんですがね。次で書く。