行列とベクトルのクラスの土台が出来上がりましたので、いよいよこれらの演算規則 を定義します。ここでは演算子の多重定義(オーバーロード)について考えます。特に 以下の点について論じます。 (1)メンバ関数にすべきか、非メンバ関数(フレンド関数)にすべきか (2)関数の戻り値型を、参照型にしてはいけない場合 (3)関数を互いに関連させて実装する方法 C++では、組み込み型に定義されている演算子を、ユーザー定義型に対しても適用で きるように演算子の働き方を拡張することができます。演算子とは実際には特殊な組み 込み関数に過ぎません。演算子はキーワードoperatorの後に演算子記号を付けた関数 として定義されます。 一部の演算子を除いて、ほとんどの演算子は多重定義できます。ただし、新しい演算 子(演算記号)を作り出すことはできませんし、演算子の優先順位を変更したり、組み 込み型の演算子を再定義(変更)することはできません。許されるのは、ユーザ定義型 に対して、既存の演算子を多重定義することだけです。
3.7.1 メンバ関数 vs. friend関数
クラスのオブジェクトの振る舞いを決めるには、メンバ関数を定義します。しかしメ ンバ関数だけでは機能を実現できない場合があります。そのために、これまで見てきた ように2通りの方法がありました。公開メンバ関数とフレンド関数です。一般にオブジ ェクトの演算を実現するには、メンバ関数にするか非メンバ関数にするかの2つに分類 されます。メンバ関数はクラスのスコープ内にありますが、非メンバ関数はそうではあ りません。非メンバ関数は更に、非公開メンバにアクセスできるかできないかで、フレ ンド関数と非フレンド関数(つまり、普通のグローバル関数)とに分類されます。 コンストラクタ、デストラクタや、代入演算子、添え字演算子などはメンバ関数にす る必要があります。また仮想関数もメンバ関数である必要があります。一方、ストリー ム入出力演算子は非メンバ関数(フレンド関数)でなければなりません。演算子を多重 定義するには、演算子()、[]、->、=を除けば、メンバ関数でも非メンバ関数でも実 現できます。多くの演算子は選択の余地があります。 メンバ関数とフレンド関数との大きな違いは、メンバ関数は thisポインタを持ちま すがフレンド関数は持たないことです。フレンド関数は対象となるクラスの非公開メン バにアクセスできる権限を与えられた特別な非メンバ関数に過ぎません。 メンバ関数は対象となるクラスオブジェクトから呼び出されます。このときメンバ関 数内では暗黙のうちの thisを使うことができます。これに対してフレンド関数は引数 に明示的にクラスオブジェクトやクラスオブジェクトへの参照を渡して呼び出さなけれ ばなりません。このときフレンド関数内では、引数で受け取ったクラスオブジェクトの 非公開メンバにドット演算子(.)を介してアクセスできます。 以下では、メンバ関数で実現する方がよいときと、非メンバ関数の方がよい場合につ いて具体的に検討します。ここでは、その大まかな指針を述べておきます。 第一の指針は、 『非公開メンバにアクセスできる関数を最小限にする』 ことです。つまりクラスのインタフェースはできるだけ必要十分な最小セットに切りつ めることです。こうすることで、クラスの利用者に対して簡単明瞭なインターフェース を提供できると同時に、アクセス権限を絞り込むことでバグが発生する可能性を少なく することができます。この原則が第一であって、「メンバ関数か非メンバ関数か」とい うことは二義的なことです。
3.7.2 演算子関数の引数
2つのオブジェクトに作用する演算子(例えば足し算+)を2項演算子といいます。 2項演算子は、1つの引数を取るメンバ関数か、2つの引数を取る非メンバ関数のど ちらかで定義されます。 クラスオブジェクトA、Bに対する任意の2項演算子@がメンバ関数で定義されて いるとき、演算「A@B」は「A.operator@(B)」と解釈されます。非メンバ関 数で定義されていると、演算「A@B」は「operator@(A,B)」と解釈されます。 この場合の関数形式は、非メンバ関数の方がわかりやすい形をしています。演算を 受ける2つのオブジェクトは対称に扱われているからです。演算子@の左側Aと右側 Bはこの並び順で引数に渡されます。 一方、メンバ関数では左側のAがメンバ関数の呼びだし元のオブジェクトで、右側 Bの方は引数として渡されます。このとき、Aは暗黙に thisポインタとして渡され ます。つまり、メンバ関数を使って多重定義された2項演算子は、第1の引数は暗黙 に渡される thisポインタを意味します。 thisポインタは隠れた第1引数です。そ のために非メンバ関数より1つ少ない引数を取ります。 1つのオブジェクトに作用する演算子は単項演算子と呼びます。単項演算子は引数 を取らないメンバ関数か、1つの引数を取る非メンバ関数によって定義されます。任 意の(前置)演算子@に対して、「@A」はメンバ関数のとき「A.operator@()」 と解釈され、非メンバ関数では「operator@(A)」と解釈されます。 一方、単項演算子を後置演算子として多重定義した場合は、「A@」はメンバ関数 では「A.operator@(int)」と、非メンバ関数では「oprator@(A, int)」と 解釈されます。 これは前置演算と区別するための規約で、後置演算子のint引数は、本当の引数で はなくダミーであってintの値は使われません。このような区別はインクリメント演 算子(++)やデクリメント演算子(--)を前置と後置の演算子として多重定義すると きに使われます。 さて、演算子を多重定義するときの第2番目の指針は、 『演算子がオブジェクトを操作するときはメンバ関数にする。 そうでないときは非メンバ関数にする。』 ということです。この指針では、 「単項演算子は、メンバ関数として多重定義すべき第1候補」 になります。なぜなら単項演算子は1つのオブジェクトに作用するものであり、その オブジェクトを直接操作するからです。この場合、操作を受けるオブジェクトをthis ポインタとして渡す(つまりオブジェクトがメンバ関数を呼び出す)方が理に適って います。これに対して、 「2項演算子は、非メンバ関数として多重定義すべき第1候補」 です。一般に、2項演算子は2つのオブジェクトの属性を使って値を求めます。演算 を受ける2つのオブジェクトの属性そのものは変わりません。例えば足し算+では、 「A+B」は2つのオブジェクトAとBの値を使って演算結果(足し算の値)を求 めますが、AとBの値自身は変わりません。 また2項演算子は「演算の対称性」という観点からも、非メンバ関数を使う方が一 貫性があります。つまり、「A@B」は「A.operator@(B)」と解釈するよりも 「operator@(A,B)」と解釈する方が素直です。AとBの役割は同等(対称)で すから。
3.7.3 対称的な2項演算子の多重定義はフレンド関数にする
具体的に見て行きましょう。2つのn次元ベクトル
の内積(スカラー積)は次のように定義されています。
2つのベクトル・クラスのオブジェクトに対する演算子*を内積(スカラー積)の意 味で定義します。この演算子を多重定義するには原理的には3つの選択肢があります。 つまりベクトル・クラスのオブジェクトXとYに対して演算「X*Y」は (1)メンバ関数「X.operator(Y)」、 (2)非メンバのフレンド関数「operator(X,Y)」、 (3)非メンバの非フレンド関数「operator(X,Y)」、 の3通りの方法があります。それぞれの実装は以下のようになります。 (1)メンバ関数 class Vector{ ...... public: ...... double operator*(const Vector &);//内積 ...... }; double Vector::operator*(const Vector &right) { if(Dim != right.Dim) abort(); // サイズが違う double sum = 0.0; for(int i = 0; i < Dim; i++) sum += ptr[i] * right.ptr[i]; // 内部表現にアクセス return sum; }
(2)非メンバのフレンド関数
class Vector{ friend double operator*(const Vector &, const Vector &);//内積 ...... public: ...... }; double operator*(const Vector &left, const Vector &right) { if(left.Dim != right.Dim) abort(); // サイズが違う double sum = 0.0; for(int i = 0; i < Dim; i++) sum += left.ptr[i] * right.ptr[i]; // 内部表現にアクセス return sum; } |
(3)非メンバの非フレンド関数 // グローバル関数 double operator*(const Vector &, const Vector &);//内積 class Vector{ ...... }; double operator*(const Vector &left, const Vector &right) { if(left.getSize() != right.getSize()) abort(); // サイズが違う double sum = 0.0; for(int i = 0; i < left.getSize(); i++) sum += left[i] * right[i]; // 添え字演算子(メンバ関数)を使う return sum; } この3つの内で、効率の理由で(3)よりも(2)の方が優れています。フレンドで ない外部関数の(3)と違って、フレンド関数の(2)は内部表現に直接アクセスでき ます。そのため、メンバ関数呼び出しのオーバーヘッドが無いからです。 この様な2項演算子では関数実装の一貫性を維持する意味でも、フレンド関数として 実現する方が望ましいでしょう。これは、次のことを考えるとよく分かります。
ベクトルの実数倍(スカラー倍)は、
となります。ただしcは任意の実数値です。従って、同じ演算子*にベクトルの実数倍 (スカラー倍)の意味を持つように多重定義すると、 X * 3; // ベクトルXを3倍する は、メンバ関数の場合は「X.operator*(3)」、フレンド関数では「operator*(X,3)」 となります。このスカラー倍は、同時に演算の交換則 3 * X; についても明示的に定義してやる必要があります。なぜなら、「X * 3」と「3 * X 」 とでは、対応する演算子関数の引数の並び(順序)が違うからです。これらは別の演算 を意味します。 ところが、後者はメンバ関数では実現できません。 3.operator(X); // 間違い のように定数3がメンバ関数を呼び出すことはあり得ないからです(組み込み型の演算 規則は変えられない)。一方、フレンド関数では容易に実現できます。 つまり、「operator(3,X)」となります。演算の交換は、引数の順番を交換するだけ で多重定義できます。 従って、+、*、==、!=、などの左右対称な2項演算子は、フレンド関数を使っ て多重定義するのが一般的です。
class Vector{ friend double operator*(const Vector &, const Vector &);//内積 friend const Vector operator*(double , const Vector &); //スカラー倍 friend const Vector operator*(const Vector &, double); //スカラー倍 ...... public: ...... }; // 多重定義された*演算子 // ベクトルのスカラー倍 (実数×ベクトル) const Vector operator*(double c, const Vector &right) { Vector v(right.Dim); //一時的なオブジェクトを作る for (int i = 0; i < right.Dim; i++) v.ptr[i] = c * right.ptr[i]; return v; //値渡しで返す } // 多重定義された*演算子 // ベクトルのスカラー倍 (ベクトル×実数) // 実数×ベクトル用の関数を使う const Vector operator*(const Vector &left, double c) { return c * left; // operator(c, left)を呼び出す } |
ここで、2番目の実装(ベクトル×実数)に注目しましょう。これは1番目の関数を 使って定義しています。この様に、2つの実装を関連させて定義することで、コードの 冗長性を無くすことができます。
ベクトルのスカラー倍を求める演算子*の定義は、値渡しで返却していることに注目 しましょう。 const Vector operator*(double, const Vector &); const Vector operator*(const Vector &, double); 一度参照型を使った参照渡しを覚えると、あらゆる関数を参照渡しとconst参照渡し にしたくなります。参照渡しは「魔法の杖」です。そして、値渡しのコストに過敏にな ります。 しかし、ここでの関数は値渡しでなければなりません。重要なポイントなので詳しく 検討しましょう。 まず、2項演算子*が行っていることは、2つのオブジェクトの値を使って演算結果 の値を求めることです。引数の2つのオブジェクト自体は変更されません。つまりこの 関数は演算結果の値を返すものです。これを組み込み型の場合について考えると、 int a = 3; int b = 5; に対して、 a * b; は、整数型の積の演算結果の値15を返します。通常は代入によってこの結果を受け取 ります。 int c = a * b; 演算子*はどのオブジェクトも変更していませんし、演算「a * b」の値そのものはど のオブジェクトにも属していません。つまり、この演算子はオブジェクトそのものを返 す(参照型を返す)ものではありません。 関数が演算結果を返すには、演算結果を格納する一時的なオブジェクトが必要です。 Vector v(right.Dim); //一時的なオブジェクトを作る for (int i = 0; i < right.Dim; i++) v.ptr[i] = c * right.ptr[i]; オブジェクトvは関数内で作成されたローカルオブジェクトです。演算結果をvに格納 してこれを返します。 return v; //値渡しで返す このローカルオブジェクトvは、結果を返し関数が終了すると消滅することに注意しま しょう。vは関数内でのみで有効であって、関数から戻ったときにはスコープの範囲か ら出います。 つまり、もしこれを参照型で返すと元のオブジェクトvは既に消えて無くなっていま す。参照型を使うときは参照元が何であるかを常に理解しておく必要があります。そう すれば、この様な過ちは避けられます。 注意すべきことは、この関数の戻り値型を(誤って)参照型にしても、コンパイル・ エラーにならない点です。構文的には誤りではないからです(普通コンパイラは警告を 出します)。
同じくベクトルの足し算(+)、引き算(−)
も値を返す演算子関数にしなければなりません。
class Vector{ ・・・・ friend const Vector operator+(const Vector & , const Vector &); //和 friend const Vector operator-(const Vector & , const Vector &); //差 ・・・・ }; |
2つのベクトル型のオブジェクトの足し算「x+y」は、「operator+(x, y)」と解 釈され、引き算「x−y」では「operator-(x, y)」と解釈されます。
これらは次のように実装することができます。
// 多重定義された+演算子 // 2つのベクトルの和を求め、値渡しで返す const Vector operator+(const Vector &left, const Vector &right) { if(left.Dim != right.Dim){ //サイズのチェック cout << "エラー:ベクトルのサイズが一致しません\n"; abort(); } Vector v(left.Dim); //一時的なオブジェクトを作る for (int i = 0; i < left.Dim; i++) v.ptr[i] = left.ptr[i] + right.ptr[i]; return v;//値渡しで返す } // 多重定義された−演算子 // 2つのベクトルの差を求め、値渡しで返す const Vector operator-(const Vector &left, const Vector &right) { if(left.Dim != right.Dim){ //サイズのチェック cout << "エラー:ベクトルのサイズが一致しません\n"; abort(); } Vector v(left.Dim); //一時的なオブジェクトを作る for (int i = 0; i < left.Dim; i++) v.ptr[i] = left.ptr[i] - right.ptr[i]; return v;//値渡しで返す } |
この様に、ベクトルの足し算と引き算を素直に実装することもできますが、もっと気の 利いたやり方があります。
クラスに演算子+が多重定義されてると、次のような計算ができます。 x = x + y; 通常は、これは次のような演算でも表現できるはずです。 x += y; 従って演算子+を多重定義する場合、同時に演算子+=もサポートすることが自然なク ラス設計といえます。 すると、演算子+=が多重定義されているクラスに対して、次のクラスオブジェクト の足し算 z = x + y; は演算子+=を使って、次のように書き表すこともできます。 z = x; z += y; これと同じように演算子+と演算子−の実装も、それぞれ演算子+=と演算子−=を 使ってできます。すなわち、次のような実装になります。
// 多重定義された+演算子 // 2つのベクトルの和を求め、値渡しで返す const Vector operator+(const Vector &left, const Vector &right) { Vector v = left; // 一時オブジェクト return v += right; // 演算子 += を使う } // 多重定義された−演算子 // 2つのベクトルの差を求め、値渡しで返す const Vector operator-(const Vector &left, const Vector &right) { Vector v = left; // 一時オブジェクト return v -= right; // 演算子 -= を使う } |
この実装は、2つの意味で合理的です。第1は、コードの冗長性の軽減です。元々、 演算子+と演算子+=は似たようなコードになります(共に足し算)。2つの演算子を 関連させて実装することで共通するコードを減らすことができます。一般には、演算子 +=は演算子+よりも簡単なコードになります。すぐ後で見るように、+では結果を格 納するための一時オブジェクトを必要としますが、+=は一時オブジェクトを必要とし ません。 第2は、上の演算子+、−の定義は、フレンド関数である必要がないことに注目しま しょう。このコーディングでは、オブジェクトの内部表現にアクセスする必要がありま せん。従って、非メンバの非フレンド関数とすることができます。これは「内部表現に アクセスできる関数を減らす」という原則と合致します。 同じことは、ベクトルのスカラー倍にも当てはまります。つまり次のようなインター フェースで表すことができます。
#define NEARLY_ZERO 1.E-16 // ゼロと見なせる class Vector; // 前方宣言 // 非メンバ・非フレンド関数 const Vector operator+(const Vector &, const Vector &); //ベクトルの和 const Vector operator-(const Vector &, const Vector &); //ベクトルの差 const Vector operator*(double , const Vector &); //スカラー倍 const Vector operator*(const Vector &, double); //スカラー倍 const Vector operator/(const Vector &, double); //実数で割り算 class Vector{ ・・・・ friend double operator*(const Vector &, const Vector &); //内積 public: ・・・・ Vector &operator*=(double); Vector &operator/=(double); Vector &operator+=(const Vector&); Vector &operator-=(const Vector&); ・・・・ }; |
2項演算子+、−、*、/ は非メンバ関数であり、かつ非フレンド関数です。そのた め、クラス定義の外でプロトタイプ宣言されています。つまりこれらはグローバル関数 となります。 ここで、クラス型Vectorの前方宣言が必要なことに注意しましょう。 class Vector; // 前方宣言 5つの演算子関数は、クラス定義を行う前に、引数にこのクラス型を用いています。そ のため、あらかじめ型宣言をしておく必要があるからです。
2項演算子の定義部は次のように実装されます。
// 多重定義された+演算子 // 2つのベクトルの和を求め、値渡しで返す const Vector operator+(const Vector &left, const Vector &right) { // 上の定義と同じ } // 多重定義された−演算子 // 2つのベクトルの差を求め、値渡しで返す const Vector operator-(const Vector &left, const Vector &right) { // 上の定義と同じ } // 多重定義された*演算子 // ベクトルのスカラー倍 (ベクトル×実数) // 実数×ベクトル用の関数を使う const Vector operator*(const Vector &left, double c) { Vector v = left; // 一時オブジェクト return v *= c; // 演算子 *= を使う } // 多重定義された*演算子 // ベクトルのスカラー倍 (実数×ベクトル) const Vector operator*(double c, const Vector &right) { Vector v = right; // 一時オブジェクト return v *= c; // 演算子 *= を使う } // 多重定義された/演算子 // ベクトルを実数で割り算する const Vector operator/(const Vector &left, double c) { Vector v = left; // 一時オブジェクト return v /= c; // 演算子 /= を使う } |
ここでの関数の戻り値の型はconstオブジェクト(「const Vector」型)になって います。
3.7.6 オブジェクトを変更する関数はメンバ関数にする
次に、演算子+=、−=、*=、/= の多重定義を実装します。これらはすべて、 オブジェクトの値を変更する演算子です。 x += y; // x = x + y と同じ x -= y; // x = x - y と同じ x *= 2; // ベクトルxは2倍される( x = x * 3 ) x /= 3; // ベクトルxは3で割ったものになる( x = x / 3 ) この様な演算子はメンバ関数を使って実装します。つまり、オブジェクトxから呼び 出される関数として扱います。またこれらの演算子は、左右非対称な演算である点か らもメンバ関数を使います。上の4つの式は、それぞれ次のように解釈されます。 x.operator+(y); x.operator-(y); x.operator*(2); x.operator/(3);
関数の定義を示します。
// 多重定義された*=演算子 Vector &Vector::operator*=(double c) { for (int i = 0; i < Dim; i++) ptr[i] *= c; return *this; } // 多重定義された/=演算子 Vector &Vector::operator/=(double c) { if(c < NEARLY_ZERO){ cout << "エラー:ゼロ除算\n"; abort(); } for (int i = 0; i < Dim; i++) ptr[i] /= c; return *this; } // 多重定義された+=演算子 Vector &Vector::operator+=(const Vector &right) { if(Dim != right.Dim){ //サイズのチェック cout << "エラー:ベクトルのサイズが一致しません\n"; abort(); } for (int i = 0; i < Dim; i++) ptr[i] += right.ptr[i]; return *this; } // 多重定義された−=演算子 Vector &Vector::operator-=(const Vector &right) { if(Dim != right.Dim){ //サイズのチェック cout << "エラー:ベクトルのサイズが一致しません\n"; abort(); } for (int i = 0; i < Dim; i++) ptr[i] -= right.ptr[i]; return *this; } |
割り算 /=の実装でゼロ除算を避けるコードでは、次のマクロを定義して使ってい ます(ヘッダ部を参照)。 #define NEARLY_ZERO 1.E-16 // ゼロと見なせる 関数の戻り値の型は参照型です。この様な場合は *thisつまり、オブジェクト自身 を返します。 +=演算子などの実装は、+演算子などと比べて簡単になります。これらは、演算結 果をオブジェクト自身に格納して返すため、一時的なオブジェクトの作成を必要としま せん。その分、一時オブジェクトの生成・消滅に関わるコストが無いので、実行効率も 良くなります。 [演習問題 3.10] 手元にあるC++のテキストを調べて、多重定義できる演算子と、 できない演算子のリストを作りなさい。
| 目次 | 前のページ | 次のページ | ページの先頭 |
Copyright(c) 1999 Yamada,K