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

トップページC++編

C++編で扱っている C++ は 2003年に登場した C++03 という、とても古いバージョンのものです。C++ はその後、C++11 -> C++14 -> C++17 -> C++20 -> C++23 と更新されています。
なかでも C++11 での更新は非常に大きなものであり、これから C++ の学習を始めるのなら、C++11 よりも古いバージョンを対象にするべきではありません。特に事情がないなら、新しい C++ を学んでください。 当サイトでは、C++14 をベースにした新C++編を作成中です。

この章の概要 🔗

この章の概要です。


演算子オーバーロード 🔗

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

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

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

関係演算子、等価演算子 🔗

まずは、関係演算子と等価演算子から見ていきましょう。ここには以下の演算子が含まれます。

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

class DataStore {
public:
    DataStore(int v) : mValue(v) {}
    bool operator==(const DataStore& rhs) const;

private:
    int    mValue;
};

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

int main()
{
    DataStore ds1(10), ds2(10);
    if (ds1 == ds2) {}
}

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

ここで、!=演算子は必ず次のように実装するべきです。

class DataStore {
public:
    DataStore(int v) : mValue(v) {}
    bool operator==(const DataStore& rhs) const;

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

private:
    int    mValue;
};

int main()
{
    DataStore ds1(10), ds2(10);
    if (ds1 != ds2) {}
}

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

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


算術演算子 🔗

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

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

本来、これらの演算子はクラス内で定義するよりも、クラス外に置いた方が良いのですが、 ここではあえてクラス内で定義する方法だけを扱います

クラス内で定義する場合、以下のような使い方に対応できません。

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

これがなぜ問題になるかというと、10 + a という式では a.operator+(10) のことだとコンパイラに伝わらないのです。a + 10 であれば a.operator+(10) となるので、引数が int型の operator+() が定義されていれば問題ありません。a + 1010 + a は普通は同じ意味であって欲しいので、これは好ましくないでしょう。

この問題を解決する手段は、クラス外で operator+() を用意することなのですが、これは第35章まで先送りします。

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

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

class DataStore {
public:
    DataStore(int v) : mValue(v) {}
    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;
}

int main()
{
    DataStore ds1(10), ds2
    ds2 = ds1 + ds1;
}

引数は、自身と同じクラス型を 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;)。

符号を表す +演算子や -演算子と、四則演算を表す +演算子や -演算子は、オーバーロードしたときの関数名は同じ 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 すればいいので、インライン関数にして無用なコストを省くように実装します。

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

これらは、前置と後置の区別をつける必要があります。しかし「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;
};


代入演算子 🔗

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

複合代入演算子 🔗

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

複合代入演算子のオーバーロードは、代入演算子と同じ形になります。すなわち、自身と同じ型の参照を引数に取り、戻り値は *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();  // 安全確実


練習問題 🔗

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


解答ページはこちら

参考リンク 🔗


更新履歴 🔗

 「変換コンストラクタ」を第13章へ移動。

 「C++11 (明示的な変換演算子)」の項の内容を削除。同じ内容を解説している Modern C++編のページへのリンクだけを残した。

 explicit を指定子と表記するように修正。

 コンパイラの対応状況について、対応している場合は明記しない方針にした。

≪さらに古い更新履歴を展開する≫



前の章へ (第18章 static (静的))

次の章へ (第20章 クラステンプレート)

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

Programming Place Plus のトップページへ



はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る