13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[小ネタ]JavaScriptで構造体のようなものを構造体っぽく呼び出す

Last updated at Posted at 2017-07-21

前説

JavaScriptには俗に「構造体」と呼ばれるものはありません。(ないよね?)
ので、大体はクラスを構造体代わりに

function Position(x, y) {
	this.x = x;
	this.y = y;
}

console.log(new Position(0, 1)); // Position {x: 0, y: 1}

とするわけですが、言語によってはnewがなくても呼び出せたりします。

なんとなく、構造体であることをはっきりさせるためにこの「newがなくても呼び出せる」をやりたいという。

ちなみに何も考えずにnewを外すと

function Position(x, y) {
	this.x = x;
	this.y = y;
}

console.log(new Position(0, 1)); // Position {x: 0, y: 1}
console.log(Position(0, 1)); // undefined

まあ単なる関数呼び出しなので、returnしていなければこうなります。

作ってみよう

①コンストラクタ関数で場合分けする

function Position(x, y) {
	if (!(this instanceof Position)) {
		return new Position(x, y);
	} // (A)

	this.x = x;
	this.y = y;
}

console.log(new Position(0, 1)); // Position {x: 0, y: 1}
console.log(Position(0, 1)); // Position {x: 0, y: 1}

コンストラクタを関数として呼び出す場合、関数内部のthisは当然クラスインスタンスにならないので
それを判定に使う感じ。
よくコンストラクタが関数として呼ばれてしまった場合の補正措置として登場しますね。

ただこれだと、構造体的なものを定義するたびに
(A)の部分を書かないといけないのがちょっと面倒。

②もうちょっと手を加える

構造体コンストラクタを生成する関数を考えてみる。

必要な要件としては

  • コンストラクタ呼び出しでも関数呼び出しでも、そのクラスのインスタンスを返す
  • 構造によって引数の数や名前が違うのをどうにかする
  • 構造体の中身の定義はなるべくシンプルに

あたりでしょうか。

こうなるとファクトリメソッド的なものを作るのが一番ですね。

function struct(func) {
	return function() {
		var f = new func();
		func.apply(f, arguments); // (C)
		return f;
	}; // (B)
}

var Position = struct(function Position(x, y) {
	this.x = x;
	this.y = y;
});

var Rect = struct(function Rect(x, y, width, height) {
	this.x = x;
	this.y = y;
	this.width = width;
	this.height = height;
});

console.log(new Position(0, 1)); // Position {x: 0, y: 1}
console.log(Position(0, 1)); // Position {x: 0, y: 1}

console.log(new Rect(0, 1, 2, 3)); // Rect {x: 0, y: 1, width: 2, height: 3}
console.log(Rect(0, 1, 2, 3)); // Rect {x: 0, y: 1, width: 2, height: 3}

本体となるコンストラクタ関数をstruct関数に送ることで、
(B)でファクトリメソッド的なものを返します。

で、そのファクトリメソッドですが、
コンストラクタごとに引数が異なるので、ほぼFunction#applyでの実装が必須です。
(arguments.lengthを見て場合分けだと限界が…)

ただ、Function#applyは関数呼び出しであり、コンストラクタ呼び出しではないので
applyの結果を返すわけにはいきません。
なので、(C)のようなワンクッション置くような手法を取っています。

これで完成(?)

…したように見えて、実は問題があります。

console.log(new Position(0, 1) instanceof Position); // false
console.log(Position(0, 1) instanceof Position); // false

console.log(new Rect(0, 1, 2, 3) instanceof Rect); // false
console.log(Rect(0, 1, 2, 3) instanceof Rect); // false

PositionオブジェクトなのにPositionクラスのインスタンスではないという結果。

まあよくよく考えてみると至極当然の話で、Position変数に格納された関数を実行すると
structの引数に送った関数をクラスとしたときのクラスインスタンスを返しますが
Position関数自体はただのファクトリメソッドなので、クラスとしての同一性・親子関係はありません。

Positionオブジェクトが返っているのは、structの引数に送った関数の名前がPositionだからなだけで

var Position = struct(function A(x, y) {
	this.x = x;
	this.y = y;
});

console.log(new Position(0, 1)); // A {x: 0, y: 1}
console.log(Position(0, 1)); // A {x: 0, y: 1}

名前を変えるとこのとおりダメダメでした。

function Position(x, y) {
	this.x = x;
	this.y = y;
}

var StructPosition = struct(Position);

console.log(new StructPosition(0, 1) instanceof Position); // true
console.log(StructPosition(0, 1) instanceof Position); // true

とすることでクラス比較は通りますが…
うーん、そもそも構造体インスタンスと構造体クラスを比較してないのがいまいち。

③さらに手を加える

クラス比較が通らないと困るので、

  • 返ってくるインスタンスは、ファクトリメソッドのクラスインスタンスである

という要件が追加されます。

これは、②structの引数に送った関数をnewしていたところを
ファクトリメソッド自身をnewすれば解決しそうです。

function struct(func) {
	return function() {
		if (!(this instanceof arguments.callee)) {
			var f = new arguments.callee();
			func.apply(f, arguments);
			return f;
		}
		return func.apply(this, arguments);
	};
}

var Position = struct(function(x, y) {
	this.x = x;
	this.y = y;
});

var Rect = struct(function(x, y, width, height) {
	this.x = x;
	this.y = y;
	this.width = width;
	this.height = height;
});

console.log(new Position(0, 1)); // Object {x: 0, y: 1}
console.log(Position(0, 1)); // Object {x: 0, y: 1}

console.log(new Rect(0, 1, 2, 3)); // Object {x: 0, y: 1, width: 2, height: 3}
console.log(Rect(0, 1, 2, 3)); // Object {x: 0, y: 1, width: 2, height: 3}

console.log(new Position(0, 1) instanceof Position); // true
console.log(Position(0, 1) instanceof Position); // true

console.log(new Rect(0, 1, 2, 3) instanceof Rect); // true
console.log(Rect(0, 1, 2, 3) instanceof Rect); // true

やったね!

気になる点としては

  1. console.logで表示されるオブジェクトの型がObjectである
  2. 結局のところ、構造体クラスの本体と比較をしていない

あたりになりますが、重要なのはあるオブジェクトが期待するクラスインスタンスになっているか
だと思っているので、あるファクトリメソッドが返すオブジェクトが、
必ず単一クラスのインスタンスでさえあればいいのかなーという感じでいます。

余談

コンストラクタ呼び出しされた関数の特性として、
そのクラスインスタンスを返すとは限らないというものがあります。

function A() {
	return 1;
}

function B() {
	return {};
}

function C() {
	return null;
}

function D() {
	return [];
}

function E() {
	return new A();
}

function F() {
	return function G() {};
}

console.log(new A()); // A {}
console.log(new B()); // Object {}
console.log(new C()); // C {}
console.log(new D()); // []
console.log(new E()); // A {}
console.log(new F()); // function G() {}

詳しくはどこかに仕様が載っていそうな気がしますが
これを見る限り、関数をコンストラクタ呼び出しした時の戻りは

  • 必ず参照型である
  • returnが明示されていない、または値型をreturnした場合は、その関数をクラスとしたときのクラスインスタンス
  • 参照型をreturnした場合はその参照

となっているみたいですね。
今回はこの特性を利用した形になっています。

終わりに

構造体を1つ作るのにこんなに関数呼ぶくらいなら
潔くnew付きで呼び出した方がいいと思いました。

13
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?