2017年2月10日金曜日

コード解読クイズ a.call.apply(a.bind, arguments)

※今回はJavaScriptハッカー向けの投稿です。

問題

JavaScript で書かれた以下のコードについて、関数Xがどのような動作をするのかわかりますか?

function X(a) {
    return a.call.apply(a.bind, arguments)
}


問題の出処

Blogger が読み込むソースコードの難読化を解除し、どのような技術が使われているのかを調査していた所、なんとも奇妙なコードと遭遇しました。

今日、そのコードと1時間半格闘してようやく合点がいったので、これはいいクイズになると思い、こうして投稿いたしました。

解答

先に答えを言ってしまいましょう! 関数 X の処理は……

可変長の引数を受け取り、関数 a の this を第二引数に変え、引数として第三引数以降を渡すようバインドした新しい関数を生成し、それを返す。

わかりましたでしょうか。同じ意味を持つ以下のようなコードを、僅かな文字数すら惜しんでとことん簡略化されたものです。

function X(a) {
  return Function.prototype.bind.apply(a, Array.prototype.slice.call(arguments, 1));
}

ちなみに、私がこれに使われている技術を使わずに簡略化するとしたらこんな感じです。12文字負けました。

function X(a) {
    return a.bind.apply(a, [].slice.call(arguments, 1))
}

解決の糸口1 第一引数の型

とりあえず、引数の意味を理解しましょう。

function X(a) {
    return a.call.apply(a.bind, arguments)
}

まず基礎知識として、 JavaScript の関数は call や apply 、 bind という関数を継承しています。

関数内で、 a から call や bind といった名前のオブジェクトを参照していますね。しかも、 a.call.apply は括弧を使って関数として使用していますから、「a.call.apply は関数」という前提で書かれたコードだということがわかります。

function X(a) {
    return a.call.apply(a.bind, arguments)
}

a.call は関数が継承する関数である apply を持っていることから、おそらく a.call も関数、さらに a も関数が継承する関数である call を持っていることから、 a は関数の可能性が濃厚です。

このことから、この関数の第一引数の a には関数を指定するものだ、という推察ができます。

ちなみに、 a の引数が関数であることの確認をしていないことを考えると、この関数を呼ぶ側で a が関数かどうか既に確認しているか、a はビルトインオブジェクトの関数である可能性があります。

もしそうでないようであればバグ対策が甘いかも知れないので、この件については別途調査が必要だということもわかります。(コードを見るに、バッチリ確認してました)

解決の糸口2 arguments を使っている

この関数内で arguments というキーワードが使用されています。

function X(a) {
    return a.call.apply(a.bind, arguments)
}

このキーワードには、関数に与えられた引数のリストが入っています。用意されている仮引数が a だけなのにあえて [a] や a.call.call(a.bind, a) と書かないことから、この X という関数には2つ以上の引数を渡される可能性もあるということを頭の隅に入れておきます。

落とし穴 call.apply の錯覚

関数が持つ call の定義は Function.prototype.call であり、apply の定義は Function.prototype.apply です。

参考:MDN『Function.prototype.call()
参考:MDN『Function.prototype.apply()

この2つの関数の違いは微々たるもので、どちらも「この関数にこの値を入れて実行する。そのとき、関数内の this 値はこのオブジェクトを使う」という意味の関数です。

eat.call(shiogumar, cake, sushi) とあれば、「shiogumar が cake と sushi を eat する」という意味になっています。

対して、 apply は引数の cake と sushi のところが配列になるだけで、やることは全く同じなんです。

それらを踏まえ、次の行を見てみましょう。

a.call.apply(a.bind, arguments)

なぜ、a に対しての call にさらに apply しているのでしょう。両方、同じ意味の関数ですし、apply は call 、 call は a を実行するなら結果として a を直接読んでいるのと大差ないように思います。

そしてそれ以上に、 a.bind に意味が無いようにも見えます。

※赤く塗った場所は、私が陥った錯覚です。

解決の糸口3 apply の動作を考える

しかし、徹底的にコードを短く書くことを追求した(っぽい)コードに、そんなムダなど有るはずもありません。

じっくり一つ一つ見ていきましょう。

まず、関数が実行される順序は apply(a.bind, arguments) の部分からです。

第一引数の a.bind は、a.call を実行する際の this になります。そして、 arguments はそのまま a.call の引数になります。

arguments が引数になるということは、call(arguments[0], arguments[1], ...) と X の呼び出し時に指定した引数の数だけ続くことになります。... の部分はコーディング上はアウトですが、便利のため ... を使って引数の連続を表します。

a.call(arguments[0], arguments[1], ...)
※ただし、this = a.bind

しかし、改めて考えてみると call を実行するときの this は、 a.bind という関数なんですよね。通常、this 値はオブジェクト内の関数はオブジェクトを指し、そうでない関数では window などのグローバルなオブジェクトを指していることが多いですが、今回は違います。

「関数が持つ関数の this を書き換える」ということが、どのような効果をもたらすのか、知らない人もいらっしゃるかもしれません。ちなみに、私は知りませんでした。

解決の糸口4 call されるものは何か

通常、 a.call とあった場合、 a.call という関数が参照する this は a です。しかし、その前に apply を実行しているため、これを実行すると a.call が参照する this の値が上書きされます。するとどういうことがおきるかというと、call で呼び出す関数が上書きされます

呼び出す関数が上書きされるということは、問題のコードは以下のように書き換えたのとほぼ同等です。

a.call.apply(a.bind, arguments)
↓ apply を実行 ↓
a.bind.call(arguments[0], arguments[1], ...)
※ただし、a.bind の this は関数 X を実行したオブジェクトになるかも

ということは、call も apply と同様の機能を持つため、同じように考えると

a.bind.call(arguments[0], arguments[1], ...)
↓ call を実行 ↓
arguments[0].bind(arguments[1], arguments[2], ...)

と同等となります。ここにきてようやく、 bind がこの関数で扱いたいメインの処理であるということが一目瞭然になりました。

JavaScript の this 関係の理解が完璧な人は、もしかしたらこれに関しては悩まないかもしれません。しかし、私は悩みました。this の理解が完璧でなかったことに加え、正直ややこしすぎて頭がついていけていません。

こうやってテキストに書き起こしてようやくわかるくらいです。

解決の糸口5 bind とは?

さて、本命であることがわかった a.bind ですが、その定義は Function.prototype.bind です。call のように this 値に入れるオブジェクトと引数を設定する関数ですが、その意味は「事前に call の設定を組み込んだ関数を作る」というものになります。

参考:MDN『Function.prototype.bind()

例えば、
var esayari = eat.bind(shiogumar, cake, sushi)

を実行すると、「shiogumar が cake と sushi を eat する」という処理をまとめて esayari という関数ができあがります。この esayari() を実行すると、shiogumar は cake と sushi を eat します。この関数は、コールバック用の関数を作るときなどに重宝します。

また、esayari(banana) とすると引数が後ろに付け足され、 shiogumar.eat(cake, sushi, banana) と同じ意味になり、 shiogumar は cake と sushi と banana を eat します。もうお腹いっぱいです。

さて、このことを踏まえて先程展開したコードを見てみると、以下のようになっていますね。

arguments[0].bind(arguments[1], arguments[2], ...)

つまり、先程の例に当てはめると「arguments[1] が arguments[2] 以降を arguments[0] する」という処理を一つにまとめた関数を生成するコードである、ということがわかります。これが、解答になります。

考察 なぜ call.apply が必要なのか

とてもややこしい動作をするこの call.apply ですが、結論から言って、call.apply の組み合わせが必ずしも必要というわけではありません。ただ、そうするのが一番コードを短く書くことができるというだけです。

というのも、可変長の引数を bind に渡すためには apply を使う必要がありますが、call を使っているのは引数リストの引数を一つ減らすためなんです。もう一度、 apply と call を展開した後のものを見てみましょう。

arguments[0].bind(arguments[1], arguments[2], ...)

上記を見ると分かるように、今回の関数の場合は bind には第二引数以降を設定する必要があります。今回、ちょうど第一引数が this になるオブジェクトですので、引数リストを与えて一回 call すると第一引数が抜け、bind には第二引数以降だけを渡すことができるようになるのです。

ちなみに、 call によって a.bind の this が arguments[0] で上書きされますが、どのみち a.bind を apply 関数に渡した時点で a.bind の this が apply と同じ、つまり関数 X の this と同じになっていると思うので、call で bind の this を arguments[0] に上書きしているのも実はファインプレーだったというところもミソです。

脱線しましたが、つまり apply する際に最初から第一引数を減らしておけば apply 後の call は必要なくなりますので、以下のように書くことができます。

a.bind.apply(a, Array.prototype.slice.call(arguments, 1))

しかし、上記の別解と問題のコードを比較すると、問題のコードのほうが処理として優れていることがわかります。
  • bind はどちらも一回
  • call はどちらも一回
  • apply はどちらも一回
  • 別解では配列のコピーを作る無駄がある
  • 別解ではコードの文字数が多い

可読性という意味では別解の方がまだ良いようにも思えますが、とても無駄なく洗練されたビジネスライクなプログラムも、たまに読むといい頭の体操になりますね。

とにかく、このコードを考えた人はすごい!ということです。

0 件のコメント :

コメントを投稿