C++入門|C++の演算子オーバーロード

クラスにも、足し算・引き算・倍率計算の力を。演算子オーバーロードで、気の流れを数値のように自然に操ろう。

C++では、数値同士を加算するときに+を使い、減算するときに-を使います。

int result = 10 + 20;

文字列を結合する場合も、stringでは+を利用できます。

string message = "修行" + string("開始");

しかし、自分で作成したクラスに対して、+や-がどのような処理を表すのかは、C++には分かりません。

たとえば、気の方向と強さを表すKiVector2Dクラスを作ったとします。

KiVector2D first;
KiVector2D second;

この2つのオブジェクトを次のように加算したくても、加算方法を定義していなければコンパイルできません。

KiVector2D result = first + second;

KiVector2Dにおける加算が、xとyをそれぞれ足す処理なのか、それとも別の特殊な合成処理なのかを、コンパイラは判断できないからです。

そこで利用するのが、演算子オーバーロードです。

演算子オーバーロードを使うと、クラスに対して+、-、*、+=、-=、==などの演算子がどのような処理を行うのかを定義できます。

ドラゴンボール風にたとえるなら、2人の戦士が放つ気の方向を合成したり、気の流れを数倍に増幅したりする処理です。

通常のメンバ関数を使う場合、次のような書き方になります。

KiVector2D result = first.add(second);

演算子を定義すると、数値の計算に近い自然な形で書けます。

KiVector2D result = first + second;

どちらも処理を実行する関数ですが、演算子を使ったほうが、計算の意味を直感的に読み取りやすくなる場合があります。

演算子オーバーロードとは

演算子オーバーロードとは、既存の演算子に、ユーザーが作成したクラス用の処理を追加する機能です。

今回のKiVector2Dクラスは、横方向の気の力をx、縦方向の気の力をyで管理します。

たとえば、次の2つの気の流れがあるとします。

first  = (1, 2)
second = (3, 4)

+を次のような計算として定義できます。

first + second

x方向:1 + 3 = 4
y方向:2 + 4 = 6

結果:(4, 6)

同じように、-では各要素を減算できます。

first - second

x方向:1 - 3 = -2
y方向:2 - 4 = -2

結果:(-2, -2)

*を使って倍率計算を定義すれば、気の流れ全体を増幅できます。

4 × first

x方向:4 × 1 = 4
y方向:4 × 2 = 8

結果:(4, 8)

今回実装する演算子は次のとおりです。

演算子使用例処理
=second = firstxとyを代入する
+=first += second自分自身へ相手のxとyを加算する
-=first -= second自分自身から相手のxとyを減算する
+first + second加算結果を持つ新しいオブジェクトを返す
-first - second減算結果を持つ新しいオブジェクトを返す
*4.0 * firstxとyを指定された倍率にする

図:KiVector2Dの演算子オーバーロード

この図が示していること

この図では、2つのKiVector2Dオブジェクトを+で加算する仕組みを表しています。

firstが持つxとsecondが持つxを加算し、同じように2つのyを加算します。

演算結果は、元のfirstやsecondを書き換えるのではなく、計算結果を持つ新しいKiVector2Dオブジェクトとして返されます。

演算子は関数として定義する

演算子オーバーロードは、特別な書き方をする関数です。

基本的な形は次のようになります。

戻り値の型 operator演算子(引数)

たとえば、+=をオーバーロードするメンバ関数は、次のように宣言できます。

KiVector2D& operator+=(const KiVector2D& other);

+をクラス外の関数として定義する場合は、次のように宣言できます。

KiVector2D operator+(
    const KiVector2D& left,
    const KiVector2D& right);

operatorの直後に、対象となる演算子を書きます。

関数名対応する演算
operator=代入
operator+=加算して代入
operator-=減算して代入
operator+加算
operator-減算
operator*乗算

KiVector2Dクラスの設計

KiVector2Dクラスでは、x方向とy方向の気の流れをdouble型で管理します。

double x;
double y;

さらに、初期値を設定するためのコンストラクタを用意します。

KiVector2D(double xValue = 0.0,
           double yValue = 0.0);

引数を指定しない場合は、xとyがどちらも0になります。

KiVector2D energy;

値を指定する場合は、次のように書けます。

KiVector2D energy(1.0, 2.0);

この場合、energyの状態は次のようになります。

x = 1.0
y = 2.0

KiVector2Dクラスのヘッダーファイル

プロジェクト/ファイル名: Chap7_08/ki_vector2d.h

#ifndef CHAP7_08_KI_VECTOR2D_H
#define CHAP7_08_KI_VECTOR2D_H

// 2次元の気の方向と強さを表すクラス
class KiVector2D {
public:
    // 横方向の気の力
    double x;

    // 縦方向の気の力
    double y;

    // コンストラクタ
    KiVector2D(
        double xValue = 0.0,
        double yValue = 0.0);

    // 代入演算子
    KiVector2D& operator=(
        const KiVector2D& other);

    // 加算代入演算子
    KiVector2D& operator+=(
        const KiVector2D& other);

    // 減算代入演算子
    KiVector2D& operator-=(
        const KiVector2D& other);
};

// 2つの気の流れを加算する
KiVector2D operator+(
    const KiVector2D& left,
    const KiVector2D& right);

// 2つの気の流れを減算する
KiVector2D operator-(
    const KiVector2D& left,
    const KiVector2D& right);

// 気の流れを指定された倍率にする
KiVector2D operator*(
    double scale,
    const KiVector2D& vector);

#endif

このヘッダーファイルでは、=、+=、-=をメンバ関数として宣言しています。

+、-、*は、クラス外の関数として宣言しています。

ただし、算術演算子は必ずクラス外へ定義しなければならない、という決まりではありません。

+や-をメンバ関数として定義することもできます。

今回は、左右の値を同じ立場で受け取り、元のオブジェクトを変更せず新しい結果を返すことを分かりやすくするため、クラス外の関数として定義します。

メンバ関数として定義する演算子

=、+=、-=では、演算子の左側にあるオブジェクト自身を変更します。

first += second;

この処理では、firstのxとyが変化します。

secondの値は変更しません。

そのため、+=はKiVector2Dのメンバ関数として定義すると自然です。

KiVector2D& KiVector2D::operator+=(
    const KiVector2D& other)

この関数内で、自分自身が持つxとyを変更します。

x += other.x;
y += other.y;

クラス外に定義する演算子

+では、左側と右側の値を利用して、新しい計算結果を作ります。

KiVector2D result = first + second;

通常、firstとsecond自体は変更しません。

クラス外のoperator+では、2つの引数を受け取ります。

KiVector2D operator+(
    const KiVector2D& left,
    const KiVector2D& right)

leftとrightを加算した新しいKiVector2Dを返します。

return KiVector2D(
    left.x + right.x,
    left.y + right.y);

演算子を実装する

プロジェクト/ファイル名: Chap7_08/ki_vector2d.cpp

#include "ki_vector2d.h"

// コンストラクタ
KiVector2D::KiVector2D(
    double xValue,
    double yValue)
    : x(xValue), y(yValue) {
}

// 代入演算子
KiVector2D& KiVector2D::operator=(
    const KiVector2D& other) {

    // 自分自身への代入でなければ値をコピーする
    if (this != &other) {
        x = other.x;
        y = other.y;
    }

    // 代入後の自分自身を返す
    return *this;
}

// 加算代入演算子
KiVector2D& KiVector2D::operator+=(
    const KiVector2D& other) {

    // 自分自身の値へ相手の値を加える
    x += other.x;
    y += other.y;

    // 計算後の自分自身を返す
    return *this;
}

// 減算代入演算子
KiVector2D& KiVector2D::operator-=(
    const KiVector2D& other) {

    // 自分自身の値から相手の値を引く
    x -= other.x;
    y -= other.y;

    // 計算後の自分自身を返す
    return *this;
}

// 加算演算子
KiVector2D operator+(
    const KiVector2D& left,
    const KiVector2D& right) {

    // 加算結果を持つ新しいオブジェクトを返す
    return KiVector2D(
        left.x + right.x,
        left.y + right.y);
}

// 減算演算子
KiVector2D operator-(
    const KiVector2D& left,
    const KiVector2D& right) {

    // 減算結果を持つ新しいオブジェクトを返す
    return KiVector2D(
        left.x - right.x,
        left.y - right.y);
}

// 倍率計算の演算子
KiVector2D operator*(
    double scale,
    const KiVector2D& vector) {

    // xとyの両方を指定された倍率にする
    return KiVector2D(
        scale * vector.x,
        scale * vector.y);
}

main関数で演算を確認する

main.cppでは、first、second、thirdという3つのKiVector2Dオブジェクトを作成します。

firstを(1, 2)で初期化し、secondへfirstを代入します。

thirdには、firstを4倍した結果を代入します。

そのあと、+、+=、-=を順番に実行します。

プロジェクト/ファイル名: Chap7_08/main.cpp

#include <iostream>
#include <string>
#include "ki_vector2d.h"

using namespace std;

// 気のベクトルを表示する関数
void printKiVector(
    const string& label,
    const KiVector2D& vector);

int main(int argc, char** argv) {
    // 3つの気のベクトルを用意する
    KiVector2D first(1.0, 2.0);
    KiVector2D second;
    KiVector2D third;

    // firstの内容をsecondへ代入する
    second = first;

    // firstを4倍した結果をthirdへ代入する
    third = 4.0 * first;

    // 初期状態を表示する
    printKiVector("first = ", first);
    printKiVector("second = ", second);

    // 2つの気の流れを加算する
    printKiVector(
        "first + second = ",
        first + second);

    // 4倍した結果を表示する
    printKiVector("third = ", third);

    // thirdへfirstを加算する
    third += first;
    printKiVector(
        "third += first の結果 = ",
        third);

    // firstからsecondを減算する
    first -= second;
    printKiVector(
        "first -= second の結果 = ",
        first);

    return 0;
}

// 気のベクトルを表示する
void printKiVector(
    const string& label,
    const KiVector2D& vector) {

    cout << label
         << "("
         << vector.x
         << ", "
         << vector.y
         << ")"
         << endl;
}

実行結果

first = (1, 2)
second = (1, 2)
first + second = (2, 4)
third = (4, 8)
third += first の結果 = (5, 10)
first -= second の結果 = (0, 0)

プログラム全体の流れ

最初に、firstを(1, 2)で初期化しています。

KiVector2D first(1.0, 2.0);

firstの状態は次のとおりです。

first.x = 1.0
first.y = 2.0

secondとthirdは、引数を指定せずに生成しています。

KiVector2D second;
KiVector2D third;

コンストラクタの初期値によって、どちらも最初は(0, 0)になります。

次に、firstをsecondへ代入します。

second = first;

operator=が呼び出され、firstのxとyがsecondへコピーされます。

second = (1, 2)

thirdには、firstを4倍した結果を代入します。

third = 4.0 * first;

operator*によって、xとyがそれぞれ4倍されます。

x:4.0 × 1.0 = 4.0
y:4.0 × 2.0 = 8.0

third = (4, 8)

代入演算子をオーバーロードする

代入演算子は、次の処理で呼び出されます。

second = first;

左側のsecondに対して、メンバ関数operator=が呼び出されます。

概念的には、次のような呼び出しです。

second.operator=(first);

operator=の引数otherは、右側のfirstを参照します。

KiVector2D& KiVector2D::operator=(
    const KiVector2D& other)

関数内では、otherが持つxとyを、自分自身へコピーします。

x = other.x;
y = other.y;

このときの自分自身とは、演算子の左側にあるsecondです。

operator=内の名前実際の対象
thissecond
xsecond.x
ysecond.y
otherfirst
other.xfirst.x
other.yfirst.y

自己代入を確認する

operator=では、次の条件を確認しています。

if (this != &other) {

thisは、演算子の左側にあるオブジェクト自身を指します。

&otherは、右側にあるオブジェクトのアドレスです。

次のように、自分自身を自分自身へ代入する可能性があります。

first = first;

この場合、thisと&otherは同じアドレスになります。

今回のKiVector2Dはdouble型の値を2つコピーするだけなので、自己代入をしても大きな問題は起こりません。

ただし、クラスが動的メモリやファイルなどの資源を管理している場合は、自己代入への対応が重要になることがあります。

operator=で自分自身を返す

operator=の戻り値は、KiVector2D&です。

KiVector2D& KiVector2D::operator=(
    const KiVector2D& other)

最後に、自分自身を表す*thisを返しています。

return *this;

thisは自分自身を指すポインタです。

*thisとすると、thisが指しているオブジェクト本体を表します。

書き方意味
this自分自身を指すポインタ
*this自分自身のオブジェクト
return *this自分自身への参照を返す

自分自身への参照を返すことで、次のような連続代入ができます。

first = second = third;

代入は右側から処理されます。

first = (second = third)

最初にsecond = thirdが実行されます。

operator=がsecond自身への参照を返すため、その結果をfirstへ代入できます。

thirdの値をsecondへ代入
            ↓
secondへの参照を返す
            ↓
そのsecondをfirstへ代入

単純なクラスではoperator=を省略できる

今回のKiVector2Dは、double型のxとyだけを持っています。

このようなクラスでは、operator=を自分で書かなくても、コンパイラがメンバごとにコピーする代入演算子を自動生成します。

つまり、今回のoperator=とほぼ同じ代入は、明示的に定義しなくても利用できます。

KiVector2D second;
second = first;

今回あえてoperator=を定義しているのは、演算子オーバーロードの書き方と、return *thisの意味を確認するためです。

実際の開発では、特別な代入処理が必要ない場合、コンパイラが生成する代入演算子に任せることも大切です。

+=演算子をオーバーロードする

次の処理では、thirdへfirstを加算します。

third += first;

概念的には、次のメンバ関数呼び出しです。

third.operator+=(first);

operator+=の中では、thirdが持つxとyを直接変更します。

x += other.x;
y += other.y;

変更前のthirdは(4, 8)です。

firstは(1, 2)です。

x:4 + 1 = 5
y:8 + 2 = 10

third = (5, 10)

+=は左側のオブジェクトを変更します。

オブジェクト演算前演算後
third(4, 8)(5, 10)
first(1, 2)(1, 2)

firstはconst参照で受け取っているため、operator+=の中では変更されません。

-=演算子をオーバーロードする

次の処理では、firstからsecondを減算します。

first -= second;

operator-=の中では、xとyをそれぞれ減算します。

x -= other.x;
y -= other.y;

firstとsecondは、どちらも(1, 2)です。

x:1 - 1 = 0
y:2 - 2 = 0

first = (0, 0)

-=も+=と同じように、左側のオブジェクト自身を変更します。

図:+=とreturn *thisの流れ

この図が示していること

third += firstでは、左側のthird自身が変更されます。

thirdのxとyへ、firstのxとyをそれぞれ加算します。

計算後はreturn *thisによって、変更されたthird自身への参照を返します。

一方、firstはconst参照として受け取っているため、operator+=の中では変更されません。

+演算子をオーバーロードする

次の処理では、firstとsecondを加算しています。

first + second

operator+には、leftとrightの2つの引数が渡されます。

KiVector2D operator+(
    const KiVector2D& left,
    const KiVector2D& right)

それぞれのxとyを加算し、新しいKiVector2Dを返します。

return KiVector2D(
    left.x + right.x,
    left.y + right.y);

firstとsecondがどちらも(1, 2)なら、結果は(2, 4)です。

x:1 + 1 = 2
y:2 + 2 = 4

重要なのは、firstとsecond自体を変更しないことです。

オブジェクト演算前演算後
first(1, 2)(1, 2)
second(1, 2)(1, 2)
加算結果なし(2, 4)

+は計算結果として、新しいオブジェクトを返します。

-演算子をオーバーロードする

-も+と同じように、新しい計算結果を返します。

KiVector2D operator-(
    const KiVector2D& left,
    const KiVector2D& right)

xとyをそれぞれ減算します。

return KiVector2D(
    left.x - right.x,
    left.y - right.y);

たとえば、次の値があるとします。

left  = (5, 8)
right = (2, 3)

減算結果は次のとおりです。

x:5 - 2 = 3
y:8 - 3 = 5

結果:(3, 5)

leftとrightは変更されません。

*演算子で気の流れを増幅する

次の処理では、firstのxとyを4倍しています。

third = 4.0 * first;

左側がdouble、右側がKiVector2Dなので、operator*は次の引数を受け取ります。

KiVector2D operator*(
    double scale,
    const KiVector2D& vector)

scaleには4.0が入ります。

vectorはfirstを参照します。

scale  = 4.0
vector = first

operator*では、xとyをscale倍します。

return KiVector2D(
    scale * vector.x,
    scale * vector.y);

firstが(1, 2)なら、結果は(4, 8)です。

x:4.0 × 1.0 = 4.0
y:4.0 × 2.0 = 8.0

この処理でもfirst自体は変更しません。

右側に倍率を書く場合

今回定義したoperator*は、doubleとKiVector2Dの順番で引数を受け取ります。

そのため、次の書き方に対応しています。

4.0 * first

次の書き方にも対応したい場合は、別のoperator*を追加します。

first * 4.0

追加する関数は次のように書けます。

KiVector2D operator*(
    const KiVector2D& vector,
    double scale) {

    return scale * vector;
}

この関数では、すでに定義しているscale * vectorを利用しています。

演算子オーバーロードでは、引数の型や順番が異なれば、同じ演算子に複数の処理を定義できます。

メンバ関数とクラス外関数の違い

演算子オーバーロードには、メンバ関数として定義する方法と、クラス外の関数として定義する方法があります。

今回の分類は次のとおりです。

演算子定義場所理由
=メンバ関数左側の自分自身へ代入する
+=メンバ関数左側の自分自身を変更する
-=メンバ関数左側の自分自身を変更する
+クラス外関数左右を同じ立場で受け取り、新しい結果を返す
-クラス外関数左右を同じ立場で受け取り、新しい結果を返す
*クラス外関数左側にdoubleを置けるようにする

ただし、左側のオブジェクトを変更する演算子は必ずメンバ関数、変更しない演算子は必ずクラス外関数、という厳密な規則ではありません。

+や-をメンバ関数として実装することも可能です。

設計するときは、演算の意味、左右の型、暗黙の型変換、コードの読みやすさなどを考えて決めます。

*をクラス外へ定義する理由

次の式では、演算子の左側がdoubleです。

4.0 * first

もしoperator*をKiVector2Dのメンバ関数として定義すると、左側にKiVector2Dオブジェクトが必要になります。

first * 4.0

4.0 * firstという順番に対応したい場合は、クラス外の関数として定義すると自然です。

KiVector2D operator*(
    double scale,
    const KiVector2D& vector)

クラス外の関数なら、左側にdouble、右側にKiVector2Dを受け取れます。

const参照で受け取る理由

演算子の引数は、const参照で受け取っています。

const KiVector2D& other

参照で受け取ることで、KiVector2Dオブジェクト全体のコピーを避けられます。

constを付けることで、演算子内から引数のオブジェクトを変更できません。

受け取り方コピー引数の変更
KiVector2D other作られるコピーは変更できる
KiVector2D& other作られない元のオブジェクトを変更できる
const KiVector2D& other作られない元のオブジェクトを変更できない

+、-、*では、元のオブジェクトを変更せず、計算に利用するだけです。

そのため、const参照が向いています。

オーバーロードできないこと

演算子オーバーロードは便利ですが、C++の演算子を自由に作り変えられるわけではありません。

新しい記号の演算子を作ることはできません。

たとえば、次のような独自演算子は作れません。

first @ second

演算子の優先順位も変更できません。

first + second * third

この式では、通常どおり*が+より先に処理されます。

引数の数も、元の演算子が持つ形から変更できません。

+のような二項演算子を、3つの値を受け取る演算子にすることはできません。

また、演算子の少なくとも一方には、ユーザー定義型が必要です。

int同士の+を別の計算へ変更することはできません。

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

C++では、多くの演算子をオーバーロードできますが、すべてではありません。

代表的なオーバーロードできない演算子には、次のようなものがあります。

演算子意味
::スコープ解決
.メンバアクセス
.*メンバポインタによるアクセス
?:条件演算子
sizeofサイズの取得

これらの演算子の動作は変更できません。

一部の演算子はメンバ関数として定義する

演算子の中には、クラスのメンバ関数として定義しなければならないものがあります。

代表的なものは次のとおりです。

演算子主な用途
=代入
[]添字によるアクセス
()関数呼び出し形式
->ポインタ形式のメンバアクセス

今回のoperator=も、KiVector2Dのメンバ関数として定義しています。

図:演算子の役割と設計上の注意

この図が示していること

=、+=、-=は、左側のオブジェクト自身を変更する演算としてメンバ関数にしています。

+、-、*は、元のオブジェクトを変更せず、新しい計算結果を返す関数として定義しています。

ただし、これは今回の設計上の選択です。+や-をメンバ関数として実装することもできます。

演算子を定義するときは、通常の演算から想像できる自然な動作にすることが大切です。

演算子の意味を分かりやすくする

演算子オーバーロードを使えば、+へどのような処理でも設定できるように見えます。

しかし、+を使ったときに値が減ったり、オブジェクトが削除されたりすると、コードを読む人が混乱します。

たとえば、次の式を見れば、多くの人は2つの値を加算した結果が得られると考えます。

KiVector2D result = first + second;

そのため、operator+では、xとyを加算した新しい結果を返すのが自然です。

演算子自然に期待される処理
+2つの値を加算した新しい結果
-左側から右側を減算した新しい結果
+=左側へ右側を加算
-=左側から右側を減算
=右側の状態を左側へ代入
==2つの値が等しいか判定

演算子の見た目と処理の意味を一致させることで、読みやすいコードになります。

演算子を増やしすぎない

すべてのクラスに演算子オーバーロードが必要なわけではありません。

たとえば、Warriorクラス同士を+で加算する処理は、意味が分かりにくい可能性があります。

Warrior result = firstWarrior + secondWarrior;

これが合体を表すのか、戦闘力の合計を表すのか、部隊の編成を表すのかが、コードだけでは分かりません。

そのような場合は、意味が明確なメンバ関数を使うほうが適切です。

Warrior result =
    firstWarrior.combineWith(secondWarrior);

一方、KiVector2Dのように数学的な値を表すクラスでは、+、-、*の意味を自然に決められます。

演算子を使うかどうかは、短く書けるかだけでなく、意味が直感的に伝わるかを基準に判断します。

+=を利用して+を実装する方法

+と+=は、似た計算を行います。

そのため、operator+の中でoperator+=を利用する書き方もできます。

KiVector2D operator+(
    KiVector2D left,
    const KiVector2D& right) {

    left += right;
    return left;
}

この書き方では、leftを値渡しで受け取ります。

leftは元のオブジェクトのコピーなので、変更しても呼び出し元には影響しません。

operator+=を使ってleftへrightを加算し、その結果を返します。

leftのコピーを作る
        ↓
left += right
        ↓
計算後のleftを返す

この方法では、加算処理をoperator+=へ集められます。

+=の計算内容を修正すれば、+にも同じ変更が反映されるため、重複した処理を減らせます。

printKiVectorではconst参照を使う

表示関数では、KiVector2Dをconst参照で受け取っています。

void printKiVector(
    const string& label,
    const KiVector2D& vector)

labelとvectorは、どちらも表示に利用するだけです。

関数内で変更する必要はありません。

const参照にすることで、不要なコピーを避けながら、関数内からの変更も防げます。

cout << vector.x << ", " << vector.y;

演算子オーバーロードで身につけたい感覚

演算子オーバーロードを使うと、自分で作成したクラスを、数値型に近い自然な書き方で扱えます。

second = first;

この処理では、operator=が呼び出されます。

third += first;

この処理では、operator+=が呼び出され、third自身が変更されます。

KiVector2D result = first + second;

この処理では、operator+が呼び出され、新しい計算結果が返されます。

KiVector2D result = 4.0 * first;

この処理では、operator*が呼び出され、firstを4倍した新しい結果が返されます。

ドラゴンボール風にたとえるなら、KiVector2Dは気の方向と強さを記録するエネルギー座標です。

+は2つの気を合成します。

-は一方の気から、もう一方の気を差し引きます。

*は気の流れを指定された倍率に増幅します。

+=と-=は、新しい結果を別に作るのではなく、戦士が現在持っている気の状態そのものを変化させます。

演算子オーバーロードは、コードを短くするためだけの機能ではありません。

クラスが表している概念に合った演算を定義し、プログラムを自然に読めるようにするための機能です。

演算子の見た目から処理内容を想像できるようにし、元のオブジェクトを変更するのか、新しい結果を返すのかを明確に設計することが大切です。

適切に使えば、自作クラスでも数値やベクトルのような分かりやすい演算ができ、読みやすく扱いやすいC++プログラムを作れるようになります。