C++編【言語解説】 第19章 演算子オーバーロード

このエントリーをはてなブックマークに追加

この章の概要

この章の概要です。

演算子オーバーロード

第16章で、代入演算子を自分で定義できることを確認しました。 このような、演算子の挙動を変更することを演算子オーバーロードと呼びます。

演算子オーバーロードは、ほとんど自由に演算子の挙動を変えることができてしまうので、 好き勝手に使うと、非常に危険であったり、理解不能なプログラムになってしまったりします。 例えば、加算を行う +演算子で、減算を行うように書き換えることもできてしまいます(当たり前ですが、こういうことはやめて下さい)。

演算子オーバーロードは、クラス定義の中に operator= のようなメンバ関数を書く方法の他に、 非メンバ関数として、クラス外に書く方法もあります。 ただ、後者の方法は、更なる機能の解説も必要になってくるため、本章では前者の方法に限定して取り上げます。 後者の方法は、第35章で取り上げます。

比較演算子

まずは、比較演算子から見ていきましょう。 比較演算子には、以下の演算子が含まれます。

比較演算子の場合、戻り値が bool型の constメンバ関数としてオーバーロードするのが普通です。 引数は、多くの場合は自身と同じクラス型を const参照で指定しますが、稀に異なる型との比較ができるようにすることがあります。

class DataStore {
public:
	bool operator==(const DataStore& rhs) const;

private:
	int    mValue;
};

bool DataStore::operator==(const DataStore& rhs) const
{
	return mValue == rhs.mValue;
}

==演算子をオーバーロードした場合は、!=演算子も必ずオーバーロードして下さい。 これは、「a == b」が真であれば「a != b」が偽であることと、その逆に「a == b」が偽なら「a != b」が真であることを期待するはずだからです。 ==演算子と !=演算子には、必ず対称性を持たせなければいけません。
ここで、!=演算子は必ず次のように実装するべきです。

class DataStore {
public:
	bool operator==(const DataStore& rhs) const;

	inline bool operator!=(const DataStore& rhs) const
	{
		return !(*this == rhs);
	}

private:
	int    mValue;
};

「*this == rhs」の部分で、operator==() が使われるので、この結果を !演算子で反転すれば、==演算子と !=演算子の結果に確実な対称性が生まれます。 また、この実装は非常に単純ですし、後から変更することもあり得ないので、inline関数にするのが良いでしょう。

このように、演算子オーバーロードでは、他の演算子を適用した結果との間で矛盾が生まれないように、注意して実装する必要があります。 <演算子と >演算子、<=演算子と >=演算子でも同じことが言えますが、 < の逆は >=、> の逆は <= であることに注意して下さい。

算術演算子

まず、四則演算に関わる演算子として、以下のものを取り上げます。

これらに代入を組み合わせた、+= などの複合代入演算子は後で取り上げます

本来、これらの演算子はクラス内で定義するよりも、クラス外に置いた方が良いのですが、 ここではあえてクラス内で定義する方法だけを扱います
クラス内で定義する場合、以下のような使い方に対応できません。

DataStore a;
a = 10 + a;  // a.mValue に 10 を加算するという意図

これがなぜ問題になるかというと、「10 + a」という式では「a.operator+(10)」を使いたいのだということがコンパイラに伝わらないのです。 「a + 10」であれば「a.operator+(10)」となるので、引数が int型の operator+() が定義されていれば問題ありません。 普通、「a + 10」と「10 + a」は同じ意味であって欲しいので、これは好ましくないでしょう。 この問題を解決する手段は、クラス外で operator+() を用意することなのですが、これは第35章まで先送りします。

「a + 10」を「a.operator+(10)」だと判断するルールを素直に適応すると、 「10 + a」は「10.operator+(a)」な訳ですが、10 はクラスでないのであり得ません。 そこで、「operator+(10, a)」が適合する関数を探す訳ですが、これに対応する operator+ はクラス外に書く必要があるということです。

このような問題があるとはいえ、クラス内で定義しても、両辺が同じクラスのオブジェクトならば、 入れ替えても同じになるので問題ありません。

class DataStore {
public:
	const DataStore operator+(const DataStore& rhs) const;

private:
	int    mValue;
};

const DataStore DataStore::operator+(const DataStore& rhs) const
{
	DataStore tmp;
	tmp.mValue = mValue + rhs.mValue;
	return tmp;
}

引数は、自身と同じクラス型を const参照で指定します。 繰り返しになりますが、他の型を指定したい場合は、クラス外で定義するべきです
戻り値は、自身のクラス型の "実体" です。 ポインタや参照にはできません。
また、constメンバ関数として定義します

実装の仕方にも注目して下さい。
「a + b;」という使い方を考えると分かりますが、結果的に a も b も変化しないはずなのです。 ですから、constメンバ関数にできますし、引数の方も const にできます。
結果を返すためには、ローカル変数を用意して、ここで計算させます。 その結果を return する訳ですが、ローカル変数なのでポインタや参照では返せませんから、 戻り値は "実体" でなければなりません。

このように定義した operator+ があれば、以下のように使用できます。

int main()
{
	DataStore a, b;
	a = a + b;  // a.mValue に b.mValue を加算するという意図
	a = b + a;  // b.operator+(a) になるが、実装に問題がなければ a.operator+(b) と同じ結果になるはず
}


四則演算の他、単項である ~演算子を除いたビット演算子も同様です。

ところで、これら算術演算子には、代入演算子と組みわせた複合代入演算子が存在しています。 複合代入演算子のオーバーロードの仕方を見ると分かるのですが、 一般的、単独の算術演算子よりも複合代入演算子の方が、効率面で優れています
そのため、「sum = a + b + c;」のような書き方よりも、

sum = a;
sum += b;
sum += c;

の方が、効率が良くなる可能性があります。 「a + b + c」だと、operator+ を呼び出すたびに、ローカルオブジェクトが作られ、これを実体で返すコストが掛かるためです。 複合代入演算子は、ローカルオブジェクトが不要であり、戻り値も参照になるので効率的です。

論理演算子

条件判定に使われる論理演算子もオーバーロードできます。

!演算子は単項になるので、上記の演算子とは少し違いがあります。 これは後で取り上げます

&&演算子、||演算子はオーバーロードすることができますが、実際にはしない方が良いです。 その理由は、短絡評価(C言語編第13章)との兼ね合いが取れないからです。 例えば、

DataStore a, b;
if (a && b) {}

このように書いたとき、この条件式が意味するのは「a.operator&&(b)」です。 関数呼び出しなので、実引数 b を決定しなければいけません。
しかし、短絡評価のルールでは、a が真であった時点で b は評価されないはずです。
このように、&&演算子や ||演算子をオーバーロードしてしまうと、式を評価するルールを乱してしまうのです。 これはクラスの利用者側を惑わせる結果になりますから、オーバーロードすることは避けた方が無難です。 実際問題、これらの演算子をオーバーロードしたい場面というのは、ほとんど無いと思います。

同じ問題を抱えている演算子がもう1つあります。

,演算子(C言語編第27章)は、2つの式を連結しますが、 必ず、左側の式を先に評価するというルールになっています。 しかし、,演算子をオーバーロードしていると、

a, b;

のように書いたとき、「a.operator,(b)」になるので、実引数を決定するために b が先に評価されてしまいます。 従って、やはりルールを乱すことになるので、,演算子のオーバーロードも避けるべきです

単項演算子

単項演算子として、まず以下の2つを取り上げます。

一瞬、何のことかと思うかも知れませんが、これは符号を表す演算子です。 「a = -b;」のように使う演算子です。
+ の方はほとんど使われる機会は無いですが、「a = +b;」のように使う演算子です。 これは「a = b;」と同じ意味になります。

符号を表す +演算子や -演算子と、四則演算を表す +演算子や -演算子は、 オーバーロードしたときの関数名は同じ operator+ や operator- になります。 そのため名前から区別することができないので、引数によって区別することになっています。 引数があれば四則演算の方を表し、引数がなければ符号の方を表します

class DataStore {
public:
	inline const DataStore operator+() const
	{
		return *this;
	}
	const DataStore operator-() const;

private:
	int    mValue;
};

const DataStore DataStore::operator-() const
{
	DataStore tmp;
	tmp.mValue = -mValue;
	return tmp;
}

四則演算のときと同様、使われ方からいって、自分自身は変化しないことに注意して下さい。 例えば、「int a = 10」とした変数a に -演算子を適用することを想像してみて下さい。

-a;      // これだけでは a の値は変わらない
a = -a;  // こうすると a の値が変わる

そのため、単項の +演算子、-演算子の場合も constメンバ関数にします。 単項なので、引数はありません。 そして、関数内でローカルオブジェクトを作り、ここで必要な計算を行って、実体で返すのが正解です

単項の +演算子は、普通は省略してしまうものですから、明示的に使用したとしても何も起こらないのが適切でしょう。 単に *this を return すればいいので、inline関数にして無用なコストを省くように実装します。


インクリメント、デクリメントの演算子も重要です。

これらは、前置と後置の区別をつける必要があります。 しかし「a++」は「a.operator++()」ですし、「++a」でも「a.operator++()」なので区別が付きそうにありません。 ここでは特別なルールが適用されていて、後置の場合には、こっそりと int型の実引数 0 が渡されることになっています。 従って、オーバーロードする際には、引数が無ければ前置になり、int型の引数を1つ取れば後置になります

前置と後置は、具体的な実装面でも違いがあります。 まず、前置を見ていきます。

class DataStore {
public:
	inline DataStore& operator++()
	{
		++mValue;
		return *this;
	}

private:
	int    mValue;
};

前置の場合、先にインクリメント(またはデクリメント)を行い、その結果を返せなければいけません。 これは上記の実装例のように、自身のメンバ変数に計算結果を適用して、自身の参照を返せば実現できます。

一方、後置の場合は、結果を返してからインクリメント(またはデクリメント)が行われる必要があります。 そのためには、返却用のオブジェクトを別途作成しなければなりません。

class DataStore {
public:
	const DataStore operator++(int);

private:
	int    mValue;
};

const DataStore DataStore::operator++(int)
{
	const DataStore tmp = *this;
	++(*this);
	return tmp;
}

返却用のオブジェクトをローカルオブジェクトとして定義し、これを返す形になるので、戻り値は実体になります。 前置だと参照にできるのに対して、こちらは実体になるという大きな違いがあります。

また、もう1つの大きなポイントは、肝心のインクリメント(デクリメント)を行う部分を「++(*this)」のような感じで、 前置のインクリメント(デクリメント)を呼び出す形で実装することです。 つまり、後置版は前置版を使って実装します。 こうすることで、前置と後置とで、処理内容が食い違ってしまうことを避け、保守性を向上させます。

さて、このような実装の違いを見ると分かるように、明らかに前置版の方が、後置版よりも実行効率が良いです。 従って、C++ でオブジェクトに対してインクリメントやデクリメントを行うときは、可能な限り、前置版を使うようにするべきです。


ポインタに関わる単項の演算子があります。

& はアドレス演算子、* は間接参照演算子です。 &演算子はビット演算子でも同じ記号を使っていますし、*演算子は乗算のものがありますから、 ここでも区別を付ける必要があります。 これらの演算子は、単項と二項という違いがありますから、引数によって区別を付けられます。

アドレス演算子、間接参照演算子については、実装上の制約はありませんが、 前者はメモリアドレスを返し、後者はクラスのオブジェクトを普通のポインタ変数のようにみなして、 指し示す先を(参照で)返すイメージで実装するべきです。

class DataStore {
public:
	inline const int* operator&() const
	{
		return &mValue;
	}

private:
	int    mValue;
};
class DataStorePtr {
public:
	inline const DataStore& operator*() const
	{
		return mPtr;
	}

private:
	DataStore*    mPtr;
};


ビット演算子の ~演算子も単項です。

これは単項の +演算子や -演算子と同じような感じです。 「a = ~a」のように使わない限り、自身の値は変化しないはずなので、constメンバ関数にできることと、 ローカルオブジェクトを作って、実体で返す形になることを確認して下さい。

class DataStore {
public:
	const DataStore operator~() const;

private:
	int    mValue;
};

const DataStore DataStore::operator~() const
{
	DataStore tmp;
	tmp.mValue = ~mValue;
	return tmp;
}


論理演算子の !演算子も単項です。

他の論理演算子 && と || については、オーバーロードしない方が良いと説明しました。 一方、!演算子はオーバーロードして問題ありませんし、割とよく使用されます。 第6章で登場したファイルストリームでも使いました。

std::ofstream ofs("hello.txt");
if (!ofs) {
	std::cerr << "ファイルオープンに失敗" << std::endl;
}

これは、std::ofstreamクラスが !演算子をオーバーロードしているからできることです。 この使われ方のように、!演算子は、何らかのエラーが起きているだとか、値が無効であるということを問い合わせる目的で使用されます。

class DataStore {
public:
	inline bool operator!() const
	{
		return mValue == 0;
	}

private:
	int    mValue;
};

代入演算子

代入演算子のオーバーロードについては、 第16章で詳細に取り上げていますから、そちらを参照して下さい。

複合代入演算子

以下の複合代入演算子がオーバーロードできます。

複合代入演算子のオーバーロードは、代入演算子と同じ形になります。 すなわち、自身と同じ型の参照を引数に取り、戻り値は *this を参照で返します。 また、メンバ変数の書き換えが起こるので、constメンバ関数にはなりません。

class DataStore {
public:
	inline DataStore& operator+=(const DataStore& rhs)
	{
		mValue += rhs.mValue;
		return *this;
	}

private:
	int    mValue;
};

なお、「a += a;」のような使われ方には意味があるので、普通の代入演算子で行われる自己代入への備えのようなものは不要です。

添字演算子

添字演算子もオーバーロードできます。

これは、配列のように機能できると便利なクラスに実装します。

class DataStoreArray {
public:
	DataStoreArray(std::size_t size) :
		mValueArray(new int[size])
	{}

	~DataStoreArray()
	{
		delete [] mValueArray;
	}

	inline int operator[](std::size_t index) const
	{
		return mValueArray[index];
	}

	inline int& operator[](std::size_t index)
	{
		return mValueArray[index];
	}

private:
	int*    mValueArray;
};

operator[] は、使い方が読み取りと書き込みの両方がある点に注意して下さい。

int num = a[3];  // 読み取り
a[3] = 100;      // 書き込み

読み取りの方は、constメンバ関数にできます。 この場合、返す値を書き換えられないように const参照で返すか、実体で返すようにします。
書き込みの方は、非constメンバ関数にします。 こちらは、戻り値に対して値を代入できなければならないので、必ず(非const の)参照で返すことになります。

添字が整数である必要はありません。 特によくあるのは、文字列を添字として使う方法で、名前を使ってデータを参照するという感覚で使用できるようになります。 このような、整数以外の値を添字に使う配列を、連想配列と呼びます。

関数呼び出し演算子

関数呼び出しに使う ( ) も演算子であり、オーバーロードできます。

これは、関数オブジェクトと呼ばれるオブジェクトの実装に使われるもので、 やや大きめの話題になるので、第34章で改めて取り上げます。

new/delete演算子

new演算子や delete演算子についても、オーバーロードできます。

これは少し高度な内容になるので、第34章で改めて取り上げます。

オーバーロードできない演算子

以下の演算子はオーバーロードすることができません。

また、次の演算子については、クラスのメンバ関数として定義しなければなりません (冒頭で取り上げたように、クラス外で行う演算子オーバーロードというものがあります)。

なお、C++ に存在していない演算子を新たに作り出すことはできません。 例えば、@演算子のようなものを作ろうとして、operator@ などという関数を定義することはできません。

変換演算子

既存の演算子の挙動を変える以外に、 変換演算子というものを定義するために operator を使うことができます。 変換演算子を定義しておくと、これを定義したクラスのオブジェクトを、別の型へ変換できるようになります。

class DataStore {
public:
	inline operator int() const
	{
		return mValue;
	}

private:
	int    mValue;
};

このように「operator 型名」とすることで、変換演算子を定義できます。 注意点として、戻り値の型を書かないことに注目して下さい(int operator int() ではありません)。 これは「operator 型名」だけで、戻り値の型は確定するからです。
この例では「operator int」なので、DataStoreクラスのオブジェクトは、int型へ変換可能になります。

DataStore a;
int b = a;  // OK

変換演算子は、うまく使うと非常に便利ですが、static_cast などのキャスト構文を使わずとも暗黙的に変換が行われるため、 意図しない変換をしてしまうことがあります。 次のようなメンバ関数を用意することでも目的は達せられるため、 変換演算子を用意するよりも、普通のメンバ関数を用意する方が安全ではあります。

class DataStore {
public:
	inline int ToInt() const
	{
		return mValue;
	}

private:
	int    mValue;
};

DataStore a;
int b = a.ToInt();  // 安全確実

C++11 になって、変換演算子を明示的な使い方だけを可能にする新機能が追加されています。 C++11以降が使えるのなら、この新機能を使えば安全です。

C++11 (明示的な変換演算子)

C++11

C++11 では、変換演算子の定義に explicitキーワードを付けることで、 暗黙的な型変換には変換演算子が働かないように制限をかけることができます。 明示的に型変換するためには、static_cast を使用します。

class DataStore {
public:
	inline explicit operator int() const
	{
		return mValue;
	}

private:
	int    mValue;
};

DataStore a;
int b = a;  // error
int c = static_cast<int>(a);  // OK

この機能は、VisualC++ 2013/2015/2017、clang 3.7 のいずれでも使用可能です

変換コンストラクタ

演算子オーバーロードとは関係無いですが、変換演算子に関係のある知識として、 変換コンストラクタについて触れておきます。

実は、引数を1つだけ指定して呼び出すことができるコンストラクタは、変換コンストラクタであるとみなされます。 仮引数が2つ以上あるとしても、2つ目以降がデフォルト値を持つ引数ならば、やはり変換コンストラクタになります。

変換コンストラクタを持つクラスのオブジェクトは、変換コンストラクタの引数の型から暗黙的に変換することができます。

class DataStore {
public:
	DataStore(int value) :
		mValue(value)
	{}

private:
	int    mValue;
};

DataStore a = 10;  // OK

この場合、int型からの暗黙的な変換を行う変換コンストラクタが存在しているため、 10 という整数からインスタンス化することが可能になります。

この程度の使用例ならば問題はありませんが、次のような使われ方になると、意図しない結果を生むかも知れません。

class DataStoreArray {
public:
	DataStoreArray(std::size_t size) :
		mValueArray(new int[size])
	{}

	~DataStoreArray()
	{
		delete [] mValueArray;
	}

	inline int operator[](std::size_t index) const
	{
		return mValueArray[index];
	}

	inline int& operator[](std::size_t index)
	{
		return mValueArray[index];
	}

private:
	int*    mValueArray;
};

void func(DataStoreArray array);

func(10);  // この 10 は意図したもの?

変換コンストラクタの機能は、変換演算子と同様、暗黙的に行われると意図しない結果を生むことがあるため、 できるだけ使わない方が良いとされます。 変換コンストラクタの場合、explicitキーワードを付けることで、この挙動を無効にすることができます。

C++11 の場合は、変換演算子も explicitキーワードを付けることで、暗黙的な型変換を許可しないようにできます。

class DataStore {
public:
	explicit DataStore(int value) :
		mValue(value)
	{}

private:
	int    mValue;
};

DataStore a = 10;  // error

基本的に、変換コンストラクタの機能を使わないのであれば、 引数1つだけで呼び出せる形式のコンストラクタには、常に explicitキーワードを付けるようにして下さい。 なお、引数を2つ以上指定して呼び出すコンストラクタには、explicitキーワードを付けることはできるものの、意味はありません。

また、コピーコンストラクタも引数が1つですが、explicit は付けてはいけません。


練習問題

問題@ この章の解説で登場した DataStoreクラスの全体像を完成させて下さい。 このクラスは、int型の値を1つ管理するだけの(それほど意味のない)クラスです。
(あなたが便利であると思う形に調整を加えて構いません)。


解答ページはこちら

参考リンク

更新履歴

'2017/3/25 VisualC++ 2017 に対応。

'2016/10/15 clang の対応バージョンを 3.7 に更新。

'2015/10/12 clang の対応バージョンを 3.4 に更新。

'2015/9/5 VisualC++ 2012 の対応終了。

'2015/8/18 VisualC++2010 の対応終了。

'2015/8/15 VisualC++ 2015 に対応。

'2014/11/1 「添字演算子」の項を修正。

'2014/10/18 clang 3.2 に対応。

'2014/10/5 新規作成。



前の章へ

次の章へ

C++編のトップページへ

Programming Place Plus のトップページへ