ハイサイ!オースティンやいびーん。
概要
ES6クラスで定義したオブジェクトを自動的にプリミティブ型に変換する方法を紹介します。
要するに、オブジェクトなのに、掛け算、割り算など、文字列の連結などが簡単にできるということです。
なぜ
最近、筆者はAngular 16のSignalについていくつか記事を書いていますが、Signalをテンプレート、もしくはcomputed
のコールバックで使う時に、そのSignalを呼ぶ「()
」必要があることに違和感を感じています。
数値を保持しているSignalなら、this.count * this.multiplier
みたいに扱えたらいいのになあという欲望が湧いたのです。
それも、できることを知っているから欲しいと思うのです。
原始的なSignal
を実装する
まず、原始的なSignalを実装しましょう。
Signalは二つの要件を満たす必要があります:
- 自身の値を保持する
- 保持している値が変わった時にイベントを配信する(通知機能)
上記の二つの要件を満たした実装は以下の通りになります。
//@ts-check
/**
* @template T
*/
class Signal {
/** @type {T} */
#value;
/** @type {Set<(v: T) => void>} */
#subscriptions = new Set();
/**
* @param initValue {T}
*/
constructor(initValue) {
this.#value = initValue;
}
/** @param value {T} */
set(value) {
this.#value = value;
this.next();
}
/** @returns {T} */
get value() {
return this.#value
}
/**
* 購読者のコールバック関数を呼ぶ
* @private
*/
next() {
this.#subscriptions.forEach((sub) => sub(this.#value));
}
/**
* @param nextCallback {(V: T) => void}
* 購読を取りやめるコールバック(unsubscribe)を返す
* @returns () => boolean
*/
subscribe(nextCallback) {
this.#subscriptions.add(nextCallback);
return () => this.#subscriptions.delete(nextCallback);
}
}
上記の実装を以下のように試すことができます。ブラウザのコンソールでコピペーしてもOKです。
const count = new Signal(1);
count.subscribe((val) => console.log('Count changed: ', val));
count.set(2); // Count changed: 2
プリマティブとして動いてくれるように修正する
我々の素晴らしい英雄なるSignalを再発明したので、こいつがJavaScriptの演算と仲良くできるようにしましょう。
これを実現するためにはSymbol.toPrimitive
を使って、Signal
クラスにメソッドを定義します。
//@ts-check
/**
* @template T
*/
class Signal {
...
/**
* @param hint {string}
* @returns {any}
*/
[Symbol.toPrimitive](hint) {
switch (hint) {
case "string":
return String(this.#value);
case "number":
return Number(this.#value);
default:
return this.#value;
}
}
}
非常に簡単なのですが、強力的な結果になります。何が起きているのかを説明する前に、まず試してみましょう。
数字の演算だと以下のようにできます。
const count = new Signal(2);
const multiplier = new Signal(2);
console.log(count * multiplier); // 4
multiplier.set(3);
console.log(count ** multiplier); // 8
console.log(count - multiplier); // -1
console.log(count / multiplier); // 0.6666...
両方ともプリマティブじゃないのに、JavaScriptのインタープリターは難なく演算ができていますよね。魔法みたいです。
文字列でも同様にできます。
const firstName = new Signal('Austin');
const lastName = new Signal('Inoue');
console.log(lastName + firstName); // InoueAustin
lastName.set('Mayer');
console.log(firstName + lastName); // AustinMayer
何が起きているのか、魔法の[Symbol.toPrimitive]
メソッド
魔法に見えますが、JavaScriptのインタープリターが単に仕事をこなしているだけなのです。
上記の演算をやろうとした時に、
- インタープリターは数字かどうかをみます
- 数字じゃなかったら、数字に変換できないかを調べる
上記の場合は2に入りますが、「数字に変換できるかどうか」を調べる時に、オブジェクトだったらSymbol.toPrimitive
のプロパティがあるかどうかをみています。
あれば、myObject[Symbol.toPrimitive]('number')
を呼んで、その結果を持って計算を試みています。
JavaScriptは素晴らしいな、と思うのはこういうところです。Pythonもできたはずです。
実際に、[Symbol.toPrimitive]
のメソッドにconsole.log
を入れて実行してみましょう。
class Signal {
...
[Symbol.toPrimitive](hint) {
console.log('toPrimitive hint:', hint);
switch (hint) {
case "string":
return String(this.#value);
case "number":
return Number(this.#value);
default:
return this.#value;
}
}
}
もう一度演算の計算をやってみると以下のログが出力されます。
const count = new Signal(2);
const multiplier = new Signal(2);
console.log(count * multiplier); // toPrimitive hint: number
どう動いているのか見えてきたので、魔法じゃないことがわかります。
まとめ
JavaScriptのSymbol.toPrimitive
でオブジェクトでも演算などができるように、プリミティブ型として扱えるようにする方法を紹介しました。
筆者は、Angularのsignalのように、computedのコールバックでもsubscribe
が渡られるようにする方法を探っていきたいなと思っています。