Underscore.jsのソースコードを読んでみた
これは、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関数が受け取る引数はobj
、iteratee
、memo
、context
の4つであることが分かります。
return function(obj, iteratee, memo, context) { var initial = arguments.length >= 3; return reducer(obj, optimizeCb(iteratee, context, 4), memo, initial); };
引数のobj
、iteratee
、context
は先ほどのmap関数と同じですが、新しく出てきた引数memo
で初期値を受け取っています。 memo
を渡せば、initial
はtrue
になり、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)
というインスタンスを取得し、インタンス変数_chain
にtrue
を設定して返しているようです。 _$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;
ここで、this
はnew
キーワードを付けて_$1
関数が呼ばれた場合に_$1
クラスのインスタンスになるので、逆にnew
を付けずに呼ばれた場合はthis instanceof _$1
はfalse
になります。 すると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
クラスのインスタンスが生成されてメンバ変数_wrapped
にobj
が入り、 _$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); });
_.chain([1, 2, 3])
が実行される_$1
クラスのインスタンス_a
が生成される ※分かりやすくするため、インスタンス_a
、インスタンス_b
... と順番に名前を付けていきます!_a
のメンバ変数_wrapped
に[1, 2, 3]
が入る_a
のメンバ変数_chain
にtrue
が入る_a
が返る_a
のメンバ関数のmap関数が実行される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])
が実行されるthis._chain
は上記でtrue
が入っているので、_$1([2, 4, 6]).chain()
が実行され、 まず_$1([2, 4, 6])
が実行される_$1
クラスのインスタンス_b
が生成される_b
のメンバ変数_wrapped
に[2, 4, 6]
が入る_b
が返る_b
のメンバ関数のchain関数が実行されるchainResult(this, chain.apply(_$1, [2, 4, 6]))
が実行される。 ここで、chain.apply(_$1, [2, 4, 6])
は_$1
クラスの新しいインスタンス_c
を生成して返し、_c
のメンバ変数_wrapped
には[2, 4, 6]
が、メンバ変数_chain
にはtrue
が入っている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プラットフォーム事業部に所属して、福利厚生アウトソーシング会社などのシステム開発を担当。
最近は子供とテレビゲームで遊んだり、運動不足解消のため、朝の散歩に加えて夜の散歩を始めました。
フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。
※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。