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…)を使用してください。