2022年6月8日

デコレータとフォワーディング、call/apply

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)

どちらも引数123funcを呼び出します。唯一の違いは、func.callthisobjにも設定することです。

例として、以下のコードでは、異なるオブジェクトのコンテキストで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がどのように渡されるかを詳しく見てみましょう。

  1. デコレーション後、worker.slowfunction (x) { ... }ラッパーになります。
  2. そのため、worker.slow(2)が実行されると、ラッパーは引数として2this=workerを受け取ります(ドットの前にあるオブジェクトです)。
  3. ラッパー内では、結果がまだキャッシュされていないと仮定して、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は、キーとして単一の値しか受け付けません。

多くの解決策が考えられます。

  1. より汎用的で複数キーを許可する新しい(またはサードパーティの)マップのようなデータ構造を実装します。
  2. 入れ子になったマップを使用します。cache.set(min)は、ペア(max, result)を格納するMapになります。したがって、cache.get(min).get(max)としてresultを取得できます。
  3. 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を実行します。

callapplyの構文上の違いは、callが引数のリストを期待するのに対し、applyはそれらを含む配列のようなオブジェクトを受け取る点だけです。

したがって、次の2つの呼び出しはほぼ同等です。

func.call(context, ...args);
func.apply(context, args);

指定されたコンテキストと引数でfuncを同じように呼び出します。

argsに関する微妙な違いが1つだけあります。

  • スプレッド構文...を使用すると、反復可能argscallへのリストとして渡すことができます。
  • 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)の内部アルゴリズムは非常に単純だからです。

仕様からほぼ「そのまま」取得されています。

  1. glueを引数の最初の要素、または引数がない場合はコンマ","とします。
  2. resultを空文字列とします。
  3. this[0]resultに追加します。
  4. gluethis[1]を追加します。
  5. gluethis[2]を追加します。
  6. this.length個のアイテムが結合されるまで続けます。
  7. resultを返します。

技術的には、thisを受け取り、this[0]this[1]…などを連結します。意図的に配列のようなthis(偶然ではありません。多くのメソッドがこの慣習に従っています)を受け入れられるように記述されています。そのため、this=argumentsでも動作します。

デコレータと関数プロパティ

一般的に、関数またはメソッドをデコレートされた関数で置き換えることは安全です。ただし、小さな例外があります。元の関数にfunc.calledCountなどのプロパティがある場合、デコレートされた関数にはそれらのプロパティがありません。これはラッパーだからです。そのため、プロパティを使用する場合は注意が必要です。

例えば、上記の例でslow関数にプロパティがある場合、cachingDecorator(slow)はそれらのプロパティを持たないラッパーになります。

一部のデコレータは独自のプロパティを提供する場合があります。例えば、関数が何回呼び出され、どれだけの時間がかかったかをカウントし、ラッパーのプロパティを介してこの情報を公開するデコレータがあります。

関数プロパティへのアクセスを維持するデコレータを作成する方法もありますが、これには関数ラッパーとして特別なProxyオブジェクトを使用する必要があります。この記事のProxyとReflectで後ほど説明します。

概要

デコレータは、関数の動作を変更する関数のラッパーです。主な処理は依然として関数によって実行されます。

デコレータは、関数に追加できる「機能」または「側面」と見なすことができます。1つまたは複数追加できます。そして、そのコードを変更することなく、これらすべてを行うことができます!

cachingDecoratorを実装するために、メソッドを研究しました。

一般的なコールフォワーディングは通常、applyを使用して行われます。

let wrapper = function() {
  return original.apply(this, arguments);
};

また、オブジェクトからメソッドを取得し、別のオブジェクトのコンテキストで呼び出すメソッド借用の例も示しました。配列メソッドを取得してargumentsに適用することは非常に一般的です。別の方法は、実際の配列であるrestパラメータオブジェクトを使用することです。

多くのデコレータが広く使用されています。この章の課題を解いて、どれだけ理解しているかを確認してください。

課題

重要度: 5

関数へのすべての呼び出しをそのcallsプロパティに保存するラッパーを返すデコレータspy(func)を作成します。

各呼び出しは引数の配列として保存されます。

例えば

function work(a, b) {
  alert( a + b ); // work is an arbitrary function or method
}

work = spy(work);

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

補足:このデコレータは、ユニットテストに役立つ場合があります。高度な形式は、Sinon.JSライブラリのsinon.spyです。

テストを含むサンドボックスを開きます。

spy(f)によって返されるラッパーは、すべての引数を保存してからf.applyを使用して呼び出しを転送する必要があります。

function spy(func) {

  function wrapper(...args) {
    // using ...args instead of arguments to store "real" array in wrapper.calls
    wrapper.calls.push(args);
    return func.apply(this, args);
  }

  wrapper.calls = [];

  return wrapper;
}

テストを含む解答をサンドボックスで開きます。

重要度: 5

fの各呼び出しをmsミリ秒遅延させるデコレータdelay(f, ms)を作成します。

例えば

function f(x) {
  alert(x);
}

// create wrappers
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // shows "test" after 1000ms
f1500("test"); // shows "test" after 1500ms

言い換えれば、delay(f, ms)fの「msミリ秒遅延した」バリアントを返します。

上記のコードでは、fは単一引数の関数ですが、解答ではすべての引数とコンテキストthisを渡す必要があります。

テストを含むサンドボックスを開きます。

解答

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

let f1000 = delay(alert, 1000);

f1000("test"); // shows "test" after 1000ms

ここでアロー関数がどのように使用されているかに注目してください。ご存知のように、アロー関数には独自のthisargumentsがないため、f.apply(this, arguments)はラッパーからthisargumentsを取得します。

通常の関数を渡した場合、setTimeoutは引数なしで、this=window(ブラウザ内であると仮定)でそれを呼び出します。

中間変数を使用して正しいthisを渡すこともできますが、少し面倒です。

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // store this into an intermediate variable
    setTimeout(function() {
      f.apply(savedThis, args); // use it here
    }, ms);
  };

}

テストを含む解答をサンドボックスで開きます。

重要度: 5

debounce(f, ms)デコレータの結果は、msミリ秒の非アクティブ状態(呼び出しなし、「クールダウン期間」)になるまでfへの呼び出しを一時停止し、その後、最新の引数を使用してfを一度呼び出すラッパーです。

言い換えれば、debounceは「電話を受ける」秘書のようなもので、静かな状態がmsミリ秒続くまで待ちます。そして、初めて最新の通話情報を「上司」に転送します(実際のfを呼び出します)。

例えば、関数fがあり、f = debounce(f, 1000)で置き換えたとします。

次に、ラップされた関数が0ms、200ms、500msで呼び出され、その後呼び出しがない場合、実際のfは1500msで一度だけ呼び出されます。つまり、最後の呼び出しから1000msのクールダウン期間の後です。

…そして、最後の呼び出しの引数を受け取ります。他の呼び出しは無視されます。

そのためのコードは次のとおりです(Lodashライブラリのデバウンスデコレータを使用)。

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// debounced function waits 1000ms after the last call and then runs: alert("c")

では、実用的な例を見てみましょう。ユーザーが入力し、入力完了時にサーバーにリクエストを送信したいとします。

入力された文字ごとにリクエストを送信する意味はありません。代わりに待ち、結果全体を処理したいです。

ウェブブラウザでは、イベントハンドラー(入力フィールドの変更ごとに呼び出される関数)を設定できます。通常、イベントハンドラーは、入力されたキーごとに非常に頻繁に呼び出されます。しかし、1000msでdebounceした場合、最後の入力後1000ms後に一度だけ呼び出されます。

このライブ例では、ハンドラーは結果を下のボックスに入力します。試してみてください。

わかりますか?2番目の入力はデバウンスされた関数を呼び出すため、その内容は最後の入力から1000ms後に処理されます。

そのため、debounceは一連のイベント(キー押下、マウスの動きなど)を処理する優れた方法です。

最後の呼び出しの後、指定された時間待機し、結果を処理できる関数を実行します。

課題は、debounceデコレータを実装することです。

ヒント:考えてみれば、ほんの数行です :)

テストを含むサンドボックスを開きます。

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

debounceへの呼び出しはラッパーを返します。呼び出されると、指定されたms後に元の関数呼び出しをスケジュールし、以前のタイムアウトをキャンセルします。

テストを含む解答をサンドボックスで開きます。

重要度: 5

「スロットリング」デコレータthrottle(f, ms)を作成します。これはラッパーを返します。

複数回呼び出された場合、最大でmsミリ秒ごとに一度、fへの呼び出しを渡します。

デバウンスデコレータと比較して、動作は全く異なります。

  • debounceは「クールダウン」期間の後に関数を一度実行します。最終結果を処理するのに適しています。
  • throttleは、指定されたms時間より頻繁に実行しません。非常に頻繁に実行する必要のない定期的な更新に適しています。

言い換えれば、throttleは電話を受ける秘書のようなものですが、最大でmsミリ秒ごとに一度、上司(実際のf)を悩ませることはありません。

この要件をよりよく理解し、それがどこから来ているかを確認するために、現実世界のアプリケーションを確認しましょう。

例えば、マウスの動きを追跡したいとします。

ブラウザでは、マウスの動きごとに実行される関数を設定し、移動するポインタの位置を取得できます。アクティブなマウス使用中には、この関数は通常非常に頻繁に実行され、1秒間に約100回(10msごと)になる可能性があります。**ポインタが移動したときに、ウェブページの情報の一部を更新したいです。**

…しかし、更新関数update()は、微小な動きごとに実行するには重すぎます。100msより頻繁に更新する意味もありません。

そのため、デコレータにラップします。元のupdate()の代わりに、各マウス移動で実行する関数としてthrottle(update, 100)を使用します。デコレータは頻繁に呼び出されますが、最大で100msごとに一度、update()への呼び出しを転送します。

視覚的には、次のようになります。

  1. 最初のマウス移動では、デコレートされたバリアントはすぐにupdateへの呼び出しを渡します。これは重要です。ユーザーは自分の動きへの反応をすぐに確認できます。
  2. その後、マウスが移動すると、100msまでは何も起こりません。デコレートされたバリアントは呼び出しを無視します。
  3. 100msの終わりに、もう1つのupdateが最後座標で発生します。
  4. 最後に、マウスがどこかで停止します。デコレートされたバリアントは100msが経過するまで待ち、その後、最後座標でupdateを実行します。そのため、最後のマウス座標が処理されることは非常に重要です。

コード例

function f(a) {
  console.log(a);
}

// f1000 passes calls to f at maximum once per 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // shows 1
f1000(2); // (throttling, 1000ms not out yet)
f1000(3); // (throttling, 1000ms not out yet)

// when 1000 ms time out...
// ...outputs 3, intermediate value 2 was ignored

補足:f1000に渡された引数とコンテキストthisは、元のfに渡す必要があります。

テストを含むサンドボックスを開きます。

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }
    isThrottled = true;

    func.apply(this, arguments); // (1)

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

throttle(func, ms)への呼び出しはwrapperを返します。

  1. 最初の呼び出しでは、wrapperfuncを実行し、クールダウン状態(isThrottled = true)を設定します。
  2. この状態では、すべての呼び出しがsavedArgs/savedThisに保存されます。コンテキストと引数の両方が同様に重要であり、保存する必要があることに注意してください。呼び出しを再現するには、同時にそれらが必要です。
  3. msミリ秒が経過すると、setTimeoutがトリガーされます。クールダウン状態が削除され(isThrottled = false)、呼び出しを無視していた場合は、wrapperが最後に保存された引数とコンテキストで実行されます。

3番目のステップはfuncではなくwrapperを実行します。funcを実行する必要があるだけでなく、クールダウン状態に再度入り、それをリセットするタイムアウトを設定する必要があるためです。

テストを含む解答をサンドボックスで開きます。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点の提案がある場合は、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数行のコードを挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。