演算子のオーバーロード


単項演算子のオーバーロード

単純型とは異なり、オブジェクトを加算したり、減算するなどの算術式にかけることはできません。 C 言語的な発想では、オブジェクトとは一定の情報をパッケージとして管理、制御するメモリ領域への参照、すなわちポインタに過ぎないと考えられます。 だとすれば、オブジェクトを算術するという行為はポインタの値を算術することに繋がりますが、無秩序なアドレスの変更はプログラムのロジックを破壊すること以外には繋がりません。

ところが、概念的に算術演算が可能なクラスというものも多く存在します。 日時、座標、サイズ、色、行列などでは実体が数値の集合なので、概念的に演算を行うことができます。 あるいは入出力やスタックなどで << や >> を本来の意味とは違う概念でシンボル的に利用するという方法も考えられるでしょう。(C++ のように)

D 言語では、このような概念的なオブジェクトの算術を実現する演算子のオーバーロードを行うことができます。 演算子のオーバーロードでは、オブジェクトに対して定められた演算子が使われた場合に特定のメンバ関数を呼び出すというサービスです。 理論的にはプロパティ同様に、演算記述をメンバ関数の呼び出しにコンパイラが変換しているだけであると捉えることもできます。

D 言語では言語仕様であらかじめ、演算子に対応するメンバ関数名が定められています。 所定のメンバ関数を宣言すれば、そのクラスのオブジェクトに対して対応する演算が行われたとき、そのメンバ関数が自動的に呼び出されるという仕組みになっています。

単項演算子に対応したメンバ関数は次のように定められています。

演算子関数名
-opNeg
~opCom
e++opPostInc
e--opPostDec

これらの単項演算子は他の項は存在しないため、オブジェクト単体に作用します。 メンバ関数は引数を受け取りません。 この関数名でメンバ関数を宣言すれば、オブジェクトに対して対応する演算が行われたときにメンバ関数が呼び出されます。

import std.stream;

class Point {
	int x , y;
	this(int x, int y) {
		this.x = x;
		this.y = y;
	}
	Point opNeg() {
		return new Point(-x, -y);
	}
	Point opPostInc() {
		return new Point(x+1 , y+1);
	}
}

int main() {
	Point pt1 = new Point(10 , 50);
	Point pt2 = -pt1;
	Point pt3 = pt1++;

	stdout.printf("pt1 X=%d, Y=%d\n", pt1.x, pt1.y);
	stdout.printf("pt2 X=%d, Y=%d\n", pt2.x, pt2.y);
	stdout.printf("pt3 X=%d, Y=%d\n", pt3.x, pt3.y);
	return 0;
}

このプログラムでは、- 単項演算子と ++ 単項演算子に対応した Point クラスを宣言しています。 opNeg() メンバ関数は - 単項演算子に対応しています。 座標を表す Point オブジェクトを負で反転した結果というものは、概念的に座標値を負で反転したものだと解釈することができるため、このプログラムでは x 座標と y 座標を負にした Point オブジェクトを返します。

同様に opPostInc() メンバ関数はインクリメント演算 ++ に対応するため、座標値をそれぞれインクリメントした Point オブジェクトを返しています。 しかし、実体は -pt1 という記述が pt1.opNeg() に、pt1++ が pt1.opPostInc() 似変換されているだけであるとも解釈することができます。 演算子をオーバーロードするメンバ関数が、必ずしも演算子が持っている本来の意味に従った処理をするとは限りません。

因みに、前置インクリメント・デクリメント演算子は意味論的に (e += 1) と同等と定義されているので、 ++e は (e += 1) と解釈されます。 --e についても同様です。 最終的には += や -= の 2 項演算子がオーバーロードされているかどうかが判定されるでしょう。


2項演算子のオーバーロード

2 項演算子の場合は、単項演算子とは異なり引数を受けることになります。 オブジェクトと同時に演算されるもう一方の項は項の型に対応するメンバ関数の引数に渡されます。 演算子をオーバーロードするメソッドを複数用意すれば、オブジェクトは様々な型との演算を事実上可能とすることができます。

演算子可換かどうか関数名_r関数名
+ 可変 opAdd -
- 非可変 opSub opSub_r
* 可変 opMul -
/ 非可変 opDiv opDiv_r
% 非可変 opMod opMod_r
& 可変 opAnd -
| 可変 opOr -
^ 可変 opXor -
<< 非可変 opShl opShl_r
>> 非可変 opShr opShr_r
>>> 非可変 opUShr opUShr_r
~ 非可変 opCat opCat_r
== 可変 opEquals -
!= 可変 opEquals -
< 可変 opCmp -
<= 可変 opCmp -
> 可変 opCmp -
>= 可変 opCmp -
+= 非可変 opAddAssign -
-= 非可変 opSubAssign -
*= 非可変 opMulAssign -
/= 非可変 opDivAssign -
%= 非可変 opModAssign -
&= 非可変 opAndAssign -
|= 非可変 opOrAssign -
^= 非可変 opXorAssign -
<<= 非可変 opShlAssign -
>>= 非可変 opShrAssign -
>>>= 非可変 opUShrAssign -
~= 非可変 opCatAssign -

2 項演算子のオーバーロードでは、可変かどうかという性質と _r 関数という通常の演算子オーバーロード用の関数とは異なる関数が一部定義されています。 これらは、オブジェクトが演算子の左オペランドとなるか、右オペランドになるかの問題に関係しています。 この問題については後述します。

一般的なオブジェクトの 2 項演算では、オブジェクトの演算子に対応したメンバ関数を、もう一方のオペランドを引数として呼び出そうとします。 例えば obj + 10 という演算を行った場合 obj . opAdd(10) と同じであると考えることができます。

では、引数型が同一の演算子のオーバーロードを宣言しているクラス型と同じである場合、メンバ関数呼び出しの解釈が重複しないかという不安があるかもしれません。 幸い、obj1 + obj2 という演算でも、単純に obj1 . opAdd(obj2) と解釈されます。 obj1 . opAdd(obj2) を評価したあと、obj2 . opAdd(obj1) が評価されるようなことはありません。

import std.stream;

class Point {
	int x , y;
	this(int x, int y) {
		this.x = x;
		this.y = y;
	}
	Point opAdd(Point pt) {
		return new Point(x + pt.x , y + pt.y);
	}
	Point opSub(Point pt) {
		return new Point(x - pt.x , y - pt.y);
	}
}

int main() {
	Point pt1 = new Point(10 , 50);
	Point pt2 = new Point(100 , 200);
	Point pt3 = pt1 + pt2;
	Point pt4 = pt2 - pt1;

	stdout.printf("pt1 X=%d, Y=%d\n", pt1.x, pt1.y);
	stdout.printf("pt2 X=%d, Y=%d\n", pt2.x, pt2.y);
	stdout.printf("pt3 X=%d, Y=%d\n", pt3.x, pt3.y);
	stdout.printf("pt4 X=%d, Y=%d\n", pt4.x, pt4.y);
	return 0;
}

このプログラムは + 加算演算子と - 減算演算子をオーバーロードする Point クラスを宣言しています。 この Point クラスのオブジェクト同士を加算させたり減算させたりした場合、互いのオブジェクトの座標を計算した新しい Point オブジェクトを生成して返します。

上のプログラムでは、双方のオペランドが Point オブジェクトでなければ演算を行うことができません。 しかし、例えば整数と算術を行うことができるようなオブジェクトの場合、オブジェクトが→オペランドに指定される可能性もあります。 次のような式を考えてください。

100 + obj

上記の表で示されていた可変か非可変かという問題と _r 関数名の存在は、このような記述の演算に対応するためものだったのです。 このような計算が行われたとき、D 言語がどのように式を解釈するかは明確に定められています。

上記のプログラムのように左オペランドがオブジェクト型である場合、式は右オペランドを引数として対応するメンバ関数を呼び出します。

左オペランドがオブジェクト型ではない、または対応するメンバ関数が存在しない場合、右オペランドがオブジェクト型かどうかを調べます。 演算子が非可変で、かつ右オペランドが演算子に対応する _r メンバ関数を宣言している場合はこれを呼び出します。

そうではなく、右オペランドがオブジェクト型で、演算子が可変で、かつ演算子に対応するメンバ関数を宣言している場合はこれを呼び出します。

そうではなく、どちらかのオペランドがオブジェクト型の場合、対応するメンバ関数が存在しないためコンパイルエラーとなります。

なぜ、オペランドの位置によって呼び出されるべきメンバ関数を分ける必要があるのかは、演算子の本来の意味が関係しています。 1 + 2 と 2 + 1 は同じですが、1 - 2 と 2 - 1 はまったく異なります。 そのため、このようなオペランドの位置が意味を持つ演算子の場合はメンバ関数を分けているのです。

import std.stream;

class Point {
	int x , y;
	this(int x, int y) {
		this.x = x;
		this.y = y;
	}
	Point opAdd(int pt) {
		stdout.printf("opAdd : X=%d+%d , Y=%d+%d\n" , x , pt , y , pt);
		return new Point(x + pt , y + pt);
	}
	Point opSub(int pt) {
		stdout.printf("opSub : X=%d-%d , Y=%d-%d\n" , x , pt , y , pt);
		return new Point(x - pt , y - pt);
	}
	Point opSub_r(int pt) {
		stdout.printf("opSub_r : X=%d-%d , Y=%d-%d\n" , pt , x , pt , y);
		return new Point(pt - x , pt - y);
	}
}

int main() {
	Point pt1 = new Point(100 , 400);
	Point pt2 = pt1 - 30;
	Point pt3 = 20 - pt1;
	Point pt4 = pt1 + 50;
	Point pt5 = 50 + pt1;
	return 0;
}

このプログラムは、オブジェクトが左オペランドに配置されている場合と、右オペランドに配置されている場合の違いを実証しています。 加算処理の opAdd() の場合はどちらも同じですが、減算処理は opSub() と opSub_r() に分離されています。


比較演算子のオーバーロード

== と != 演算子に対応するメンバ関数は同じ opEquals() メンバ関数でした。 演算子が異なるのに呼び出されるメンバ関数が同じであるというのは奇妙のようですが、この等価演算では != は == の式を否定するだけなので、本来の演算子の意味で考えると呼び出すべき関数は統一するべきだと考えられるためです。

obj1 == obj2 という式は obj1.opEquals(obj2) に変換されます。 != 演算子の本来の意味はこれを否定することなので obj1 != obj2 という式は !(obj1.opEquals(obj2)) と変換されるのです。

import std.stream;

class Point {
	int x , y;
	this(int x, int y) {
		this.x = x;
		this.y = y;
	}
	int opEquals(Point pt) {
		return (this.x == pt.x && this.y == pt.y);
	}
}

int main() {
	Point pt1 = new Point(100 , 400);
	Point pt2 = new Point(100 , 400);
	Point pt3 = new Point(10 , 40);

	stdout.printf("pt1 == pt2 : %d\n" , pt1 == pt2);
	stdout.printf("pt1 == pt3 : %d\n" , pt1 == pt3);
	stdout.printf("pt1 != pt2 : %d\n" , pt1 != pt2);
	stdout.printf("pt1 != pt3 : %d\n" , pt1 != pt3);
	return 0;
}

このプログラムでは、== 演算子と != 演算子をオーバーロードし、指定した座標オブジェクトが自分自身と同じかどうかを判定する Point クラスを宣言しています。 Point オブジェクト同士を == または != 演算で比較しようとすると opEquals() メンバ関数が呼び出されます。

<、<=、>、>= 比較演算子も、同様に opCmp() という名前のメンバ関数を共有しています。 これらの演算子の本来の意味は大小の比較なので最終的に返す値は 0 かそれ以外の値となります。 そこで、これらの演算子でオブジェクトが比較された場合、opCmp() メンバ関数が返した値と 0 が使われた演算子で比較されます。

例えば obj1 < obj2 という演算が行われた場合 (obj1.opCmp(obj2) < 0) に変換されると考えられます。 obj1 <= obj2 であれば (obj1.opCmp(obj2) <= 0)、obj1 > obj2 であれば (obj1.opCmp(obj2) > 0)、obj1 >= obj2 であれば (obj1.opCmp(obj2) >= 0) という形になるでしょう。

import std.stream;

class Integer {
	int value;
	this(int value) { this.value = value; }
	int Value() { return this.value; }
	void Value(int vlaue) { this.value = value; }

	int opCmp(Integer target) {
		return opCmp(target.Value);
	}
	int opCmp(int value) {
		if (this.value < value) return -1;
		else if (this.value > value) return 1;
		else return 0;
	}
}

int main() {
	Integer obj1 = new Integer(10);
	Integer obj2 = new Integer(100);

	stdout.printf("obj1(%d) > obj2(%d) = %d\n",
		obj1.Value , obj2. Value , obj1 > obj2);
	stdout.printf("obj1(%d) >= obj2(%d) = %d\n",
		obj1.Value , obj2. Value , obj1 >= obj2);
	stdout.printf("obj1(%d) < obj2(%d) = %d\n",
		obj1.Value , obj2. Value , obj1 < obj2);
	stdout.printf("obj1(%d) <= obj2(%d) = %d\n",
		obj1.Value , obj2. Value , obj1 <= obj2);
	return 0;
}

このプログラムは、整数をパッケージ化してオブジェクトとして扱う Integer クラスを宣言しています。 Integer クラスが整数のオブジェクト表現だとすれば、整数オブジェクトどうしが互いに比較し合う処理は自然に行われるべきでしょう。

比較演算子のオーバーロードは、最終的に opCmp() メンバ関数が返した結果と 0 が比較されるという性質があります。 これを利用し、引数に与えられた他項とオブジェクトを比較し、オブジェクトより大きい場合は 1 (0 よりも大きい値)、オブジェクトより小さい場合は -1 (0 よりも小さい値)、同一の場合は 0 を返します。 こうすることで、オブジェクトを正しく他の値を比較することができるでしょう。


関数呼び出し演算子のオーバーロード

関数呼び出し演算子 ( ) をオーバーロードすることができます。 この演算子をオーバーロードするメンバ関数は opCall() という名前で宣言されなければなりません。

関数呼び出し演算子をオーバーロードすることによって、オブジェクトをあたかも関数のように扱うことができます。 オブジェクトの主要な機能を呼び出す手段として利用することができるでしょう。

import std.stream;

class Color {
	int r , g , b;
	void opCall(int r , int g , int b) {
		this.r = r;
		this.g = g;
		this.b = b;
	}
	int opCall() {
		return ((r << 16) | (g << 8) | b);
	}
}

int main() {
	Color color = new Color();

	color(255 , 100 , 180);
	stdout.printf("color = %X", color());
	return 0;
}

このプログラムの Color クラスでは、関数呼び出し演算子をオーバーロードする opCall() メンバ関数が 2 つ宣言されています。 一方は Color オブジェクトが表す色を設定するためのメンバ関数、もう一方は色を表す 32 ビットの値を得るメンバ関数です。 main() 関数を見てわかるように Color クラスのオブジェクト color を、まるで関数のように呼び出していることがわかります。

ただし、グローバルな関数を持つ D 言語ではこのような表現が既存のグローバル関数を隠蔽してしまう可能性があります。 D 言語は識別子をより内側のスコープから検索するため、上記のプログラムで color() という名前の関数が宣言されていたとしてもオブジェクトのオーバーロードされた関数を優先的に呼び出します。 すなわち color はオブジェクトの識別子であると判断され、グローバルな関数の名前は隠蔽されてしまうのです。

この問題を解決するには識別子を内側のスコープではなく、グローバルなスコープから検索するようにコンパイラに指定する必要があります。 D 言語では識別子の直前に . を指定した場合、式はグローバルなスコープ(モジュールスコープ)から検索されると定められています。

. 識別子

もし、上記のプログラムで color() 関数がグローバルな位置で宣言されている場合、color() 関数を main() 関数から呼び出すには .color() という形で指定すればよいのです。

import std.stream;

class Color {
	int r , g , b;
	void opCall(int r , int g , int b) {
		this.r = r;
		this.g = g;
		this.b = b;
	}
	int opCall() {
		return ((r << 16) | (g << 8) | b);
	}
}

int color() {
	return 0;
}

int main() {
	Color color = new Color();

	color(255 , 100 , 180);
	stdout.printf("color = %X", .color());
	return 0;
}

このプログラムの main() 関数の printf() 関数の呼び出しでは color 識別子の直前に . が指定されているため、color() 関数が優先的に呼び出されます。 そうでなければ、内側のスコープである color オブジェクトが優先されてしまうことを確認できるでしょう。



前のページへ戻る次のページへ