これまでで、行列クラスの内部データのレイアウトとオブジェクトの初期化及びその 後始末について設計を行いました。後は、オブジェクトの振る舞いを決めること、即ち オブジェクトの演算を定義することです。組み込みのint型やdouble型には、+、−、 *、/、などのC++演算子が定義されているように、行列クラスのオブジェクト同士の +、−、*演算に意味を持たせます。これを演算子の多重定義(オーバーロード) といいます。そもそもC++の組み込み型自体、演算子が多重定義されています。+や− 演算子は、整数、浮動小数点数やポインタにたいして、その文脈に応じて異なる振る舞 いをします。 ここでは代入演算子(=)について詳しく取り上げます。これと関連してコンストラ クタ(特にコピーコンストラクタ)について更に理解を深めます。またフレンド関数 について説明します。
3.4.1 オブジェクトの代入
C構造体は代入演算(=)ができました。構造体の代入はすべてのメンバの値をコピ ーします。次の3次元空間の座標点 (x,y,z) を表現する構造体 Point を例にします。 struct Point{ int x; // x座標 int y; // y座標 int z; // z座標 }; で定義された Point型の変数の代入は、 Point p = { 10, 20, 30 }; // メンバを(x,y,z)=(10,20,30)で初期化 Point q; q = p; // pの値をqに代入 によって、pのメンバx、y、zの値がそれぞれqのメンバに代入されます。 クラスはこのC構造体の性質を受け継いでいます。つまりクラスのオブジェクトの代 入では、デフォルトでは各データメンバの値が代入されます。 ところが、動的メモリを作成するクラス(や構造体)では問題を起こします。行列ク ラス Matrixは、ポインタ ptrをデータメンバに持ちます。行列クラスの2つオブジェ クトa、bについて代入を行ったときに何が起こるか考えてみましょう。 まず代入する前では次の図のように、オブジェクトa、bのメンバ ptrはそれぞれ行 列要素のメモリ領域AとBを指しているとします。
ここで、 a = b; // オブジェクトの代入 と代入します。その結果が次の図のような状況です。
この代入では期待した結果が得られません。行列オブジェクトの代入とは行列要素の値 そのものがすべてコピーされることを意味するのに、そのようになりません。問題の所 在は明らかです。ポインタの代入ではそれが指すメモリのアドレス値が代入されるから です。従って、2つのオブジェクトのデータメンバ ptrは同じメモリ領域Bを指すこと になります。 これは2つの意味で深刻な問題を引き起こします。第1に、オブジェクトaが指して いたメモリ領域Aはそのアドレス値を失い、永久に解放されることはありません。これ はメモリリーク(メモリの枯渇)の原因となります。第2に、メモリ領域Bは2度解放 されることになります。オブジェクトの一方のデストラクタが先に呼ばれたとき他方の オブジェクトが指しているにもかかわらず領域Bが解放されます。そして、次のオブジ ェクトのデストラクタが呼ばれると、もはや存在しない領域Bを解放しようとします。 データメンバにポインタを持つクラスのオブジェクトの代入を正しく行うには、ポイ ンタの値(アドレス)を代入する(これを浅いコピーと呼びます)のではなく、ポイン タが指すメモリ領域のデータそのもののコピー(これを深いコピーと呼びます)を行う ようにします。つまり、オブジェクトの代入について演算子=を正しく定義してやる必 要があります。これが、代入演算子を多重定義する理由です。
3.4.2 代入演算子の多重定義
C++の演算子@を拡張して新たに意味を持たせるには、次の関数を定義することで演 算子@を多重定義します。 戻り値型 operator@(引数リスト); ここで、演算子@は、=、+、−、*、/、...などのC++演算子を意味します。 代入演算子を多重定義するには、operator=()という関数を定義します。 Matrixク ラスの代入演算子を定義するには、次のプロトタイプの関数 Matrix &Matrix::operator=(const Matrix&); を公開メンバ関数として定義します。このメンバ関数は Matrixクラスのオブジェクトの 参照(const参照)を引数に取ります。このとき次のようなコード a = b; に対して、Matrixクラスのオブジェクトaのメンバ関数operator=()が呼ばれて、オブ ジェクトbが引数として渡され、 a.operator=(b); が実行されます。 つまり代入演算子を多重定義するということはオブジェクトaに演算記号=を付加した 「 a = 」に対して規則を与えることです。演算子ですから演算を受ける対象があります。 それを関数の引数として定義します。 従って、「 a = 」でオブジェクトaのメンバ関数operator=()が呼ばれ、演算を受 けるオブジェクトbがこのメンバ関数に渡されます。
3.4.3 代入演算子では自己参照を返す
次に、代入演算子を定義する関数operator=()の戻り値型について検討します。上の プロトタイプ宣言に注目してください。オブジェクトの参照を返しています。この場合、 オブジェクト自身の参照(自己参照)を返します。これを擬似コードで書くと、 Matrix &Matrix::operator=(const Matrix &b) { // オブジェクトbのデータメンバを正しく // 深いコピーで代入する return *this; } というようにthisポインタを明示的に使って、その間接参照 *this(即ち自分自身) を返すことで実現できます。この様にすることで、次のような演算子の連鎖的な実行が 可能になります。 a = b = c; この構文は次のように評価されます。 a = ( b = c ); そして次のメンバ関数が呼び出されます。 a.operator=( b.operator=(c) ); 最初に「b = c 」が評価されて「b.operator=(c)」が実行されます。オブジェクト cがbに代入されて、bの参照が返されます。次に、この参照を「 a = b 」でaのメ ンバ関数が受け取り「a.operator=(b);」が実行されます。これで組み込み型の変数 と同じような連鎖的な代入が可能になります。 代入を正しく行う目的という意味では、戻り値型がvoidでも不正ではありません。 void Matrix::operator=(const Matrix &b); // 不正ではないが、不自然 しかし、オブジェクトの振る舞いは組み込み型のそれと同じように定義するのが自然で す。
3.4.4 代入演算子では自己代入を避ける
代入演算子で最後のポイントは、オブジェクト自身を自分に代入する場合を避けるこ とです。第1に、ムダなことは避けるというのが常識的な理由です(常識的な判断は困 ったエラーを未然に防ぎます)。第2はコード上の問題を引き起こします。もし、コピ ーを受けるオブジェクトのメモリ領域を再設定するようにしている場合、データの値が 代入される前にその領域を削除してしまうことになります。 この様な状況を避けるには、自己代入に対しては何もしないで自己参照を返します。 つまり次のような定義を書きます。 Matrix &Matrix::operator=(const Matrix &b) { if(this != &b){ // オブジェクトbは自分自身ではない // オブジェクトbのデータメンバを正しく // 深いコピーで代入する } return *this; } ここでも、thisポインタを明示的に使っています。thisはメンバ関数に暗黙に渡され た現在のオブジェクトのアドレスです。これとコピー元のオブジェクトのアドレスの値 &bを比較して、自己代入をチェックします。 以上から、代入演算子の多重定義は次のように実装できます。 // クラス定義 class Matrix{ public: ........ Matrix &operator=(const Matrix &); // 代入演算子 private: ........ }; //多重定義された代入演算子の定義 Matrix &Matrix::operator=(const Matrix &right) { if(this != &right){ //自己代入をチェックする // サイズが違っていれば、サイズを再設定する if( (Row != right.Row) || (Col != right.Col) ) setSize(right.Row, right.Col); // 行列要素を代入する for(int i = 0; i < Row; i++) for(int j = 0; j < Col; j++) ptr[i][j] = right.ptr[i][j]; } return *this; // x = y = zと書けるようにする } [演習問題 3.3] 上の代入演算子の定義で、自己代入のチェックが無い場合、 どのような問題が起こるか説明しなさい。 以上まとめると、クラスXの代入演算子を多重定義する一般形式は、 X &X::operator=(const X&right) { if(this != &right){ // オブジェクトのすべてのデータメンバを正しく // 深いコピーで代入する } return *this; } となります。クラスが動的メモリを作成するときには、代入演算子を多重定義する必要 があります。
3.3.5 コピーコンストラクタの再考
代入演算子と同じ理由で、コピーコンストラクタも必要です。コピーコンストラクタ を持たないクラスで、既存のオブジェクトで初期化を行うと、C++は(デフォルトの) コンストラクタを作成して初期化を実行します。このデフォルトのコンストラクタの振 る舞いは浅いコピーによるものです。代入演算子も、クラスに明示的に定義されていな ければコンパイラによってデフォルトの代入演算子が生成されます。これも、浅いコピ ーを行います。 従ってクラスのコンストラクタが動的メモリを作成する場合は、コピーコンストラク タと代入演算子を定義する必要があります。コピーコンストラクタはオブジェクトを既 存のオブジェクトで初期化するために必要です。また代入演算子は、既に初期化された オブジェクトに代入を行うために必要です。わたしたちのコピーコンストラクタをもう 一度見てみましょう。 // コピーコンストラクタ Matrix::Matrix(const Matrix &init) { Row = init.Row; Col = init.Col; new_matrix(); // 行列の領域確保 for(int i = 0; i < Row; i++)//要素を代入 for(int j = 0; j < Col; j++) ptr[i][j] = init.ptr[i][j]; } この様に、正しく初期化するように定義されています。 もう一つ重要なことは、コピーコンストラクタの引数がconstオブジェクトへの参照 になっていることです。const参照型を引数にするのは、実行効率のためというより、 もっと重要な理由があります。 もしコピーコンストラクタを値呼び出しで定義したとすると、関数呼び出しの無限ル ープに陥ってしまいます。なぜなら、値呼び出しでは、コピーコンストラクタに渡すオ ブジェクトのコピーを作るために、そのコピーコンストラクタを再度呼び出すことにな るからです。従って、コピーコンストラクタでは参照呼び出しを使わなければなりませ ん。
3.4.6 ストリーム入出力演算子の多重定義
演算子の多重定義のもう1つの例として、ストリーム入出力演算子を考えます。スト リーム演算子を多重定義して、行列を入出力できるようにします。つまり、次のような プログラムが書けるように演算子を拡張してみましょう(行列ライブラリの設計(1) 2.11 考察とその他の話題 を参照)。 Matrix a(10,10); // 10行10列の行列 cin >> a; // 行列要素の入力 cout << a; // 行列要素を表示する ストリーム抽出演算子 >> は2つのオブジェクトに作用します。 cin >> a; ではistreamクラスのオブジェクトcinと行列aです。この様に2つに作用する演算子 を二項演算子といいます。二項演算子は2つの引数をとる関数 operator>>() で定義 します。引数に渡されるのは、2つのオブジェクト cin とaの参照です。そして戻り 値型がistreamクラスのオブジェクトへの参照になります。 それにより、ストリーム 演算子の連鎖的な利用ができます。ですからプロトタイプは次のようになります。 istream &operator>>(istream &, Matrix &); 第1引数が演算子(>>)の左側のオブジェクトに、第2引数が演算子の右側のオブジェク トに対応します。 同じく、ストリーム挿入演算子 << はostreamクラスのオブジェクトcoutとMatrix クラスのオブジェクトに作用する二項演算子で、関数 operator<<() で定義されます。 ostream &operator<<(ostream &, const Matrix &); この様に>> 演算子、<< 演算子が多重定義されると、次のコード cin >> a; に対して、 operator>>(cin , a ); が呼び出されます。同様に、 cout << a; に対しては、 operator<<(cout , a ); が呼び出され実行されます。
3.4.7 フレンド関数
この場合、operator>>() とoperator<<() はクラスのメンバ関数ではないことに 注意しましょう。これらは、Matrixオブジェクトの非メンバ関数ですので、行列要素に アクセスできません。 フレンド関数は、クラスの非公開メンバにアクセスできる許可を得た特別な非メンバ 関数です。クラスの外部関数(非メンバ関数)をフレンド関数にするには許可を与える クラスの定義の中でキーワードfriendを先頭に付加して宣言します。 // クラス定義 class Matrix{ // フレンド関数 friend ostream &operator<<(ostream &, const Matrix &); friend istream &operator>>(istream &, Matrix &); public: ........ private: ........ }; フレンド関数の宣言は、クラス定義内のどの場所で行っても構いません。次にこれらの 関数定義を示します。 // ストリーム入力演算子 istream &operator>>(istream& input, Matrix& a) { cout << a.Row << " 行" << a.Col << " 列の行列要素を入力してください" << endl; for(int i = 0; i < a.Row; i++) for(int j = 0; j < a.Col; j++){ cout << (i+1) << " 行" << (j+1) << " 列: "; input >> a.ptr[i][j]; } return input; } // ストリーム出力演算子 ostream &operator<<(ostream& output, const Matrix& a) { output.setf(ios::scientific); // 科学表記法 for(int i = 0; i < a.Row; i++){ for(int j = 0; j < a.Col; j++){ output << setw(15) << a.ptr[i][j]; if( !( (j+1) % 5 ) ) output << endl; } if( a.Col % 5 ) output << endl; } return output; } Matrixクラスの非公開メンバに、「a.Row」、「a.Col」、「a.ptr[i][j]」と直接ア クセスしています。注意すべきことは、キーワードfriendは内部データにアクセス許 可を与える側のクラス定義の中で行うということです。
3.4.8 機能拡張された行列クラスの実装
ここまでの、新しいバージョンのクラス定義と内部実装を以下に示します。
// matrix13.h // Matrixクラス インターフェイス部 #ifndef MATRIX13_H #define MATRIX13_H #include <iostream.h> #include <iomanip.h> #include <stdlib.h> class Matrix{ friend ostream &operator<<(ostream &, const Matrix &); friend istream &operator>>(istream &, Matrix &); public: Matrix(int = 0, int = 0);// デフォルトコンストラクタ Matrix(const Matrix &); // コピーコンストラクタ ~Matrix(); // デストラクタ void setSize(int, int); // 行列のサイズを設定する int getRow() const { return Row;} // 行数を取得 int getCol() const { return Col;} // 列数を取得 Matrix &operator=(const Matrix &); // 代入演算子 private: double **ptr; // 要素へのポインタ int Row; // 行の数 int Col; // 列の数 // ユーティリティ関数 void new_matrix();// 行列の領域を確保する void del_matrix();// 領域を開放する }; #endif |
// matrix13.cpp // Matrixクラス メンバー関数定義部 // コンストラクタでメンバー初期化リストを使う #include "matrix13.h" // デフォルトコンストラクタ Matrix::Matrix(int row, int col) : Row(row), Col(col) { new_matrix(); // 行列の領域確保 for(int i = 0; i < Row; i++)//要素を0に設定 for(int j = 0; j < Col; j++) ptr[i][j] = 0.0; } // コピーコンストラクタ Matrix::Matrix(const Matrix &init) : Row(init.Row), Col(init.Col) { new_matrix(); // 行列の領域確保 for(int i = 0; i < Row; i++)//要素を代入 for(int j = 0; j < Col; j++) ptr[i][j] = init.ptr[i][j]; } // デストラクタ Matrix::~Matrix() { del_matrix(); } // 行列のサイズを設定する void Matrix::setSize(int row, int col) { del_matrix(); // 行列の領域解放 Row = row; Col = col; new_matrix(); // 行列の領域確保 for(int i = 0; i < Row; i++)//要素を0に設定 for(int j = 0; j < Col; j++) ptr[i][j] = 0.0; } // 行列の領域を確保する void Matrix::new_matrix() { if(Row <= 0 || Col <= 0){ Row = 0; Col = 0; ptr = 0; return; } ptr = new double *[Row];//行の設定 if(ptr == 0){ cout << "エラー:領域確保に失敗しました。\n"; abort(); } for(int i = 0; i < Row; i++){//列の設定 ptr[i] = new double[Col]; if(ptr[i] == 0){ while(--i) delete [] ptr[i]; delete [] ptr; cout << "エラー:領域確保に失敗しました。\n"; abort(); } } } // 領域を開放する void Matrix::del_matrix() { for(int i = 0; i < Row; i++)// 列を解放 delete [] ptr[i]; delete [] ptr; //行を解放 } istream& operator>>(istream& input, Matrix& a) { cout << a.Row << " 行" << a.Col << " 列の行列要素を入力してください" << endl; for(int i = 0; i < a.Row; i++) for(int j = 0; j < a.Col; j++){ cout << (i+1) << " 行" << (j+1) << " 列: "; input >> a.ptr[i][j]; } return input; } ostream& operator<<(ostream& output, const Matrix& a) { output.setf(ios::scientific); // 科学表記法 for(int i = 0; i < a.Row; i++){ for(int j = 0; j < a.Col; j++){ output << setw(15) << a.ptr[i][j]; if( !( (j+1) % 5 ) ) output << endl; } if( a.Col % 5 ) output << endl; } return output; } //多重定義された代入演算子 Matrix &Matrix::operator=(const Matrix &right) { if(this != &right){ //自己代入をチェックする if( (Row != right.Row) || (Col != right.Col) ) setSize(right.Row, right.Col); for(int i = 0; i < Row; i++) for(int j = 0; j < Col; j++) ptr[i][j] = right.ptr[i][j]; } return *this; // x = y = zと書けるようにする } |
// lst03_10.cpp //Matrixクラスの入出力機能をテストする //Matrix13.cppと一緒にコンパイルする #include <iostream.h> #include "Matrix13.h" int main() { Matrix a(3,4); cin >> a; cout << a << endl; Matrix b; b = a; // 代入 cout << b << endl; return 0; } |
3 行4 列の行列要素を入力してください 1 行1 列: 1.1 1 行2 列: 1.2 1 行3 列: 1.3 1 行4 列: 1.4 2 行1 列: 2.1 2 行2 列: 2.2 2 行3 列: 2.3 2 行4 列: 2.4 3 行1 列: 3.1 3 行2 列: 3.2 3 行3 列: 3.3 3 行4 列: 3.4 1.100000e+00 1.200000e+00 1.300000e+00 1.400000e+00 2.100000e+00 2.200000e+00 2.300000e+00 2.400000e+00 3.100000e+00 3.200000e+00 3.300000e+00 3.400000e+00 1.100000e+00 1.200000e+00 1.300000e+00 1.400000e+00 2.100000e+00 2.200000e+00 2.300000e+00 2.400000e+00 3.100000e+00 3.200000e+00 3.300000e+00 3.400000e+00 |
このプログラムでは、3行4列の行列を作成し、ストリーム入力演算子を使って、行列 要素をキーボードから入力します。それからストリーム出力演算子を使って行列要素を 標準出力します。また、代入演算子を使って行列のコピーを作り、代入結果を出力して います。 この様にストリーム演算子を多重定義することで、例えばファイルからの入出力が可 能となるように、クラスオブジェクトの振る舞いをカスタマイズすることもできます。
3.4.9 メンバ初期設定子(初期化リスト)の使い方
上のメンバ関数の実装で、(デフォルト)コンストラクタの定義が少しだけ変更され ていることに注目しましょう。以前のものと比較すると、 // 以前のバージョンのコンストラクタ Matrix::Matrix(int row, int col) { Row = row; // 代入 Col = col; // 代入 new_matrix(); for(int i = 0; i < Row; i++) for(int j = 0; j < Col; j++) ptr[i][j] = 0.0; } // 今度のバージョンのコンストラクタ Matrix::Matrix(int row, int col) : Row(row), Col(col) { new_matrix(); for(int i = 0; i < Row; i++) for(int j = 0; j < Col; j++) ptr[i][j] = 0.0; } この違いは、以前のバージョンではデータメンバを初期化した後に代入しているのに対 して、今度のバージョンではデータメンバを初期化して与えています。同様な別の例を 示すと、 int data1 = 10; // 初期化 int data2; // 初期化(ただし値は不定) data2 = data1; // 代入 data1は、int型の記憶領域が作成されるときに値10がセットされます(初期化)。一 方data2では、int型の記憶領域が作成されて、その後値10が代入されます(代入)。 クラスのオブジェクトが生成される時には、次の2つのステップで行われます。 (1)データメンバの初期化 (2)コンストラクタの起動 つまり、コンストラクタが呼ばれる前にオブジェクトのデータメンバは作成されていま す。以前のバージョンでは、既に作成されたデータに対してコンストラクタの中で代入 を行っています。一方、今度のバージョンではコンストラクタが呼ばれる前にデータメ ンバは初期化されています。 これをメンバ初期設定子(初期化リスト)といいます。メンバ初期設定子の使い方 は、コンストラクタの引数リストの後に、コロン(:)を付けてメンバをカンマで区切 って並べます。「 : Row(row), Col(col)」では、Rowが値 rowで、Col が値 colで 初期化されます。メンバ初期設定子を使う方が効率の観点から優れています。 特に、クラスによってはメンバ初期設定子が必ず必要な場合があります。例えばクラ スが、定数変数(や定数オブジェクト)をデータメンバに持つ場合です。 class X{ public: X(int i = 0) : Data(i) { } //メンバ初期設定子で初期化 private: const int Data; // 定数 }; では、データメンバDataは定数です。つまり代入ができません。これはコンストラクタ の中でも代入できません。このクラスのコンストラクタをメンバ初期設定子を使わない で、 X::X(int i) { Data = i; } // エラー で定義すると、次のようにコンパイルエラーになります(Borland C++ Builderの場合)。
Warning W8038 test.cpp 4: Constant member 'X::Data' is not initialized in function X::X(int) Error E2024 test.cpp 4: Cannot modify a const object in function X::X(int) *** 1 errors in Compile *** |
最初の警告(Warning)では、定数メンバが初期化されていないため(コンパイラによっ てはエラーとする)。2番目のエラーはコンストラクタのなかで、定数メンバへの代入が 行われたからです。 [演習問題 3.4] 演習問題3.1で作成したベクトルを表現するクラス Vectorに 代入演算子と入出力演算子を多重定義して実装しなさい。 また、オブジェクトを配列で初期化するコンストラクタを実 装しなさい。 回答:インターフェース部( vector02.h ) 実装部( vector02.cpp ) [演習問題 3.5] 演習問題3.4で作成したクラスをテストするプログラム を書きなさい。 回答:( ex03_02.cpp )
| 目次 | 前のページ | 次のページ | ページの先頭 |
Copyright(c) 1999 Yamada, K