🪄

黒魔術で無理やり演算子オーバーロードを実装して遊ぶ

2024/01/11に公開

あいさつ

演算子オーバーロード、夢ですよね。
Python等の一部言語には言語仕様として実装されていますが、我らがJavaScriptには実装されていません。
しかし、言語の中でも屈指の変態度自由度を誇る我らがJavaScript、何らかの方法はあるはずです。
その方法を解明するため、我々調査隊はアマゾンの奥地へと向かった――。

今回は黒魔術をこねこねして演算子オーバーロードっぽいものを簡単に実装したので、備忘録がてら記事にまとめます。

※ ネタ記事であり、ネタアイデアです。遊ぶためには十分かもしれませんが、実用的ではないのでまともな手法を期待している方向けではありません。(具体的にはevalとか使います)
とはいえBabelを挟むような実装よりはマシだと思う
完成したあとの追記: 正直、やってることはBabelの二番煎じだと思う...
ただ、トランスパイルなどの工程を挟むことなく、フロントエンドでも動作するのは楽しいので、今回の方法にも意味はあると信じたい...

今回用いるクラス

Vector2
class Vector2 {
	constructor(x=0, y=0) {
		this.x = x;
		this.y = y;
	}
	add(vec) {
		const result = this.clone();
		result.x += vec.x; result.y += vec.y;
		return result;
	}
	sub(vec) {
		const result = this.clone();
		result.x -= vec.x; result.y -= vec.y;
		return result;
	}
	mul(scale) {
		const result = this.clone();
		result.x *= scale; result.y *= scale;
		return result;
	}
	div(scale) {
		const result = this.clone();
		result.x /= scale; result.y /= scale;
		return result;
	}
	clone() {
		return new Vector2(this.x, this.y);
	}
}

このクラスを以下のように使用できることを目標とします。

const vector1 = new Vector2(5, 10);
const vector2 = new Vector2(3, 4);

const vector3 = vector1 / 2 + vector2 * 3;

ちなみに上のサンプルコードがそのまま動くようにするのは不可能です。
あくまでこのように使用できるというだけです。

実装の方針

JavaScriptの言語仕様上、そのままのJavaScriptのコードを解釈して正規の手法で実行される演算子オーバーロードの仕組みを作るのはほぼ不可能です。 参考

なのでアプローチとしては、演算子オーバーロードを再現する部分のコードを文字列として取得し、簡易的にパース、計算を行う方針で行きます。

しかし、JavaScriptのコードの中に文字列でコードを埋め込むのはナンセンスです。

const result = calcit("vector1 / 2 + vector2 * 3"); //ダサい!!

そこで、アロー関数を用います。

アロー関数を文字列化する

これは難しいことでもなんでもなく、アロー関数にtoStringすることで文字列としてコードを取得することが可能になります。
そして、こちらの投稿を参考に邪魔な部分を取り除くようにしたものが以下のコードです。

function calcit(func) {
	const funcBody = func.toString().replace(/^[^{]*{\s*/,'').replace(/\s*}[^}]*$/,'').replaceAll(" ", "").replaceAll(";", "");
	
	return funcBody;
}

//使用例
const vector = calcit(() => {
	vector1 / 2 + vector2 * 3;
});
console.log(vector); // output: vector1/2+vector2*3

ちなみに文字列ではなくアロー関数を使うことには見た目以外にも利点があって、計算の中でカッコを使うときに、エディターの支援を受けながらインデントできたり、カッコの閉じ忘れがわかりやすくなったりします。

そしてかなり雑な書き方をしたので、高確率で何かしら事故ることでしょう

トークナイザを実装する

計算部分の実装に参考にしたコード
あくまで参考であって、パクリではないです

function tokenizer(expr) {
	const _expr = expr.replace(/[\-\\+\\*\\/\\(\\)]/g, ' $& ').trim();
	return _expr.split(/\s+/);
}

やってることは簡単で、演算子で分解してるだけです。

function calcit(func) {
	const funcBody = func.toString().replace(/^[^{]*{\s*/,'').replace(/\s*}[^}]*$/,'').replaceAll(" ", "").replaceAll(";", "");
	
	const tokens = tokenizer(funcBody);
	return tokens;
}

//使用例
const vector = calcit(() => {
	vector1 / 2 + vector2 * 3;
});
console.log(vector); // output: ["vector1", "/", "2", "+", "vector2", "*", "3"]

パーサーを実装する

const PRIORITY = {
	"+": 1,
	"-": 1,
	"*": 2,
	"/": 2
}

function tree(val, left=null, right=null) {
	const obj = {
		val: val,
		left: left ? left: null,
		right: right ? right: null,
		
		isLeaf: () => {
			return obj.left === null && obj.right === null;
		}
	};
	
	return obj;
}

function priority(tokens) {
	var bracket = 0;
	var minIdx = 0, minVal = Infinity;
	
	for (var i=0; i < tokens.length; i++) {
		const v = tokens[i];
		if (v === "(") bracket++;
		if (v === ")") bracket--;
		if (bracket === 0 && PRIORITY[v] < minVal) {
			minIdx = i;
			minVal = PRIORITY[v];
		}
	}
	
	return minIdx;
}

function parser(tokens) {
	if (tokens.length === 0) return null;
	if (tokens.length === 1) return tree(tokens.pop());
	
	if (tokens.indexOf("(") === 0 && tokens.lastIndexOf(")") === tokens.length - 1 && priority(tokens) === 0) {
		tokens = tokens.slice(1, tokens.length - 1);
	}
	
	const index = priority(tokens);
	const left = tokens.slice(0, index);
	const right = tokens.slice(index + 1, tokens.length);
	
	return tree(tokens[index], parser(left), parser(right));
}

中身は大体参考元と一緒ですが、一部改変しています。

function calcit(func) {
	const funcBody = func.toString().replace(/^[^{]*{\s*/,'').replace(/\s*}[^}]*$/,'').replaceAll(" ", "").replaceAll(";", "");
	
	const tokens = tokenizer(funcBody);
	const tree = parser(tokens);
	return tree;
}

//使用例
const vector = calcit(() => {
	vector1 / 2 + vector2 * 3;
});
console.log(vector);

実行すると以下の木を得ることのできる関数が完成しました。

計算部分を実装する

const OP = {
	"+": (x, y) => x.add(y),
	"-": (x, y) => x.sub(y),
	"*": (x, y) => x.mul(y),
	"/": (x, y) => x.div(y),
};

function normalize(input) {
	if (Number.isFinite(input)) {
		return Number.parseFloat(input);
	} else {
		return eval(input);
	}
}

function calctree(input) {
	if (input.isLeaf()) return input.val;
	return tree(OP[input.val](normalize(calctree(input.left)), normalize(calctree(input.right)))).val;
}

このコードでは、calcit関数を呼んだ状態のスコープで変数の中身を手に入れるために、evalを使用しています。
eval使うと実装が楽でいいよね

function calcit(func) {
	const funcBody = func.toString().replace(/^[^{]*{\s*/,'').replace(/\s*}[^}]*$/,'').replaceAll(" ", "").replaceAll(";", "");
	
	const tokens = tokenizer(funcBody);
	const tree = parser(tokens);
	const result = calctree(tree);
	return result;
}

//使用例
const vector1 = new Vector2(5, 10);
const vector2 = new Vector2(3, 4);

const vector = calcit(() => {
	vector1 / 2 + vector2 * 3;
});
console.log(vector); // output: Vector2 {x: 11.5, y: 17}

なんと完成してしまいました。
ちなみに、参考にしたコードが完成度が高いものだったおかげで、カッコにも対応しています。

const vector1 = new Vector2(5, 10);
const vector2 = new Vector2(3, 4);

const vector = calcit(() => {
	(vector1 / 2 + vector2 * 3) * 2;
});
console.log(vector); // output: Vector2 {x: 23, y: 34}

そして、このコードはvectorに限定されたものではなく、addsubmuldivの4つのメソッドを実装したクラスならば全てにおいて正常に動作します。
※場合によってはnormalize関数の中身を魔改造することで、変数を解決するときの動作を変更する必要があるかもしれません

しかしこのコードにも問題点はあります。
具体的に言うと、掛け算において数値が最初に来る場合に、きちんと計算を行うことができません。

const vector1 = new Vector2(5, 10);
const vector2 = new Vector2(3, 4);

const vector = calcit(() => {
	2 * (vector1 / 2 + vector2 * 3);
});
console.log(vector); // output: TypeError: x.mul is not a function

これに関しては、OPオブジェクトの中のmulを適切にいじることで解消できますが、実装が複雑になるのと、あくまで遊びのアイデアなのでこの記事では実装しません。
気になる方はぜひご自身で実装してみてください
俗に言う丸投げである

そして、アロー関数を中身だけにする部分が雑なせいで、引数のないアロー関数以外(functionだったり、引数ありのアロー関数だったり)を渡すと、暴走する。
だれか氏、適切な実装をください...()

他にも、vectorとvector同士の掛け算、割り算の場合にもエラーが発生します。
(というより返却されるvectorの中身がNaNになる)
これはVector2クラスに外積等のメソッドを実装し、mul、div関数の中に分岐を作ることで解消できますが面倒くさいので触れません

しかし、当初の目標であったできる限りJavaScriptのコードによせたまま演算子オーバーロードを行うことには成功したので、これにて成功ということにして、記事を終わります。

Discussion