JavaScriptは、関数を取り扱う際に比類のない柔軟性を提供します。関数は渡したり、オブジェクトとして使用したりできます。そして、今度はそれらの間で呼び出しを転送し、デコレートする方法を見ていきます。
透過的なキャッシング
CPU負荷の高い関数slow(x)があるとします。しかし、その結果は安定しています。つまり、同じxに対しては常に同じ結果を返します。
関数が頻繁に呼び出される場合、再計算に余分な時間を費やすのを避けるために、結果をキャッシュ(記憶)したい場合があります。
しかし、その機能をslow()に追加する代わりに、キャッシングを追加するラッパー関数を作成します。ご覧のように、そうすることで多くの利点があります。
コードを以下に示し、説明を続けます。
function slow(x) {
// there can be a heavy CPU-intensive job here
alert(`Called with ${x}`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) { // if there's such key in cache
return cache.get(x); // read the result from it
}
let result = func(x); // otherwise call func
cache.set(x, result); // and cache (remember) the result
return result;
};
}
slow = cachingDecorator(slow);
alert( slow(1) ); // slow(1) is cached and the result returned
alert( "Again: " + slow(1) ); // slow(1) result returned from cache
alert( slow(2) ); // slow(2) is cached and the result returned
alert( "Again: " + slow(2) ); // slow(2) result returned from cache
上記のコードでは、cachingDecoratorはデコレータです。これは、別の関数を取り、その動作を変更する特別な関数です。
アイデアは、任意の関数に対してcachingDecoratorを呼び出すことができ、キャッシングラッパーが返されることです。これは素晴らしいことです。なぜなら、このような機能を使用できる関数はたくさんあり、必要なのはそれらにcachingDecoratorを適用することだけだからです。
キャッシングをメイン関数のコードから分離することにより、メインコードもシンプルに保ちます。
cachingDecorator(func)の結果は「ラッパー」です。func(x)の呼び出しをキャッシングロジックで「ラップ」するfunction(x)です。
外部コードから見ると、ラップされたslow関数は依然として同じことを行います。動作にキャッシングという側面が追加されただけです。
要約すると、slow自体を変更する代わりに、個別のcachingDecoratorを使用することにはいくつかの利点があります。
cachingDecoratorは再利用可能です。別の関数に適用できます。- キャッシングロジックは分離されており、
slow自体の複雑さを増すことはありませんでした(もしあったとしても)。 - 必要に応じて複数のデコレータを組み合わせることができます(他のデコレータは後続します)。
コンテキストに「func.call」を使用する
上記で述べたキャッシングデコレータは、オブジェクトメソッドで動作するのに適していません。
たとえば、以下のコードでは、デコレーション後、worker.slow()は機能しなくなります。
// we'll make worker.slow caching
let worker = {
someMethod() {
return 1;
},
slow(x) {
// scary CPU-heavy task here
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
// same code as before
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func(x); // (**)
cache.set(x, result);
return result;
};
}
alert( worker.slow(1) ); // the original method works
worker.slow = cachingDecorator(worker.slow); // now make it caching
alert( worker.slow(2) ); // Whoops! Error: Cannot read property 'someMethod' of undefined
エラーは、this.someMethodにアクセスしようとする行(*)で発生し、失敗します。なぜそうなるか分かりますか?
理由は、ラッパーが元の関数をfunc(x)として行(**)で呼び出しているためです。そして、このように呼び出されると、関数はthis = undefinedになります。
次を実行しようとすると、同様の症状が見られます。
let func = worker.slow;
func(2);
したがって、ラッパーは元のメソッドへの呼び出しを渡しますが、コンテキストthisなしで渡します。そのため、エラーが発生します。
修正しましょう。
thisを明示的に設定して関数を呼び出すことができる特別な組み込み関数メソッドfunc.call(context, …args)があります。
構文は以下のとおりです。
func.call(context, arg1, arg2, ...)
最初の引数をthisとして、次の引数を引数として提供してfuncを実行します。
簡単に言うと、次の2つの呼び出しはほぼ同じことを行います。
func(1, 2, 3);
func.call(obj, 1, 2, 3)
どちらも引数1、2、3でfuncを呼び出します。唯一の違いは、func.callがthisをobjにも設定することです。
例として、以下のコードでは、異なるオブジェクトのコンテキストでsayHiを呼び出します。sayHi.call(user)はthis=userを提供してsayHiを実行し、次の行はthis=adminを設定します。
function sayHi() {
alert(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };
// use call to pass different objects as "this"
sayHi.call( user ); // John
sayHi.call( admin ); // Admin
そして、ここではcallを使用して、指定されたコンテキストとフレーズでsayを呼び出します。
function say(phrase) {
alert(this.name + ': ' + phrase);
}
let user = { name: "John" };
// user becomes this, and "Hello" becomes the first argument
say.call( user, "Hello" ); // John: Hello
私たちのケースでは、ラッパーでcallを使用して、コンテキストを元の関数に渡すことができます。
let worker = {
someMethod() {
return 1;
},
slow(x) {
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func.call(this, x); // "this" is passed correctly now
cache.set(x, result);
return result;
};
}
worker.slow = cachingDecorator(worker.slow); // now make it caching
alert( worker.slow(2) ); // works
alert( worker.slow(2) ); // works, doesn't call the original (cached)
これで全て問題ありません。
すべてを明確にするために、thisがどのように渡されるかを詳しく見てみましょう。
- デコレーション後、
worker.slowはfunction (x) { ... }ラッパーになります。 - そのため、
worker.slow(2)が実行されると、ラッパーは引数として2とthis=workerを受け取ります(ドットの前にあるオブジェクトです)。 - ラッパー内では、結果がまだキャッシュされていないと仮定して、
func.call(this, x)は現在のthis(=worker)と現在の引数(=2)を元のメソッドに渡します。
複数引数にする
では、cachingDecoratorをさらに普遍的にしましょう。これまでのところ、単一引数関数でのみ動作していました。
では、複数引数のworker.slowメソッドをどのようにキャッシュしますか?
let worker = {
slow(min, max) {
return min + max; // scary CPU-hogger is assumed
}
};
// should remember same-argument calls
worker.slow = cachingDecorator(worker.slow);
以前は、単一引数xに対して、結果を保存するためにcache.set(x, result)と、取得するためにcache.get(x)を使用できました。しかし、今度は引数の組み合わせ(min,max)の結果を覚えておく必要があります。ネイティブのMapは、キーとして単一の値しか受け付けません。
多くの解決策が考えられます。
- より汎用的で複数キーを許可する新しい(またはサードパーティの)マップのようなデータ構造を実装します。
- 入れ子になったマップを使用します。
cache.set(min)は、ペア(max, result)を格納するMapになります。したがって、cache.get(min).get(max)としてresultを取得できます。 - 2つの値を1つに結合します。私たちの特定のケースでは、文字列
"min,max"をMapキーとして使用できます。柔軟性を高めるために、デコレータにハッシュ関数を提供できます。これは、多くの値から1つの値を作成する方法を知っています。
多くの実用的なアプリケーションでは、3番目の方法は十分に優れているため、それに固執します。
また、xだけでなく、func.callですべての引数を渡す必要があります。function()では、その引数の擬似配列をargumentsとして取得できることを思い出してください。そのため、func.call(this, x)はfunc.call(this, ...arguments)に置き換える必要があります。
より強力なcachingDecoratorを以下に示します。
let worker = {
slow(min, max) {
alert(`Called with ${min},${max}`);
return min + max;
}
};
function cachingDecorator(func, hash) {
let cache = new Map();
return function() {
let key = hash(arguments); // (*)
if (cache.has(key)) {
return cache.get(key);
}
let result = func.call(this, ...arguments); // (**)
cache.set(key, result);
return result;
};
}
function hash(args) {
return args[0] + ',' + args[1];
}
worker.slow = cachingDecorator(worker.slow, hash);
alert( worker.slow(3, 5) ); // works
alert( "Again " + worker.slow(3, 5) ); // same (cached)
これで、任意の数の引数で動作します(ただし、ハッシュ関数も任意の数の引数を許可するように調整する必要があります。これに対処する興味深い方法は、後で説明します)。
2つの変更点があります。
- 行
(*)では、argumentsから単一のキーを作成するためにhashを呼び出します。ここでは、引数(3, 5)をキー"3,5"に変換する単純な「結合」関数を使用しています。より複雑なケースでは、他のハッシュ関数が必要になる場合があります。 - 次に、
(**)はfunc.call(this, ...arguments)を使用して、コンテキストと、ラッパーが受け取ったすべての引数(最初の引数だけではありません)を元の関数に渡します。
func.apply
func.call(this, ...arguments)の代わりに、func.apply(this, arguments)を使用できます。
組み込みメソッドfunc.applyの構文は以下のとおりです。
func.apply(context, args)
this=contextを設定し、配列のようなオブジェクトargsを引数のリストとして使用して、funcを実行します。
callとapplyの構文上の違いは、callが引数のリストを期待するのに対し、applyはそれらを含む配列のようなオブジェクトを受け取る点だけです。
したがって、次の2つの呼び出しはほぼ同等です。
func.call(context, ...args);
func.apply(context, args);
指定されたコンテキストと引数でfuncを同じように呼び出します。
argsに関する微妙な違いが1つだけあります。
- スプレッド構文
...を使用すると、反復可能なargsをcallへのリストとして渡すことができます。 applyは、配列のようなargsのみを受け入れます。
…そして、実際の配列など、反復可能で配列のようなオブジェクトについては、どちらでも使用できますが、applyの方がおそらく高速です。なぜなら、ほとんどのJavaScriptエンジンは内部でそれをよりよく最適化しているからです。
コンテキストを含むすべての引数を別の関数に渡すことを、コールフォワーディングと呼びます。
これが最も単純な形式です。
let wrapper = function() {
return func.apply(this, arguments);
};
外部コードがこのwrapperを呼び出す場合、元の関数funcの呼び出しと区別できません。
メソッドの借用
では、ハッシュ関数にもう少し小さな改善を加えましょう。
function hash(args) {
return args[0] + ',' + args[1];
}
現時点では、2つの引数でのみ動作します。任意の数のargsを結合できればより良いでしょう。
自然な解決策は、arr.joinメソッドを使用することです。
function hash(args) {
return args.join();
}
…残念ながら、それは機能しません。hash(arguments)を呼び出しているため、argumentsオブジェクトは反復可能で配列のようですが、実際の配列ではありません。
そのため、それにjoinを呼び出すと失敗します。以下に示すように。
function hash() {
alert( arguments.join() ); // Error: arguments.join is not a function
}
hash(1, 2);
それでも、配列のjoinを使用する簡単な方法があります。
function hash() {
alert( [].join.call(arguments) ); // 1,2
}
hash(1, 2);
このトリックは、メソッドの借用と呼ばれます。
通常の配列([].join)からjoinメソッドを取り(借用し)、[].join.callを使用してargumentsのコンテキストで実行します。
なぜこれが機能するのでしょうか?
ネイティブメソッドarr.join(glue)の内部アルゴリズムは非常に単純だからです。
仕様からほぼ「そのまま」取得されています。
glueを引数の最初の要素、または引数がない場合はコンマ","とします。resultを空文字列とします。this[0]をresultに追加します。glueとthis[1]を追加します。glueとthis[2]を追加します。- …
this.length個のアイテムが結合されるまで続けます。 resultを返します。
技術的には、thisを受け取り、this[0]、this[1]…などを連結します。意図的に配列のようなthis(偶然ではありません。多くのメソッドがこの慣習に従っています)を受け入れられるように記述されています。そのため、this=argumentsでも動作します。
デコレータと関数プロパティ
一般的に、関数またはメソッドをデコレートされた関数で置き換えることは安全です。ただし、小さな例外があります。元の関数にfunc.calledCountなどのプロパティがある場合、デコレートされた関数にはそれらのプロパティがありません。これはラッパーだからです。そのため、プロパティを使用する場合は注意が必要です。
例えば、上記の例でslow関数にプロパティがある場合、cachingDecorator(slow)はそれらのプロパティを持たないラッパーになります。
一部のデコレータは独自のプロパティを提供する場合があります。例えば、関数が何回呼び出され、どれだけの時間がかかったかをカウントし、ラッパーのプロパティを介してこの情報を公開するデコレータがあります。
関数プロパティへのアクセスを維持するデコレータを作成する方法もありますが、これには関数ラッパーとして特別なProxyオブジェクトを使用する必要があります。この記事のProxyとReflectで後ほど説明します。
概要
デコレータは、関数の動作を変更する関数のラッパーです。主な処理は依然として関数によって実行されます。
デコレータは、関数に追加できる「機能」または「側面」と見なすことができます。1つまたは複数追加できます。そして、そのコードを変更することなく、これらすべてを行うことができます!
cachingDecoratorを実装するために、メソッドを研究しました。
- func.call(context, arg1, arg2…) – 指定されたコンテキストと引数を使用して
funcを呼び出します。 - func.apply(context, args) –
contextをthisとして、配列のようなargsを引数のリストとして渡してfuncを呼び出します。
一般的なコールフォワーディングは通常、applyを使用して行われます。
let wrapper = function() {
return original.apply(this, arguments);
};
また、オブジェクトからメソッドを取得し、別のオブジェクトのコンテキストで呼び出すメソッド借用の例も示しました。配列メソッドを取得してargumentsに適用することは非常に一般的です。別の方法は、実際の配列であるrestパラメータオブジェクトを使用することです。
多くのデコレータが広く使用されています。この章の課題を解いて、どれだけ理解しているかを確認してください。
コメント
<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkr、jsbin、codepen…)を使用してください。