オセロゲームを作ろう
手続き型言語とオブジェクト指向言語との違いを、具体的にプログラミングをしながら考えてみる。例として、手続き型言語はC、オブジェクト指向言語はJavaを取り上げる。
課題として、人間とコンピュータが対戦できるオセロゲームの、コンピュータプレーヤーの頭脳を作成することにしよう。オセロゲームは、8x8のマスに、交互に白と黒の石を置いていき、石を置いた時、その色で挟まれた石を裏返す、というゲームである。ここで作成するのは、盤面の情報から、最適な次の一手を決めるプログラムだ。この手続きは、次のようになる。
コンピュータプレーヤーの手続き
入力 盤面 出力 次の一手を置く位置
まず言えることは、石を置ける場所には限りがある、ということだ。少なくとも、既に石が置いてあるマスには、置くことはできない。また、石を置いた時、必ず相手の石を挟んで裏返しにしなくてはいけない。どこに置いても裏返せない場合は、そのターンはパスしなくてはいけない。
更に、この基本的なルールを守るだけでは面白くない。コンピュータプレーヤーはなるべく強くしたい。そのためには、なるべく多く相手の石を裏返しにすることができる位置に石を置くようにした方が良さそうだ。だが、実際にはそう単純ではなく、いくつかの定石もある。また、数手先まで先読みして、結果的に多くの石を残すためには、一時的に相手の石を少ししか裏返しにしない位置に石を置いた方が良い場合もある。
これらのことを考えると、オセロプログラムに必要な条件は、次の通りとなる。
- 1. 盤面から、石を置いて良い場所をリストアップする機能が必要である。
- 2. 石をどこにも置けない場合は、パスという判定をする必要がある。
- 3. 先読みなど、いろいろな方法を駆使して、強い一手を探す必要がある。
ところが、どういう方法でやれば最適な一手が打てるのかは、良く分からない。方法としてはいろいろありそうだが、どれが良いのかは試してみるしか無さそうだ。そこで
- 4. 次の一手を求める手続きは、いろいろな方法で、複数作成して、その中から選ぶようにする。
- 5. どう選べば良いかは分からないので、ランダムに選ぶ。
という条件も定めた。
C言語の場合
まずは、このオセロプログラムを手続き型言語であるC言語で作成してみよう。
手続き型プログラミングでは、まず、どういう機能が必要かをリストアップし、それぞれの機能をモジュールとする。更に、このモジュールを分割して、できるだけ細かくしておく。最終的には、それぞれのモジュールを関数として作成する。
前章で挙げた6つの条件を踏まえて、必要なモジュールを考える。
条件1は、少し表現を変えて、ある位置に石を置いて良いかどうかを判定するものとしよう。
条件1のモジュール
入力 盤面、位置 出力 石を置いて良いかどうか
このモジュールには、judge_put という名前を付ける。
条件2は、盤面で空いている位置をすべて調べて、どこに置いても裏返せる石が無いならパス、という処理を行うモジュールになるだろう。
条件2のモジュール
入力 盤面 出力 パスかどうか
このモジュールには、judge_pass という名前を付ける。
また、盤面に石を置いて、挟んだ相手の石を裏返すモジュールも必要だろう。ここで、先読みをする場合や、最適な一手を選ぶことを考えると、裏返した石の個数を返すようにしておくと良さそうだ。
相手の石を裏返すモジュール
入力 盤面、石を置く位置 出力 裏返しにした石の個数
このモジュールには、put という名前を付ける。
先読みの場合は、実際には石を置かないで、置いたとしたら裏返しにできる石の個数だけを調べたい場合もある。そういうモジュールも用意しよう。
裏返しにできる石の個数を調べるモジュール
入力 盤面、石を置く位置 出力 裏返しにできる石の個数
このモジュールには、try_put という名前を付ける。
こうして、一通り必要そうな機能を持ったモジュールが出揃ったら、実装方法を考える。
まず、データ形式として、盤面は、8x8の2次元整数配列とし、0が空いているマス、1が自分の色の石が置いてあるマス、2が相手の色の石が置いてあるマスとしよう。また、石を置く位置は、この2次元配列の添え字で表現しよう。
ここで、マスの値を直接書くのではなく、定数としておいた方が可読性が増す。また、汎用の int 型ではなく、マス専用の型 SQUARE_TYPE を作成した方が、より可読性が増す。
typedef enum { SQUARE_EMPTY, SQUARE_MINE, SQUARE_OPPONENT } SQUARE_TYPE;
先にリストアップしたモジュールの関数は、以下のように考えられる。
int judge_put ( const SQUARE_TYPE **board, int row, int column ); int judge_pass ( const SQUARE_TYPE **board ); int put ( SQUARE_TYPE **board, int row, int column ); int try_put ( const SQUARE_TYPE **board, int row, int column );
ところが、多少問題がある。例えば、judge_put という関数を呼び出す時は、
int result = judge_put(board, 2, 3);
のようになるだろう。しかし、これでは
- 2, 3 のどちらが行と列のどちらを表しているのか分からない。
- result に何が返ってくると石を置けるのか分からない。
という問題がある。これも、先ほどと同様に、
typedef struct { int row; int column; } POSITION; typedef enum { JUDGE_OK, JUDGE_ERROR } JUDGE_RETURN;
のように、構造体や列挙型データを作って、以下のように定義した方が良い。
JUDGE_RETURN judge_put ( const SQUARE_TYPE **board, POSITION position ); JUDGE_RETURN judge_pass ( const SQUARE_TYPE **board ); int put ( SQUARE_TYPE **board, POSITION position ); int try_put ( const SQUARE_TYPE **board, POSITION position );
こうすると、
POSITION position; position.row = 2; position.column = 3; JUDGE_RETURN result = judge_put(board, position); if (result == JUDGE_OK) { }
のように、呼び出す側のプログラムを見ても、先ほどは分からなかった点が明確になる。
上記のサンプルプログラムのうち、4行目の関数(judge_put)の2番目の引数(position)が、誤ってcolumnになっていました。
ご指摘して下さった木村祥久様に感謝致します。
put と try_put の2つの関数は、裏返しにした石の個数を返す。だが、
- 指定した位置には既に石があって、置けない。
- 指定した位置には石は無いが、置いても1つも裏返しにならない。
というケースがある。前者は指定した位置のエラーなので、エラーを返した方が良さそうだ。そこで、-1 を返すようにしよう。後者はエラーとは言えないので、0 を返すようにする。
次に、オセロプログラムのメインテーマである、次の一手を決めるモジュールを考えよう。条件4にあるように、このモジュールは、いろいろな方法で実装して、複数作成する。ここでは、その1つとして、石を置いても良い場所のうち、最初に見つかった場所に置く、という、一番単純な方法のモジュールを作ろう。このモジュールは、simple_itte という名前にする。
このモジュールの入出力は、以下の通りである。
次の一手を決めるモジュール
入力 盤面 出力 次の一手を置く位置
この関数は、
POSITION simple_itte ( SQUARE_TYPE **board );
と定義できそうに思える。ところが、これだとパスしなくてはいけない場合に、何を返して良いのかに困る。パスの場合はエラーを返すようにするには、次の一手を置く位置を保存するポインタを渡して、次のようにするしかない。
JUDGE_RETURN simple_itte ( SQUARE_TYPE **board, POSITION *position );
この関数は、以下の通りになる。
JUDGE_RETURN simple_itte ( SQUARE_TYPE **board, POSITION *position ) { POSITION p; for (p.row = 0 ; p.row < 8 ; p.row++) { for (p.column = 0 ; p.column < 8 ; p.column++) { if ( try_put(board, p) > 0 ) { *position = p; return JUDGE_OK; } } } return JUDGE_ERROR; }
もっと複雑な方法で一手を考える関数を、いくつか作成したとしよう。
JUDGE_RETURN itte1 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte2 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte3 ( SQUARE_TYPE **board, POSITION *position );
この時、メインの手続きは、条件5から、結果をランダムに選ぶので、以下のようになる。
JUDGE_RETURN result; switch (rand() % 4) { case 0: result = simple_itte(board, &position); break; case 1: result = itte1(board, &position); break; case 2: result = itte2(board, &position); break; case 3: result = itte3(board, &position); break; } return result;
パスの場合もありうるので、これまでと同様に、パスかどうかを返すようにし、結果は引数に与えられたポインタに保存するようにしている。
Java言語の場合
次に、このオセロプログラムをオブジェクト指向言語であるJava言語で作成してみよう。
C言語の場合は、どういう機能が必要かに着目し、モジュールを作成した。オブジェクト指向言語であるJava言語では、機能ではなく、どういう「モノ」があるか、に着目し、リストアップした「モノ」を、それぞれクラスとして作成する。
このオセロプログラムでは、どういう「モノ」があるだろうか。この時点では、思い付くのは「盤面」と、あとは「次の一手を決める頭脳」くらいしか無い。条件4から、いろいろな種類の「次の一手を決める頭脳」があって、それらをすべて働かせて、得られた結果から1つ選び、その結果を「盤面」に反映させれば良いだろうと思われる。
そこで、まずは「盤面」のクラスを作成しよう。名前を Board とする。「盤面」のクラスは、8x8のマスの状態(空、自分の石がある、相手の石がある)を保持する必要がある。これは、C言語の場合と同様、2次元整数配列で持つとしよう。そうすると、Board クラスは次のようになる。
public class Board { private int[][] board = new int[8][8]; private final static int SQUARE_EMPTY = 0; private final static int SQUARE_MINE = 1; private final static int SQUARE_OPPONENT = 2; public Board ( ) { for (int row = 0 ; row < 8 ; row++) { for (int column = 0 ; column < 8 ; column++) { board[row][column] = SQUARE_EMPTY; } } } }
この「盤面」クラスには、以下のような機能が必要だろう。
- 指定した位置が空かどうかを調べる。
- 指定した位置に自分の石が置いてあるかどうかを調べる。
- 指定した位置に相手の石が置いてあるかどうかを調べる。
- 指定した位置に自分の石を置く。
- 指定した位置に自分の石を置いたとして、裏返せる石の数を数える。
- 自分の石の個数を数える。
- 相手の石の個数を数える。
これくらいの機能を揃えれば、この盤面上でオセロゲームを進行させて、画面表示などもできそうである。これらの機能を、「盤面」クラスのメソッドとして作成する。具体的には、以下のようになるだろう。
// 指定した位置が空かどうかを調べる。 public boolean isEmpty ( Position position ); // 指定した位置に自分の石が置いてあるかどうかを調べる。 public boolean isMine ( Position position ); // 指定した位置に相手の石が置いてあるかどうかを調べる。 public boolean isOpponent ( Position position ); // 指定した位置に自分の石を置く。 public void put ( Position position ); // 指定した位置に自分の石を置いたとして、裏返せる石の数を数える。 public int tryPut ( Position position ); // 自分の石の個数を数える。 public int countMine ( ); // 相手の石の個数を数える。 public int countOpponent ( );
石を置く位置は Position という別のクラスとした。これは、C言語の場合の構造体 POSITION とほぼ同じものである。
ここで、いくつかのメソッドでは、エラーが発生することが予想できる。具体的には、
- 指定した位置が空かどうかを調べる。
- 位置が盤面をはみ出している。
- 指定した位置に自分の石が置いてあるかどうかを調べる。
- 位置が盤面をはみ出している。
- 指定した位置に相手の石が置いてあるかどうかを調べる。
- 位置が盤面をはみ出している。
- 指定した位置に自分の石を置く。
- 位置が盤面をはみ出している。
- その位置には石を置けない。
- 指定した位置に自分の石を置いたとして、裏返せる石の数を数える。
- 位置が盤面をはみ出している。
- その位置には石を置けない。
というエラーの可能性がある。エラーとしては、位置が盤面をはみ出している場合と、その位置には石を置けない場合の2つである。Java言語では、エラーは例外として、特別な処理を行う。そこで、この2つの場合を、それぞれ OutOfBoardException と CannotPutException という名前の例外にしよう。すると、メソッドの定義は次のようになる。
// 指定した位置が空かどうかを調べる。 public boolean isEmpty ( Position position ) throws OutOfBoardException; // 指定した位置に自分の石が置いてあるかどうかを調べる。 public boolean isMine ( Position position ) throws OutOfBoardException; // 指定した位置に相手の石が置いてあるかどうかを調べる。 public boolean isOpponent ( Position position ) throws OutOfBoardException; // 指定した位置に自分の石を置く。 public void put ( Position position ) throws OutOfBoardException, CannotPutException; // 指定した位置に自分の石を置いたとして、裏返せる石の数を数える。 public int tryPut ( Position position ) throws OutOfBoardException, CannotPutException; // 自分の石の個数を数える。 public int countMine ( ); // 相手の石の個数を数える。 public int countOpponent ( );
これらのメソッドを実装すれば、オセロゲームが一通り進行できる。もう1つの「次の一手を決める頭脳」はまだ無いので、コンピュータプレーヤーとの対戦はできないが、この時点で、人間対人間のオセロゲームを作成することは可能になっている。
次に、オセロプログラムのメインテーマである、「次の一手を決める頭脳」を作成しよう。前述した通り、条件4から、いろいろな種類の「次の一手を決める頭脳」を作成することになる。しかし、どんな頭脳も同じ頭脳であり、盤面を入力とし、次の一手を置く位置を決めるメソッドがある、という点では変わりが無い。よって、まずはこの頭脳の基本となる「頭脳」クラスを作成する。名前を Brain としよう。
public abstract class Brain { public abstract Position itte ( Board board ) throws CannotPutException; }
パスしなくてはいけない場合は、CannotPutException という例外を投げるようにしている。
次の一手を決めるメソッドの名前(itte)と引数、返り値の仕様を定義しただけなので、abstract class としている。実際には、この Brain クラスを継承して、itteメソッドを実装した、いろいろな種類のサブクラスを作成しないといけない。
例として、C言語で作成した simple_itte という関数と同じ機能を持った、SimpleBrain というクラスを作成すると、次のようになる。
public class SimpleBrain extends Brain { public Position itte ( Board board ) throws CannotPutException { Position p = new Position(); for (p.row = 0 ; p.row < 8 ; p.row++) { for (p.column = 0 ; p.column < 8 ; p.column++) { try { if ( board.isEmpty(p) ) { if ( board.tryPut(p) > 0 ) { return p; } } } catch ( OutOfBoardException e ) { // 盤面上の位置しか指定しないので、この例外は発生しない } catch ( CannotPutException e ) { // 先に空かどうか調べているので、この例外は発生しない } } } throw new CannotPutException(); // パス } }
このような、一手を決める機能を持った Brain クラスのサブクラスが複数あったとして、メインの手続きは、条件5から、結果をランダムに選ぶので、以下のようになる。
Vector list = new Vector(); list.addElement(new SimpleBrain()); // 取り敢えず、今はこれしかない。 int index = rand() % list.size(); Brain brain = (Brain)list.elementAt(index); return brain.itte(board);
パスの場合は、Brain クラスのメソッド itte が、例外を返した。例外は、通常の返り値とは違い、特別扱いされる。この例では、パスであれば、このメインの手続きを呼び出した大元で、パスの処理をする必要があり、この手続きの中では、パスであることを返せば良い。よって、例外に対して何もせず、例外が発生したら、そのまま呼び出し元に例外が渡されるようにしている。
もっと複雑な方法で一手を考える機能を持ったクラスを、いくつか作成したとしよう。
public class Brain1 extends Brain { 略 } public class Brain2 extends Brain { 略 } public class Brain3 extends Brain { 略 }
この場合も、list にこれらの「頭脳」を追加するだけで良い。
機能追加
プログラムをより強くするためには、コンピュータが選んだ一手が、どういう先読みをして、どういう基準で決定されたのかを、人間が画面などで見て確認できるような仕組みもあると良さそうだ。そこで、新たに、次の条件も追加するとしよう。
- 6. 一手を決める時に行った先読みの過程を、画面に表示したり、ファイルに記録したりできる。
画面に表示すれば、石を置ける場所の1つ1つについて、試しに石を置いてみると、どの石が裏返るかが、アニメーション表示されるだろう。また、ファイルに保存する場合は、
(1,2)に石を置くと、(1,3) (1,4) (1,5) の3個が裏返ります。 (3,5)に石を置くと、(4,5) (5,5) (4,6) (5,7) の4個が裏返ります。
のような出力が続くだろう。
このような機能の追加を、先ほど作成したC言語のプログラムとJava言語のプログラムに施すことを考える。
C言語の場合は、
JUDGE_RETURN simple_itte ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte1 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte2 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte3 ( SQUARE_TYPE **board, POSITION *position );
という4個の関数を作成していた。これらに、先読みの過程の出力先へのポインタを引数として渡すように、修正をすることになる。ファイルであれば FILE * だろう。画面の場合は、環境依存になるが、例えば Windows であれば HWND になる。その結果、引数が
JUDGE_RETURN simple_itte ( SQUARE_TYPE **board, POSITION *position, FILE *fp, HWND hwnd ); JUDGE_RETURN itte1 ( SQUARE_TYPE **board, POSITION *position, FILE *fp, HWND hwnd ); JUDGE_RETURN itte2 ( SQUARE_TYPE **board, POSITION *position, FILE *fp, HWND hwnd ); JUDGE_RETURN itte3 ( SQUARE_TYPE **board, POSITION *position, FILE *fp, HWND hwnd );
となる。引数が増えたので、メインの手続きも修正する必要がある。
Java言語の場合は、まず、「先読みの過程の出力先」というクラスを作成する。名前は OutputTarget としよう。これは、実際にはファイルであったり、画面であったりするので、メソッドの定義だけをした interface として作成する。
画面でアニメーションすることと、ファイルに出力する内容を考えると、入力としては、石を置いた位置と、置く前の盤面、置いた後の盤面の3つがあれば良さそうだ。そこで、OutputTarget は次のようになる。
public interface OutputTarget { public abstract void output ( Position position, Board before_board, Board after_board ) throws Exception; }
Java言語の場合も、次の一手を決める機能を持った4個のクラスを作成していたが、それらはすべて Brain という共通のスーパークラスを継承していた。よって、修正はこの Brain クラスに対して行う。具体的には、次のようにした。
public abstract class Brain { protected Vector list = new Vector(); public void addOutputTarget ( OutputTarget target ) { list.addElement(target); } public abstract Position itte ( Board board ) throws CannotPutException; }
「先読みの過程の出力先」を複数メンバーとして登録できるようになった。よって、ファイルと画面に出力する場合は、
public class FileTarget implements OutputTarget; public class WindowTarget implements OutputTarget;
という、OutputTarget を実装したクラスを作成し、Brain オブジェクト brain に対し、
brain.addOutputTarget(new FileTarget()); brain.addOutputTarget(new WindowTarget());
とすることになる。片方にしか登録しなければ、片方にだけしか出力されない。
実際に出力をするには、SimpleBrain など、Brain のサブクラスも修正をする必要がある。また、メインの手続きで、addOutputTarget メソッドを呼び出すように修正をする。
手続き型言語Cとオブジェクト指向言語Javaの違い
本稿は、オセロプログラムをC言語とJava言語で作成してきた。ここでは、その過程で明らかになった、手続き型言語であるCと、オブジェクト指向言語であるJavaとの違いについてまとめる。
制約と可読性
例として、盤面上の指定した位置が空かどうかを調べる方法を、C言語とJava言語とで比較する。
C言語の場合は、2次元整数配列を board、POSITION型の位置を position とすると、
if ( board[position.row][position.column] == SQUARE_EMPTY ) { /* 空 */ }
となる。
一方、Java言語の場合は、Board オブジェクトを board、Position オブジェクトを position とすると、
if ( board.isEmpty(position) ) { // 空 }
ここで行いたい処理は、
「盤面のある位置が空かどうか」
である。これを英語で表記すると、
If a square at the position on the board is empty or not.
である。明らかに、Java言語の場合の方が、英語の文章そのものに近く、可読性が高い。
実は、C言語の場合も、
#define NO 0 #define YES 1 int isEmpty ( SQUARE_TYPE **board, POSITION position );
という関数を作成すれば、
if ( isEmpty(board, position) == YES ) { /* 空 */ }
と、先ほどよりは英語の文章に近くなる。しかし、2つの点で、Java言語に比べて劣っている。
1つは、対象が不明確であるということだ。オブジェクト指向では、メソッドはある特定のオブジェクトを対象として起動される。そのため、盤面に対し、位置を指定して、空かどうかを調べる、という処理が、コーディング上も明確になる。ところが、C言語の関数では、board と position が同等の立場にあるように見えてしまう。
もう1つは、C言語では型に関する制約が無いということだ。たとえ isEmpty という関数を用意したとしても、最初のようなコーディングをしても、プログラムは作成できてしまう。それどころか、
if ( board[position.row][position.column] == 0 ) { /* 空 */ }
や
SQUARE_TYPE *ptr = (SQUARE_TYPE *)board[0][0]; ptr += position.row * 8 + position.column; if ( *ptr == 0 ) { /* 空 */ }
というコードでも動いてしまう。結果として、データが抽象化されないままのプログラムが作成されてしまう。
Java言語の場合は、board は Board クラスの private メンバーなので、直接アクセスすることはできない。そのため、空かどうかを調べるには、isEmpty メソッドを利用するしかない。
隠蔽(カプセル化)
Java言語で作成した Board クラスでは、マスの状態を保存する board メンバーを private とし、外部から直接アクセスできないようにした。また、この board の状態を調べるのに、board に格納されている値そのものを返すメソッドではなく、状態の判定を行う3種類のメソッド isEmpty, isMine, isOpponent を用意した。更に、board の変更は、put という、石を置く操作を表すメソッドでしか行えないようにした。これらの結果、Board クラスの実装は、外部からは隠蔽され、カプセル化されている。
カプセル化の効果により、Board クラスの実装を変更しても、Board クラス以外のプログラムには、まったく影響しないようになっている。例えば、盤面の情報がここではメモリ上の2次元配列となっているが、メモリではなくファイルに保存しておくようにしたり、インターネット経由でサーバに保存しておくようにしたりしても、修正は Board クラスだけで済む。C言語のような実装方法では、このような修正は大変困難になる。
C言語では、マスの状態を保存する変数boardは、可読性向上のために、int型ではなく、SQUARE_TYPE型という専用の型を用意して、その2次元配列とした。一方、Java言語で作成した Board クラスでは、board は汎用のint型の2次元配列としていた。これも、カプセル化のために、board にアクセスするのは Board クラス自身だけであるため、int型であっても混乱を招かないと判断してのことである。
C言語では、SQUARE_TYPE型の2次元配列としたが、実際にはint型と同様に扱われてしまう。そのため、
board[i][j] = 30; board[i][j] = board[k][l] - 5;
といった、意味のない演算を入れても、コンパイルされ、動作してしまう。Java言語では、put メソッドでしか盤面の値を変更できないため、Board クラス自身が完璧に作成されていれば、不正な値が格納されてしまうことはない。
返り値と例外
Java言語には例外という仕組みがあり、通常の結果から外れる例外的なケースが発生した場合は、例外として特別扱いをされる。その結果、以下の3つの利点が生まれている。
1つは、そのメソッドでどのようなエラーが発生し得るかが、ソースコードのレベルで明確に定義されていることである。例えば、指定した位置に自分の石を置く、という put メソッドでは、
- 位置が盤面をはみ出している (OutOfBoardException)
- その位置には石を置けない (CannotPutException)
という2つのエラーの可能性があると分かる。
もう2つは、結果は返り値、エラーは例外と、明確に分離されていることである。C言語では、エラーの表し方に規定が無いため、関数によって、返り値が結果とエラーとを兼ねている場合や、エラーをまったく返さない場合など、統一された形式が無い。本稿のプログラムでも、judge_put 関数では返り値は結果のみ、put 関数では結果とエラーとを兼ね、simple_itte 関数ではエラーのみを返すと、ばらばらになった。それぞれの関数を設計している時点では、その関数にとって自然な方法を選択しているのだが、結果として、プログラム全体の可読性を低下させている。
部品の独立性と設計アプローチ
本稿で、手続き型言語であるCと、オブジェクト指向言語であるJavaで、それぞれ同じ機能を持ったプログラムを作成したが、そのアプローチの仕方は大きく異なった。
C言語では、まず全体の機能から、必要な機能をリストアップし、それを細分化して、モジュールを作成する、というアプローチをとった。一方、Java言語では、「盤面」と「頭脳」という2つの「モノ」を作成し、それぞれについて、必要な機能をメソッドとして作成した。これは、そのまま、手続きを中心に考えたか、オブジェクトを中心に考えたか、という違いを表している。
手続き的アプローチでは、予め必要な機能をすべてリストアップしなくてはいけない。更に、機能を細分化してモジュールを定義するため、処理の細かい部分まで、最初から規定しまうことになる。そのため、途中で条件6のような機能追加をしようとすると、広範囲に修正が及ぶ結果となる。また、当初の仕様からトップダウン的に作成するため、細かい部分のモジュールも当初の仕様を前提として実装されることが多く、モジュールを他のプロジェクトで利用することも、困難になりやすい。更に、プロジェクトが巨大であると、当初から全体を細かい処理まで規定すること自体が困難な場合も多い。
一方、オブジェクト指向アプローチでは、作成する「モノ」のそれぞれについて、それ自身で必要と考えられる機能をメソッドとして作成すれば良いことが多い。本稿で作成した Board クラスも、盤面として必要な、石を置く、ある場所の状態を調べる、など、基本的な操作をメソッドとして実装しただけだが、結果的に、これを組み合わせて、当初の仕様を満たすものが作成できる。更に、一般的な機能を実装したため、当初の仕様とは違う、人間対人間のオセロゲームを作成する場合にも、この Board クラスをそのまま利用することができる。
オブジェクト指向では、このように、それ自身で必要な機能をメソッドとして持つ「モノ」を作成し、それらを組み合わせてアプリケーションを作成するという、ボトムアップ的アプローチを取れる。この方法では、仕様全体に渡って、細かい部分まで規定する必要が無いため、巨大なプロジェクトにも対応できると推測される。また、各クラスごとの修正も、他に影響を及ぼすことが少なく、修正が容易でもある。
オブジェクト指向のアプローチは、それぞれが独立に機能する、汎用の部品を作成し、クラスライブラリを構築しているという見方もできる。よって、再利用可能なソフトウェア資産が増えやすい、という特徴もある。オブジェクト指向では、あるクラスを継承して、元々の機能はそのまま受け継ぎ、更にデータや機能を追加したサブクラスを作成する、という差分プログラミングが可能だ。そのため、部品の発展や再利用が行いやすく、作成した資産も有効に活用できるケースが多い。
インターフェース
オブジェクト指向プログラミングでは、異なるオブジェクトの間で、仕様(インターフェース)が共通の部分だけを抜き出し、その機能に関してのみ同じ「モノ」であるとして扱うことができる。例えば、「次の一手を決める頭脳」は、SimpleBrain など複数作成したが、すべて次の一手を決めるメソッド itte に関しては同じ「モノ」であると見做し、そのインターフェースを共通のスーパークラス Brain とした。また、条件6の機能追加をした際は、画面やファイルなどの出力先を、出力するメソッド output に関しては同じ「モノ」であると見做し、そのインターフェースを OutputTarget とした。
このように、共通の仕様(インターフェース)を持つものを同じ「モノ」として扱うと、仕様と実装を分離できる、プログラムレベルで仕様に関する制約を設けることができる、などの利点がある。
例えば、次の一手を求める手続きは、いろいろな方法で、複数作成する、という条件4に関して、C言語では、
JUDGE_RETURN simple_itte ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte1 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte2 ( SQUARE_TYPE **board, POSITION *position ); JUDGE_RETURN itte3 ( SQUARE_TYPE **board, POSITION *position );
という、共通の引数を持つ関数を作成した。しかし、共通なのは字面だけであり、実際にはそれぞれの頭脳を表すモジュール間に共通性が無い。例えば、これらの頭脳関数を、それぞれ異なる引数を持つ関数として作成することもできてしまう。その結果、新しい頭脳関数を作成したら、呼び出し側で、新しい関数をその定義に合わせて呼び出すように、修正をしなければいけない。
一方、Java言語では Brain という「頭脳」クラスを作成し、「次の一手を決める頭脳」は、すべて itte というメソッドを持つ、という仕様を決めた。そのため、呼び出し側では、実際にどのような頭脳が作成されるにしても、単に Brain クラスの itte というメソッドを実行すれば良いことになる。また、呼び出し側では Brain クラスの itte というメソッドしか起動しないため、新しく頭脳を作成する場合は、必ずその仕様を満たすものとして作成せざるを得ない。
つまり、呼び出す側に必要なのは仕様だけなのである。Java言語では仕様(Brain)と実装(SimpleBrain等)とが分離されているため、Brain クラスを作成した時点で、呼び出し側のコーディングは完成する。しかし、C言語では、仕様と実装が分離されていないため、新しい頭脳関数を作成する度に、呼び出し側を修正しなければならない。
また、実装する側としても、Java言語の場合は Brain クラスを継承したサブクラスとするしかないため、必ず仕様が満たされる。Brain クラスを継承していなかったり、継承していても itte メソッドを仕様通りに実装していなかったりすれば、コンパイルエラーとなる。しかし、C言語では、それぞれの頭脳関数を、どんな引数と返り値のものとしても作成できてしまう。
このように、Java言語では仕様をプログラムレベルの制約とすることができる。このことは、大規模なプログラムを複数人で分担して実装する上でも有効である。また、機能追加や変更などにも柔軟に対処できる。
例えば、条件6として、ファイルと画面とに先読みの過程を出力できるようにした際、インターフェースを「先読みの過程の出力先」を表す OutputTarget として定義し、実際の出力先は、この OutputTarget を実装したクラスを作成する、という方法を取った。
ファイルには文字列を出力するが、画面にはアニメーションとして先読みの過程を表示する。よって、同じ出力先であっても、その処理内容はまったく異なったものとなる。しかし、どちらの出力も、必要なデータは、石を置いた位置と、置く前の盤面、置いた後の盤面の3つである、として、この仕様を OutputTarget としたのが、本稿でのアプローチだ。
本稿の中では、実際に画面やファイルに出力するためのコーディングまでは行っていない。しかし、OutputTarget というインターフェースを決めているため、「次の一手を決める頭脳」の修正は、この時点で行うことができるようになっている。実際、その修正をしても、コンパイルできて、プログラムは動作する(勿論、出力はされない)。つまり、実際に出力を行う実装をしなくても、仕様を決めただけで、オセロプログラム自身は完成し、動作させることができる。
一方、実際に出力をするクラスを作成する場合は、OutputTarget の示す仕様を満たさない限り、コンパイルできない。よって、オセロプログラムの頭脳を作成する人とは別の人が実装を行っても、仕様から外れることが無い。また、出力部の実装は呼び出す側に影響しないため、頭脳と出力部とは、平行して実装することができる。
更に、本稿では addOutputTarget というメソッドで、複数の「出力先」を登録できるような仕組みを入れておいたため、新しい出力先が増えても問題が無い。例えば、先読みの過程を1つ1つ音声で解説してくれるような機能を追加しようとしても、OutputTarget というインターフェースを実装すれば、他のクラスには影響することなく、機能を追加できる。
まとめ
本稿では、オセロプログラムを作成する、という具体的な課題を例として、手続き型言語であるCと、オブジェクト指向言語であるJava言語との、プログラミングスタイルの違いについて考察し、Java言語を使った、オブジェクト指向アプローチの利点についてまとめた。
Java言語では、カプセル化や例外、インターフェースなどの機能を使うことで、データが抽象化され、プログラムの可読性が向上したり、プログラムレベルで仕様を記述することができる。C言語と違って、これらはプログラムレベルでの制約となるため、可読性の悪いプログラムや、仕様を守らないプログラムを作成しようとしても、コンパイルできない、と点が、Java言語の利点である。
また、オブジェクト指向プログラミングでは、クラスという独立性の高い部品を多数作成し、それらを組み合わせるというボトムアップアプローチが取れる。よって、多人数のプロジェクトであっても、作業の分担を行いやすい。また、作成した部品の修正や再利用も容易である。
Java言語では、プログラムレベルで、仕様や例外、インターフェースや継承などの抽象的な概念を記述できる。よって、クラスやメソッド、変数などに適切な名前を付け、更にjavadocなどと組み合わせれば、プログラムそれ自身を仕様書、設計書として読むことができるものとすることも可能だ。これは、C言語には無い、Java言語の大きな利点である。