クラス


オブジェクト指向について

C++ や Java、C# などのオブジェクト指向言語を経験していれば、クラスという題名だけで、ここで何を解説しようとしているか理解しているはずです。 クラスとは、C 言語の構造体の概念を発展させ、再利用可能なデータと処理をパッケージ化して提供するための手段です。 熟練の C 言語プログラマであれば、関数ポインタをメンバに保有している構造体のようなものだと考えても良いでしょう。 D 言語にも構造体はありますが、構造体については後述します。

オブジェクト指向は一種のデザインパターンなので、C 言語で導入することも可能ですが、C 言語は言語仕様の特性上、オブジェクト指向に必要な処理過程を十分に自動化することができません。 しかし、D 言語はオブジェクト指向言語として設計されているため、言語仕様を学習することで優れた設計方法をスマートにプロジェクトに導入することができます。

オブジェクト指向とは、再利用性の高いデータと関数をテンプレートとして開発します。 このテンプレートをクラスと呼ぶのです。 クラスは構造体のようにメンバを保有することができますが、構造体とは異なりデータを格納する変数だけではなく、関数を提供することもできます。 しばしば、この関数をメソッドと呼ぶのがオブジェクト指向では一般的です。

クラスは、プログラムの実行時に必要な数だけ実体を生成することができます。 このときのクラスの実体をインスタンスまたはオブジェクトとも呼びます。 C 言語の構造体では、構造体型の変数を宣言するということは、単純に構造体と同じサイズのメモリを確保するということでした。 しかし、クラスはデータの塊を提供するだけではなく、そのデータを制御するための関数も同時に提供するのです。

クラスを利用するには、クラスを宣言し、クラスに付属するデータや関数など、メンバを宣言しなければなりません。 クラスの宣言には class キーワードを用いて次のように記述します。

class クラス名 { クラス本体 }

クラス本体には、データを保存するための変数や関数を指定します。 クラスに関連付けられている変数のことをフィールドと呼びます。 フィールドを利用するには、フィールド宣言文を記述しなければなりません。 基本的には、フィールド宣言はローカル変数宣言文と同じだと考えてよいでしょう。

class Point {
	int x , y;
}

例えば、この Point クラスは数値型の変数 x と y をフィールドとして保有しています。 フィールドには C 言語の構造体のようにメンバとしてアクセスすることができ、値を保存したし参照したりすることができます。 この Point クラスは 2 次元座標を表す 2 つの数値を関連付ける役割を果たしています。 2 次元座標を表す 2 つの数値を個別に扱うよりも、論理的な繋がりを 1 つにまとめたほうが扱いやすいのです。

C や C++ 言語では、構造体やクラスの変数を宣言するときに、定義に続いて次のように変数を作成することができました。

class Point {
	int x , y;
} pt ;

しかし、D 言語ではこのような表記は許されていないので注意してください。 定義したクラスの変数を作成する場合は、クラス名を型として変数宣言文を用います。

Point pt;

これで、Point クラス型の変数 pt を作成することができました。 クラス型の変数が指しているオブジェクトのフィールドにアクセスするには、オブジェクトに続いて後置演算子 . を指定します。

オブジェクト名 . フィールド名

例えば、上記した Point クラスの変数 pt から、Point クラスのメンバ x と y にアクセスするには次のように記述することになるでしょう。

pt.x = 10;
pt.y = 100;
value = pt.x * pt.y;

ただし、D 言語のクラス型変数はローカル変数宣言文で宣言するだけでは利用することができません。 次のコードを実行した場合は、無事にコンパイルすることができても実行時にアクセス違反による実行時エラーが表示されるはずです。

Point pt;
pt.x = 10;

なぜならば、D 言語におけるクラス型変数とは、クラスの実体を格納しているヒープメモリへの参照だからです。 つまり、pt 変数はクラスの実体を保存しているメモリへのポインタだと考えてください。 だとすれば、ポインタを宣言しただけで、pt 変数にはメモリアドレスが格納されていないのですから、その状態でメンバへのアクセスを試みても失敗するという原理は理解できるでしょう。 (このような性質から、クラスが他の変数を参照変数などと表現することもあります)

クラスの実体であるインスタンスを生成するには new 演算子を用いて、ヒープを割り当てなければなりません。 この式を new 式と呼びます。

new クラス名

これが、基本的な new 式の使い方です。 new 演算子によって、プログラムはクラスを表現するために必要なメモリをヒープ領域から割り当て、割り当てたヒープ領域をガベージコレクタの対象として管理します。 C 言語の malloc() 関数や C++ 言語の new 演算子とは異なり、これに対応する free() 関数や delete 演算子といったメモリ解放は、原則必要ありません。

import std.stream;

class Point {
	int x , y;
}

int main() {
	Point pt = new Point;
	pt.x = 10;
	pt.y = 100;

	stdout.printf("pt.x = %d, pt.y = %d\n" , pt.x , pt.y);
	return 0;
}

このプログラムを実行すると、プログラムは Point クラスのインスタンスをヒープ領域から生成し、このオブジェクトの x フィールドと y フィールドに値を保存しています。 printf() 関数では、これらの値を参照して画面に値を表示しています。


オブジェクトの参照と null

クラス型の参照変数が何のオブジェクトも参照していない場合は、変数からオブジェクトにアクセスすることはできません。 ポインタが適切な値を参照していないのですから、不正なポインタからデータにアクセスすれば、実行時にエラーが発生することは C 言語プログラマであれば理解できるはずです。

では、より安全なコードを記述するには、クラス型の変数が適切なオブジェクトを参照しているのかどうかを調べる必要があります。 C 言語では、アドレス 0 を格納しているポインタは、どの有効なアドレスとも異なることを保障していました。 D 言語では、変数が有効な参照を保有していないことを表すには null キーワードを用います。

null は、予約語で明確に定められた空の変数を表す汎用型なので、任意の参照型に変換されます。 クラス型の変数が有効なオブジェクトを参照していない場合、null と比較して true を返します。 ところが、次の式を実行した場合も実行時にエラーが発生してしまいます。

Point pt;
stdout.printf("pt == null = %d\n" , pt == null);

上記の式 pt == null は、クラス型の参照変数が適切なオブジェクトを参照しているか、それもと空なのかを調べているように思えます。 確かに、Java や C# などのオブジェクト指向型言語では、この方法で変数が null に等しいかどうかを調べることができるのですが、D 言語では == 演算子の意味を独自の処理に置き換える「演算子のオーバーロード」と呼ばれる機能があるため、== 演算子を用いるとオブジェクトが参照されてしまうのです。

演算子 == では、変数が参照しているオブジェクトが本質的に同一かどうかを調べることができないため、D 言語ではオブジェクトの比較に is 演算子を用います。

参照変数1 is 参照変数2

is は 2 項演算子で、左右のオペランドに指定されたオブジェクトが同じオブジェクトであれば true を、そうでなければ false を返します。 オブジェクトが空であるかどうかを調べたい場合は is 演算子でオブジェクトと null を比較します。

import std.stream;

class Point {
	int x, y;
}

int main() {
	Point pt1 , pt2 = new Point;
	stdout.printf("pt1 is null = %d\n" , pt1 is null);
	stdout.printf("pt2 is null = %d\n" , pt2 is null);
	return 0;
}

このプログラムの pt1 変数は、適切に初期化していないのでデフォルトの値 null を参照しています。 一方、pt2 は生成したインスタンスを参照しています。 これらの変数を null と比較した結果を表示すると pt1 is null は true、pt2 is null は false となります。

因みに is 演算子のオペランドにオブジェクト以外を指定した場合は == 演算子と同じ働きになります。

これまで、何度もクラス型の変数は実体メモリを保有するのではなく、new 演算子で動的に確保したヒープメモリを参照するポインタであることを説明してきました。 そのため、代入演算子で互換性のある他のクラス型変数にオブジェクトを代入すると、参照をコピーするという意味になります。 このとき、インスタンスが複製されるわけではないことに注目する必要があります。

import std.stream;

class Point {
	int x, y;
}

int main() {
	Point pt1 = new Point;
	Point pt2 = pt1;

	pt2.x = 10;
	pt2.y = 100;

	stdout.printf("pt1.x = %d, pt1.y = %d\n" , pt1.x , pt1.y);
	stdout.printf("pt1 is pt2 = %d" , pt1 is pt2);
	return 0;
}

このプログラムは、オブジェクトの代入が単純なポインタの複製であることを証明します。 pt1 は new 演算子で新たに生成した Point クラスのインスタンスを参照しています。 そして、同じ Point 型の変数 pt2 に pt1 を単純に代入しています。

プログラムでは、pt2 変数からインスタンスのフィールドの値を変更しています。 pt1 変数が参照するオブジェクトのフィールドは変更していませんが、出力結果を見ると pt1 のフィールドの値が pt2 から変更した値であることを確認できます。 これは is 演算子の結果からも分かるように、pt1 と pt2 変数が参照しているオブジェクトが同一であるためです。


メンバ関数

クラスの本体には、フィールドの他に関数を指定することができます。 クラス本体で定義された関数は、フィールド同様に、クラスのインスタンスに関連付けられます。 C 言語の構造体とメンバを制御するコードは完全に分離されていましたが、D 言語のクラスを用いれば、論理的に繋がりのあるデータとデータの処理を結びつけることができるのです。

クラスに対応した関数を、C++ や D 言語ではメンバ関数、オブジェクト指向の世界ではしばしばメソッドとも呼びます。 メンバ関数と単純なグローバル関数の違いは、メンバ関数のスコープがオブジェクトに対応していることです。 メンバ関数が呼び出されるということは、つまり関連するクラスのオブジェクトがその時点で確実に存在していることを意味しているので、メンバ関数は無条件でオブジェクトのフィールドにアクセスすることができます。 メンバ関数がフィールドにアクセスする場合は、オブジェクトを指定する必要はなく、フィールド名を直接指定して参照することができるのです。

import std.stream;

class Point {
	int x , y;
	void Write() {
		stdout.printf("X = %d : Y = %d\n" , x , y);
	}
}

int main() {
	Point pt1 = new Point;
	Point pt2 = new Point;

	pt1.x = 10;
	pt1.y = 100;

	pt2.x = 1000;
	pt2.y = 10000;

	pt1.Write();
	pt2.Write();

	return 0;
}

このプログラムの Point クラスでは、オブジェクトが保有するデータを標準出力に出力する Write() メンバ関数を定義しています。 Point クラスの Write() メンバ関数では、オブジェクトのフィールドを表示するために x と y 変数にアクセスしていますが、オブジェクトは指定してません。 プログラムを実行して確認できるように、メンバ関数が呼び出されている時点でメソッドはオブジェクトに暗黙的に関連付けられているのです。 pt1.Write() の実行では pt1 オブジェクトのフィールドが、pt2.Write() では pt2 オブジェクトのフィールドが参照されています。

もちろん、メンバ関数に引数を渡したり、メンバ関数から戻り値を受け取ることもできます。 これらの宣言方法も、基本的に関数とは変わりませんが、メンバ関数を対象にデータを受け渡しする場合は、通常、そのクラスに関連したデータであるべきです。

class Point {
	int x, y;
	void SetPoint(int x , int y) { ... }
}

このように、メンバ関数が引数を受け取ってクラスのデータに関する処理を行うことができます。 通常、クラスのフィールドに関連した処理を行うコードは、クラスのメンバ関数として記述されるべきです。

上記のように、クラスのフィールド名とメンバ関数のローカル変数名が衝突することは問題にはなりません。 メンバ関数内のコードは、よりスコープに近い識別子を優先的に参照します。 しかし、メンバ関数内のコードは同一のフィールド名とローカル変数名が衝突している場合、メンバ関数からフィールドにアクセスできなくなってしまいます。 この問題を解決するためには this キーワードを用いた原始式を使います。

this . メンバ名

this キーワードは、メンバ関数など、オブジェクトを持つクラス内部のコード領域以外で利用することはできません。 this キーワードは、暗黙的にオブジェクトを参照します。 自分自身へのポインタのような存在なので、this を指定すればオブジェクトのメンバを参照することができます。

import std.stream;

class Point {
	int x , y;
	void SetPoint(int x, int y) {
		this.x = x;
		this.y = y;
	}
	void Write() {
		stdout.printf("X = %d : Y = %d\n" , x , y);
	}
}

int main() {
	Point pt1 = new Point;

	pt1.SetPoint(10 , 100);
	pt1.Write();

	return 0;
}

このプログラムは、フィールドを一度に初期化してくれるメンバ関数 SetPoint() を定義していますが、この関数の仮引数の変数宣言を見ると、フィールドの変数名と名前が同じです。 この状態では、x や y という名前を指定するとローカル変数が優先されるため、フィールドにデータを保存することができません。 変数名を衝突しないようにプログラムすればそれで済む話ですが、フィールドと関連のあるデータを頻繁に扱うメンバ関数のプログラムでは、このような現象が頻繁に発生するため、this 式を用いてフィールドを明示的に参照したほうがわかりやすいのです。

因みに、this キーワードを用いてオブジェクトの他のメンバ関数を呼び出すこともできます。 メンバ関数の内部関数も認められているので、内部関数とメンバ関数名が衝突している場合などに利用できるでしょう。



前のページへ戻る次のページへ