FORCIA CUBEフォルシアの情報を多面的に発信するブログ

Underscore.jsのソースコードを読んでみた

2021.12.22

アドベントカレンダー2021 エンジニア テクノロジー

これは、FORCIA Advent Calendar 2021の22日目の記事です。

こんにちは。アドベントカレンダー22日目の記事を担当させて頂きます、エンジニアの澤田です。

普段の業務でJavaScriptでプログラムを書くことが多いですが、その際によく使用するJavaScriptのライブラリにUnderscore.jsがあります。 なぜUnderscore.jsを多用するのかというと、フォルシアで開発している主な機能の1つに検索機能があることが関係しています。 検索機能では、データベースに対してSQLを実行し、その結果をHTMLのテンプレートに当てはめていく、ということをよく行います。 SQLの実行結果は、構造化されていないデータの配列であることが多いため、HTMLテンプレートに適用しやすくするためにデータを組み替えて親子関係を持たせたり、複数のSQL実行結果を組み合わせたりします。 その際のデータ処理にUnderscore.jsを使うことが多いのです。 (ただ、最近のブラウザなどのJavaScript実行環境では、Underscore.jsの様々な関数がネイティブでサポートされているので、Underscore.jsを使用する範囲は徐々に少なくなりそうです)

Underscore.jsの特徴として、関数型プログラミングのような書き方ができる、という点があります。 関数型プログラミングでは、ある関数に値を渡して得られた値を別の関数に渡す、ということを繰り返していく書き方をしますが、そうした処理を行うための関数型言語にあるような便利な関数がUnderscore.jsにはたくさん用意されています。 例えば以下のような関数があります。

  • map: 配列(※)と関数を渡して、配列の各要素に対して関数を適用した配列を返す
  • filter: 配列と真偽値を返す関数を渡して、配列の各要素の内、関数に適用した結果trueになるものの配列を返す
  • reduce(foldl): 配列と関数と初期値を渡して、以下の順に処理を繰り返し、最終的に得られた値を返す(「折り畳み」と言うこともあります)
    • 初期値と配列の最初の要素を関数に渡して得られた値
    • 上記の値と配列の次の要素を関数に渡して得られた値
    • 上記の値と配列の次の要素を関数に渡して得られた値
    • ...
    • 上記の値と配列の最後の要素を関数に渡して得られた値
  • chain: 上記のmapやfilter等、Underscore.jsがサポートしている関数を複数組み合わせて適用した結果を返す

※配列ではなく連想配列オブジェクトを渡すこともできます。

Underscore.jsを使うと関数型プログラミングのような書き方ができるとして、Underscore.jsの内部ではどのように関数が定義されているのでしょうか? それが気になって、今回Underscore.jsのソースコードを読んでみることにしました。 それでは早速見ていきましょう!

なお、Underscore.jsのバージョンは1.3.1を使用し、ソースコードはUMDのDevelopment版(GitHubリポジトリ上のソースコードはこちら)を使用しています。

map関数

まず、自分がよく使うmap関数を見ていきたいと思います! ソースコードには以下のように定義されています。

  // Return the results of applying the iteratee to each element.
  function map(obj, iteratee, context) {
    iteratee = cb(iteratee, context);
    var _keys = !isArrayLike(obj) && keys(obj),
        length = (_keys || obj).length,
        results = Array(length);
    for (var index = 0; index < length; index++) {
      var currentKey = _keys ? _keys[index] : index;
      results[index] = iteratee(obj[currentKey], currentKey, obj);
    }
    return results;
  }

ここでmap関数が受け取る引数は、それぞれ以下のような意味になっています。

  • obj: 関数を適用する配列(または連想配列オブジェクト)
  • iteratee: objの各要素に適用する関数
  • context: thisの値を置き換えたい場合に渡す(自分はあまり使いません)

最初にiteratee = cb(iteratee, context);と書かれていて、cbというWrapper関数を通していますが、基本的には渡したiterateeがそのまま返ります。 _keys には、引数の obj が連想配列オブジェクトの場合に、キーの配列が入りますが、通常の配列の場合は値が設定されません。 var currentKey = _keys ? _keys[index] : index;のところで、_keysに値が設定されているかどうかで、_keysの要素をキーに使うのか、配列のインデックスをキーに使うのかが分かれています。 引数のobjが、配列と連想配列オブジェクトのどちらでも受け取れるようになっているのはこの工夫があるからですね! あとは、_keysまたは配列の要素数だけループを回して、一時変数として定義されている配列resultsに関数を適用して得られた値を順番に格納していき、最後にそのresultsを返す形になっています。かなりシンプルな定義ですね!

reduce関数

次にreduce関数を見てみましょう! ソースコードには以下のように定義されています。

  // **Reduce** builds up a single result from a list of values, aka `inject`,
  // or `foldl`.
  var reduce = createReduce(1);

おっと、、実際の中身はcreateReduce関数に定義されているようなので、そちらを見ていきます。

  // Internal helper to create a reducing function, iterating left or right.
  function createReduce(dir) {
    // Wrap code that reassigns argument variables in a separate function than
    // the one that accesses `arguments.length` to avoid a perf hit. (#1991)
    var reducer = function(obj, iteratee, memo, initial) {
      var _keys = !isArrayLike(obj) && keys(obj),
          length = (_keys || obj).length,
          index = dir > 0 ? 0 : length - 1;
      if (!initial) {
        memo = obj[_keys ? _keys[index] : index];
        index += dir;
      }
      for (; index >= 0 && index < length; index += dir) {
        var currentKey = _keys ? _keys[index] : index;
        memo = iteratee(memo, obj[currentKey], currentKey, obj);
      }
      return memo;
    };

    return function(obj, iteratee, memo, context) {
      var initial = arguments.length >= 3;
      return reducer(obj, optimizeCb(iteratee, context, 4), memo, initial);
    };
  }

ここでcreateReduce関数は以下の関数を返しているので、reduce関数が受け取る引数はobjiterateememocontextの4つであることが分かります。

    return function(obj, iteratee, memo, context) {
      var initial = arguments.length >= 3;
      return reducer(obj, optimizeCb(iteratee, context, 4), memo, initial);
    };

引数のobjiterateecontextは先ほどのmap関数と同じですが、新しく出てきた引数memoで初期値を受け取っています。 memoを渡せば、initialtrueになり、contextを渡さなければoptimizeCb(iteratee, context , 4)iterateeをそのまま返すので、 この場合、createReduce関数内で定義されているreducer関数は以下と同等になります。(ここで、createReduce(1)dirの値には1を渡しているので、indexの初期値は0と定義できます)

    var reducer = function(obj, iteratee, memo) {
      var _keys = !isArrayLike(obj) && keys(obj),
          length = (_keys || obj).length;
      for (var index = 0; index < length; index++) {
        var currentKey = _keys ? _keys[index] : index;
        memo = iteratee(memo, obj[currentKey], currentKey, obj);
      }
      return memo;
    };

先ほどのmap関数と似ていますね! 違いはと言えば、resultsの部分がmemoに置き換わっている点です。 map関数の場合はresultsに関数を適用した結果を格納していきますが、reduce関数の場合は、要素を順番に辿りながら、memoとその要素をiteratee関数に渡して得られた結果をmemoに再代入し、最後にそのmemoを返す形になっています。 こちらもシンプルな定義ですね!

また、dirを受け取るcreateReduce関数を定義することで、要素を最後から前に向かって逆向きに折り畳む、reduceRight関数も以下のように簡単に定義できています。

  // The right-associative version of reduce, also known as `foldr`.
  var reduceRight = createReduce(-1);

リファクタリングの観点からも、素晴らしい抽象化ですね!

chain関数

最後に、Underscore.jsの関数を複数組み合わせて適用できる、chain関数を見てみたいと思います! ソースコードには以下のように定義されています。

  // Start chaining a wrapped Underscore object.
  function chain(obj) {
    var instance = _$1(obj);
    instance._chain = true;
    return instance;
  }

おや、、chain関数は_$1(obj)というインスタンスを取得し、インタンス変数_chaintrueを設定して返しているようです。 _$1という謎めいたクラスが出てきました。これは何でしょう? ソースコードを見ると以下のように定義されています。

  // If Underscore is called as a function, it returns a wrapped object that can
  // be used OO-style. This wrapper holds altered versions of all functions added
  // through `_.mixin`. Wrapped objects may be chained.
  function _$1(obj) {
    if (obj instanceof _$1) return obj;
    if (!(this instanceof _$1)) return new _$1(obj);
    this._wrapped = obj;
  }

引数のobj_$1クラスのインスタンスであることはあまり無いと思うので、以下の2行を見ればよさそうです。

    if (!(this instanceof _$1)) return new _$1(obj);
    this._wrapped = obj;

ここで、thisnewキーワードを付けて_$1関数が呼ばれた場合に_$1クラスのインスタンスになるので、逆にnewを付けずに呼ばれた場合はthis instanceof _$1falseになります。 するとnewを付けずに呼ばれた場合は!(this instanceof _$1)trueになり、newを付けて再度呼ばれます。 そのため、newを付けても付けなくても_$1クラスのインスタンスが生成され、メンバ変数_wrappedに引数のobjが格納されて返されます。

そうするとおそらく、Underscore.jsの関数を複数適用した場合に、それぞれの関数が_wrappedに値が入っているかどうかを見ているのではないか、と推測できますね!

_wrappedでソースコードを探していくと、mixin関数の定義で以下のように書かれています。

  // Add your own custom functions to the Underscore object.
  function mixin(obj) {
    each(functions(obj), function(name) {
      var func = _$1[name] = obj[name];
      _$1.prototype[name] = function() {
        var args = [this._wrapped];
        push.apply(args, arguments);
        return chainResult(this, func.apply(_$1, args));
      };
    });
    return _$1;
  }

そして mixin関数はソースコードの最後の方で以下のように呼ばれています。

  var _ = mixin(allExports);

まさにここのようです!

allExportsにはUnderscore.jsで使える全ての関数が入っているので、 これら全ての関数を_$1オブジェクトのプロパティに設定しつつprototypeプロパティにも設定しています。 そのため、_$1クラスのインスタンスとして関数が実行された場合は、そのインスタンスと_wrappedの値を関数に適用した結果を、chainResult関数に渡していることが分かります。

そしてchainResult関数は以下のように定義されています。

  // Helper function to continue chaining intermediate results.
  function chainResult(instance, obj) {
    return instance._chain ? _$1(obj).chain() : obj;
  }

ここで少し紛らわしいのが、_$1(obj).chain()のchain関数は_.chain()とは異なる、というところです。

_$1(obj)_$1クラスのインスタンスが生成されてメンバ変数_wrappedobjが入り、 _$1(obj).chain()は、chainResult(this, chain.apply(_$1, [obj])を返す形になります。 ここで、_$1クラスのインスタンスが再生成されているので、メンバ変数_chainの値は何も設定されていない状態(undefined)になっています。

そのため、chainResult(this, chain.apply(_$1, [obj])chain.apply(_$1, [obj])を返し、それは_$1クラスの新しいインスタンスで、そのメンバ変数_wrappedにはobjが、_chainにはtrueが設定された状態になっています。

...と、少々ややこしくなってきたので、 以上をふまえつつ、以下のコードを実行した場合の動作を見てみましょう!

_.chain([1, 2, 3])
	.map(function(num) {
		return (num * 2);
	});
  1. _.chain([1, 2, 3])が実行される
  2. _$1クラスのインスタンス_aが生成される ※分かりやすくするため、インスタンス_a、インスタンス_b ... と順番に名前を付けていきます!
  3. _aのメンバ変数_wrapped[1, 2, 3]が入る
  4. _aのメンバ変数_chaintrueが入る
  5. _aが返る
  6. _aのメンバ関数のmap関数が実行される
  7. chainResult(this, map.apply(_$1, [[1, 2, 3], function(num) { ... }]))が実行される。 ここで、map.apply(_$1, [[1, 2, 3], function(num) { ... }])[2, 4, 6]になるので、 chainResult(this, [2, 4, 6])が実行される
  8. this._chainは上記でtrueが入っているので、_$1([2, 4, 6]).chain()が実行され、 まず_$1([2, 4, 6])が実行される
  9. _$1クラスのインスタンス_bが生成される
  10. _bのメンバ変数_wrapped[2, 4, 6]が入る
  11. _bが返る
  12. _bのメンバ関数のchain関数が実行される
  13. chainResult(this, chain.apply(_$1, [2, 4, 6]))が実行される。 ここで、chain.apply(_$1, [2, 4, 6])_$1クラスの新しいインスタンス_cを生成して返し、 _cのメンバ変数_wrappedには[2, 4, 6]が、メンバ変数_chainにはtrueが入っている
  14. chainResult(this, chain.apply(_$1, [2, 4, 6]))this._chainは設定されていない(undefinedになっている)ので、そのまま_cが返る

最後の_c_$1クラスのインスタンスになっており、 メンバ変数_wrappedには[2, 4, 6]が、_chainにはtrueが入った状態になっています。 _c_$1クラスのインスタンスなので、Underscore.jsで使える全ての関数を続けて実行できるようになっています。 なるほど!!

chain関数を使って、Underscore.jsの関数を複数実行した結果は、最後にvalue関数を実行すると取得できますが、 value関数は以下のように定義されていて、メンバ変数_wrappedの値を返すだけになっています。

	// Extracts the result from a wrapped and chained object.
	_$1.prototype.value = function() {
		return this._wrapped;
	};

シンプルですね!!

さいごに

普段の業務で、既にある機能を改修する際にまずドキュメントを読みますが、モジュールの役割や依存関係を正確に理解・把握するために、実際に書かれているコードを読むことが多いです。 その中で、「こんな書き方があるのか」と勉強になり、自分でもそれを使うようになることがたくさんあります。

今回、Underscore.jsのソースコードを読んでみて、今まで使ったことがないような書き方を知ることができて、とても面白かったです! (例えばfunc.lengthでfunc関数が受け取る引数の数が分かったり、push.apply(arrayA, arrayB))で配列arrayAに配列arrayBの要素を追加できる、など)

皆さんも有名なライブラリのソースコードを読んでみて、新しい書き方を見つけてみてはいかがでしょうか :)

この記事を書いた人

澤田 哲明

大手旅行会社でWebデザイナーとして勤務しつつプログラミングを学び、2012年にフォルシアに入社。
現在はDXプラットフォーム事業部に所属して、福利厚生アウトソーシング会社などのシステム開発を担当。
最近は子供とテレビゲームで遊んだり、運動不足解消のため、朝の散歩に加えて夜の散歩を始めました。

フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。


採用お問い合わせフォーム 募集要項

※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。