2024年10月23日

デコレータと転送, call/apply

JavaScript は関数を処理する際、非常に高い柔軟性を提供します。関数は渡され、オブジェクトとして使われます。ここでは、これらの間の呼び出しを 転送 したり、装飾(デコレータ) する方法を説明します。

透過キャッシュ(Transparent caching)

CPU負荷は高いが、その結果が不変である関数 slow(x) があるとします。言い換えると、同じ x の場合、常に同じ結果が返ってきます。

もし関数が頻繁に呼ばれる場合、再計算に余分な時間を費やすことを避けるため、結果をキャッシュ(覚えておく)して欲しいかもしれません。

その機能を slow() に追加する代わりに、ラッパーを作りましょう。これから見ていくように、そうすることで多くのメリットがあります。

コードと説明は次の通りです:

function slow(x) {
  // CPUを大量に消費するジョブがここにある可能性があります
  alert(`Called with ${x}`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) { // 結果が map にあれば
      return cache.get(x); // それを返します
    }

    let result = func(x); // なければ func を呼び

    cache.set(x, result); // 結果をキャッシュ(覚える)します
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1) はキャッシュされました
alert( "Again: " + slow(1) ); // 同じ

alert( slow(2) ); // slow(2) はキャッシュされました
alert( "Again: " + slow(2) ); // 前の行と同じ

上のコード中の cachingDecoratorデコレータ です: 別の関数を取り、その振る舞いを変更する特別な関数です。

この考え方は、任意の関数で cachingDecorator を呼ぶことができ、それはキャッシングラッパーを返します。このような機能を使うことができる多くの関数を持つことができるので、これは素晴らしいことです。また、必要なのは、関数に cachingDecorator を適用するだけです。

メインの関数コードからキャッシュ機能を分離することで、コードをシンプルに保つこともできます。

cachingDecorator(func) の結果は “ラッパー” です: func(x) の呼び出しをキャッシュロジックに “ラップ” する function(x) です。:

上でわかるように、ラッパーは func(x) の結果を “そのまま” 返します。外部のコードからは、ラップされた slow 関数は、依然として同じことを行い、単にその振る舞いに追加されたキャッシュの側面をもちます。

要約すると、slow 自身のコードを修正する代わりに、分離した cachingDecorator を利用することは、いくつかのメリットがあります。:

  • cachingDecorator は再利用可能です。私たちは、別の関数に適用することもできます。
  • キャッシュロジックは分離されているので、slow 自身の複雑性は増加しません。
  • 必要に応じて、複数のデコレータを組み合わせることができます(他のデコレータについては次に続きます)。

コンテキストのために、“func.call” を利用する

上で言及されたキャッシュデコレータはオブジェクトメソッドで動作するのには適していません。

例えば、下のコードでは、デコレーションの後、worker.slow() は動作を停止します:

// worker.slow のキャッシングを作成する
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // 実際には、CPUの重いタスクがここにあるとします
    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(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // オリジナルメソッドは動きます

worker.slow = cachingDecorator(worker.slow); // キャッシングする

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)

それらは両方とも func を引数 1, 2, 3 で呼び出しています。唯一の違いは、func.callthisobj にセットしていることです。

例として、下のコードでは異なるオブジェクトのコンテキストで sayHi を呼び出しています。: sayHi.call(user)this=user を提供する sayHi を実行し、次の行で this=admin をセットします。:

function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// 別のオブジェクトを "this" として渡すために call を使用する
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

そして、ここでは call を使って与えられたコンテキストとフレーズで say を呼び出しています。:

function say(phrase) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

// user は this になり, "Hello" は最初の引数になります
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" は正しいものが渡されます
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // キャッシングします

alert( worker.slow(2) ); // 動作します
alert( worker.slow(2) ); // 動作します(キャッシュが使われます)

これで全てうまく行きます。

すべてをクリアにするために、どのように this が渡されているか詳しくみてみましょう:

  1. デコレーション後の worker.slow はラッパー function (x) { ... } です。
  2. なので、worker.slow(2) が実行されるとき、ラッパーは引数として 2 と、this=worker を取ります(これはドットの前のオブジェクトになります)。
  3. ラッパーの中では、 結果がまだキャッシュされていないと仮定すると、func.call(this, x) はオリジナルのメソッドに現在の this (=worker) と、現在の引数 (=2)を渡します。

複数の引数

さて、cachingDecorator をより普遍的なものにしましょう。これまでは、単一引数の関数でのみ動作していました。

どのようにして複数の引数の worker.slow メソッドをキャッシュするでしょう?

let worker = {
  slow(min, max) {
    return min + max; // 恐ろしいCPU負荷が想定される
  }
};

// 同じ引数呼び出しを覚える必要があります
worker.slow = cachingDecorator(worker.slow);

以前は、1つの引数 x に対し、単に cache.set(x, result) として結果を保存し、cache.get(x) でそれを取得していました。しかし、今回は 引数の組み合わせ (min,max) で結果を覚える必要があります。ネイティブの Map は1つのキーに1つの値のみを取ります。

可能な解決策はたくさんあります:

  1. より汎用性があり、複数のキーを許可する新しい(もしくは 3rdパーティを使って)マップライクなデータ構造を実装する方法です。
  2. 入れ子のマップを使います: cache.set(min)(max, result) ペアを格納する Map になるでしょう。なので、cache.get(min).get(max)result を得ることができます。
  3. 2つの値を1つに結合します。今のケースだと、Map として文字列 "min,max" を使うことができます。柔軟性のために、デコレータに ハッシュ関数 を提供することもできます。それは多くのものから一つの値を作る方法です。

実用的な多くのアプリケーションでは、第3の策で十分ですので、それで進めます。

また、func.callx だけでなくすべての引数を渡す必要があります。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つの変更があります:

  • (*) では、hash を呼び出し、arguments から単一のキーを生成しています。ここではシンプルな “結合” 関数を使用しています(引数 (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 呼び出しを実行します。

apply の使い方には若干の違いがあります。

  • スプレッド演算子 ...call へのリストとして 反復可能(iterable)args を渡すことができます。
  • apply配列ライク(array-like)args のみを許可します。

…また、実際の配列など、反復可能かつ配列ライクなオブジェクトの場合は、任意のいずれかを使用できますが、多くの JavaScript エンジンで内部の最適化がされるため、apply の方が恐らく高速です。

コンテキストと一緒にすべての引数を別の関数にわたすことは、call forwarding(呼び出しの転送) と言われます。

最もシンプルな形は以下です:

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

外部のコードがこのような wrapper は呼び出すと、元の関数 func の呼び出しと見分けはつきません。

メソッドの借用(Borrowing a method)

ハッシュ関数に小さな改善をしてみましょう:

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);

とは言っても、配列結合を使う簡単な方法があります。:

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

このトリックは メソッドの借用(method borrowing) と呼ばれます。

私たちは、通常の配列から結合メソッドを行いました(借りました): [].join。また、arguments のコンテキストで実行するため [].join.call を使っています。

なぜこれが動作するのでしょう?

これは、ネイティブメソッド 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] … などを一緒に結合します。これは意図的に任意の配列ライク(array-like) の this を許容する方法で書かれています(多くのメソッドがこの慣習に従っています)。そういうわけで this=arguments でも動きます。

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

一般的に、1つの小さい点を除けば、デコレートされている関数やメソッドを置き換えるのは安全です。もしオリジナルの関数に func.calledCount といったようなプロパティが含まれている場合、デコレートされた関数はラッパーであるためそれらは提供しません。したがって、それらを利用する際には注意が必要です。

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

一部のデコレータは独自のプロパティを提供することがあります。E.g. 関数が何回実行されたか、どれだけ時間がかかったかをカウントし、ラッパープロパティを介してこの情報を公開する、といったケースです。

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

サマリ

デコレータ は関数の振る舞いを変更するラッパーです。メインの仕事は引き続き元の関数により行われます。

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

cachingDecorator を実装するために、次のメソッドを学びました:

一般的な 呼び出し転送(call forwarding) は通常 apply で行われます。:

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

また、私たちはオブジェクトからメソッドを取得し、別のオブジェクトのコンテキストでそのメソッドを呼び出すと言う、 メソッドの借用 の例も見ました。配列のメソッドを取り、それらを引数 arguments に適用するのはよくあることです。代替としては、本当の配列である残りのパラメータオブジェクトを使うこと、があります。

多くのデコレータが世の中に出回っています。このチャプターのタスクを解決することで、いかにデコレータが良いものかを確認してください。

タスク

重要性: 5

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

すべての呼び出しは引数の配列として格納されます。

For instance:

function work(a, b) {
  alert( a + b ); // work は任意の関数またはメソッド
}

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"
}

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

テストと一緒にサンドボックスを開く

ここでは、ログにすべての引数を格納するために calls.push(args) を使い、呼び出しをフォワードするために、f.apply(this, args) を使う事ができます。

function spy(func) {

  function wrapper(...args) {
    wrapper.calls.push(args);
    return func.apply(this, arguments);
  }

  wrapper.calls = [];

  return wrapper;
}

サンドボックスでテストと一緒に解答を開く

重要性: 5

f の各呼び出し毎に ms ミリ秒遅延をするデコレータ delay(f, ms) を作成してください。

例:

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

// ラッパーを作成
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // "test" は 1000ms 後に表示
f1500("test"); // "test" は 1500ms 後に表示

つまり、delay(f, ms)ms 遅延した” f のバリアントを返します。

上のコードにおいて、f は単一引数の関数ですが、あなたの解答はすべての引数とコンテキスト this を渡すようにしてください。

テストと一緒にサンドボックスを開く

解答:

function delay(f, ms) {

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

}

ここで、アロー関数がどう使われているか注意してください。ご存知の通り、アロー関数は独自の thisarguments を持ちません。なので、f.apply(this, arguments) はラッパーから thisarguments を取ります。

もし、通常の関数を渡す場合、setTimeout はそれを引数なしで、 this=window (ブラウザの場合) で呼び出します。なので、ラッパーからそれらを渡すようにコードを書く必要があります。:

function delay(f, ms) {

  // setTimeout の中で、ラッパーから this と 引数を渡すための変数を追加
  return function(...args) {
    let savedThis = this;
    setTimeout(function() {
      f.apply(savedThis, args);
    }, ms);
  };

}
function delay(f, ms) {

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

};

サンドボックスでテストと一緒に解答を開く

重要性: 5

debounce(f, ms) デコレータの結果は、ms ミリ秒毎に最大一度 f への呼び出しを渡すラッパーです。

言い換えると、“デバウンス” 関数を呼び出すと、最も近い ms ミリ秒までの他の未来はすべて無視されることが保証されます。

例:

let f = debounce(alert, 1000);

f(1); // すぐに実行される
f(2); // 無視される

setTimeout( () => f(3), 100); // 無視される (100 ms だけ経過した)
setTimeout( () => f(4), 1100); // 実行される
setTimeout( () => f(5), 1500); // 無視される (最後の実行から 1000ms 経過していない)

実践において、debounce はこのような短い期間の中で新しいことができないことを知ったときに、何かを取得/更新する関数に対して役立ちます,リソースを無駄にしないように。

テストと一緒にサンドボックスを開く

function debounce(f, ms) {

  let isCooldown = false;

  return function() {
    if (isCooldown) return;

    f.apply(this, arguments);

    isCooldown = true;

    setTimeout(() => isCooldown = false, ms);
  };

}

debounce 呼び出しはラッパーを返します。そこには2つの状態があります:

  • isCooldown = false – 実行する準備ができている
  • isCooldown = true – タイムアウトを待っている

最初の呼び出しで、 isCooldown は偽なので、呼び出しは処理され、状態は true になります。

isCooldown が true の間、すべての他の呼び出しは無視されます。

その後、与えられた遅延後に setTimeout がそれを false に戻します。

function debounce(f, ms) {

  let isCooldown = false;

  return function() {
    if (isCooldown) return;

    f.apply(this, arguments);

    isCooldown = true;

    setTimeout(() => isCooldown = false, ms);
  };

}

サンドボックスでテストと一緒に解答を開く

重要性: 5

“スロットリング” デコレータ throttle(f, ms) を作ります – これはラッパーを返し、ms ミリ秒毎に最大一度 f の呼び出しを渡します。“クールダウン” 期間に入る呼び出しは無視されます。

debounce との違いは – もし無視された呼び出しがクールダウン中の最後のものであれば、遅延の終わりにそれを実行します。

実際のアプリケーションを確認して、要件のよりよい理解とそれがどこから来るのかを見てみましょう。

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

ブラウザでは、マウスのマイクロレベルの動きに対して実行される関数を設定し、移動に応じたポインタの場所を取得する事ができます。マウス使用中、この関数は通常とても頻繁に実行され、1秒あたり100回(10ミリ秒毎)程度になります。

この追跡関数は web ページ上の一部の情報を更新する必要があります。

更新を行う関数 update() はマイクロレベルの移動で実行するには重すぎます。また、100ms に1回より多くの頻度で実行しても意味がありません。

従って、オリジナルの update() の代わりにマウス移動毎に実行する関数として throttle(update, 100) を割り当てます。このデコレータは頻繁に呼ばれても、update() が呼ばれるのは 100ms 毎に最大一回です。

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

  1. 最初のマウスの移動に対して、デコレートされたバリアントは update へ呼び出しを渡します。これは重要で、ユーザは自身の移動に対するリアクションがすぐに見えます。
  2. その後、マウスが移動するのに対し 100ms まで何も起こりません。デコレータは呼び出しを無視します。
  3. 100ms が経過すると – 最後の座標でもう一度 update が発生します。
  4. そして最終的に、マウスはどこかで停止します。デコレートされたバリアントは 100ms の期限まで待ち、その後最後の座標で update を実行します。従って、恐らく最も重要な最後のマウス座標は処理されます。

コード例:

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

// f1000 は、1000ms 毎に最大1回 f へ呼び出しを渡します
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

P.S. f1000 に渡される引数とコンテキスト this はオリジナルの f に渡される必要があります。

テストと一緒にサンドボックスを開く

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

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

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

    isThrottled = true;

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

  return wrapper;
}

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

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

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

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) {
      // memo last arguments to call after the cooldown
      savedArgs = arguments;
      savedThis = this;
      return;
    }

    // otherwise go to cooldown state
    func.apply(this, arguments);

    isThrottled = true;

    // plan to reset isThrottled after the delay
    setTimeout(function() {
      isThrottled = false;
      if (savedArgs) {
        // if there were calls, savedThis/savedArgs have the last one
        // recursive call runs the function and sets cooldown again
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

サンドボックスでテストと一緒に解答を開く

チュートリアルマップ