2-2 オブジェクト指向プログラミング


よいプログラムとは

バグが出ても直しやすく、また機能拡張しやすいプログラムは、次のような性質をもっている。

simple.c を単純に改造してボタンの数をふたつにすると、上のような性質を保つことは難しい。 元のプログラムでは、状態変数 button_pressed を使い、ボタンが押されているか否かを true/false で表していた。 単純な改造でボタンの数をふたつにするには、これを変更して、どのボタンが押されているかを 0, 1, 2 で表し、例えば Redraw() を次のように変更することになるだろう。

似たようなコードの繰り返しになっていることに注意してほしい。 描画の手順は同じだが、座標だけが微妙に異なっている。


構造体の活用

このようなコードの繰り返しは、構造体をうまく活用すると取り除くことができる。 描画の手順は同じで、座標だけが異なることに注目し、次のようなコードにすればよい。 まず、

とする。 最初に構造体 Button を定義し、Redraw() は引数としてこの構造体へのポインタをとるようにする。 Redraw() は座標の計算をする際、定数値ではなく、この構造体の値を使っていることに注意してほしい。

このようにすると、一見、Redraw() の中身が複雑になり、プログラムがかえってわかりにくくなったように感じるが、イベントループの側も次のように直すことで、ボタンの数が増えても類似コードの重複を避けられるようになったことがわかるだろう。

このように、類似コードが重複していても、アルゴリズムが完全に同じであるならば、構造体をうまく活用することで、重複を避けることができる。


関数ポインタの活用

アルゴリズムが完全に一致していなくても、差分がわずかであるならば、構造体を使って類似コードの重複を避けることができる。 例えばマウス・ボタンが離されたときの処理をおこなう MouseClick() は simple.c では次のようであった。

この関数は、どのボタンが押されたかによって実行する処理を変えなければならないので、そのままでは Button 構造体をうまく使った関数に書きかえることができない。

しかし、このような場合も、次のように工夫すれば、類似コードの重複をかなりの程度裂けることができる。 まず構造体 Button の定義を修修正する。

追加した構造体の要素 button_no は、構造体がどのボタンを表しているかを示す。 修正した構造体を使うと、MouseClick() は次のような形に書きかえることができる。

マウスカーソルがボタンの内側にあるかないかの判定は共通化し、その後の処理を if 文を使って切りかえている。

このような方法でも、類似コードの重複を除去できているが、ボタンの数を2つから3つに増やすと、MouseClick() の内容を再び書き換えなければならない。 冒頭に示したよいプログラムの条件を考えると、ボタンの数に依存した処理は main() 内のイベントループ周辺にまとめておくことが求められるので、 ボタンの個数が変わる度に MouseClick() の内容を書き換えるのは望ましくない。

このような問題のない方法として、関数ポインタを使った方法を紹介する。 関数ポインタは、文字どおり関数を指すポインタである。例えば、

のようにして使う。

始めに示した構造体を使ったプログラムでは、重複しているコードを見比べ、 数値が異なる部分を構造体の要素にした。 関数ポインタを使うと、コードの異なる部分も構造体の要素にできる。 コード中の異なる部分を独立した関数とし、その関数へのポインタを構造体 Button の要素とするのがコツである。

構造体 Button を初期化するとき、action に関数 ButtonAction() へのポインタを代入しておけば、マウス・ボタンが離されたときに、ButtonAction() が呼び出される。 異なる関数へのポインタを代入しておけば、異なる関数が呼び出される。


C から C++ へ

オブジェクト指向プログラミングは、上で示した構造体と関数ポインタを使ったプログラミングを洗練させたものであると考えることができる。

しばしば耳にする誤解は、プログラムを C++ で記述すると実行速度が C で記述した場合と比べて遅くなる、というものである。 しかし下で示すように、C++ で記述されたプログラムの実行速度は、C で関数ポインタ等を駆使してオブジェクト指向で記述されたプログラムとほぼ等しい。 同じ批判をするのならば、オブジェクト指向で記述すると、関数ポインタ等が多用されるので、実行速度が遅くなると言うべきである。 もっともこれも、上の例からわかるとおり、オブジェクト指向を使わなくとも、結果的に似たようなプログラムになり、実行速度も変わらないことが多い。

例えばオブジェクト指向言語である C++ を使うと、実行速度はほぼそのままで、上のプログラムをより簡潔な記述に書きなおすことができる。 まず構造体 Button をクラス Button に書きかえる。 C++ では、クラスは拡張された構造体として実現されている。

要素 action は関数ポインタの形ではなく、普通の関数の形で宣言される。 しかしながら、これは記述の上だけであり、実装上には関数ポインタを宣言したと考えて差し支えない。 先頭のキーワード virtual は、action が実装上には関数ポインタであることを示している。 また末尾の = 0 は、関数ポインタ action の値が null (= 0) であることを表す。 ちなみに action のような要素のことを、オブジェクト指向言語ではメソッド (method) と呼ぶ。 C++ ではメソッドと呼ばずに、仮想関数 (virtual function) と呼ぶ。

action が null ではなく、実体のある関数を指すようにするには、クラス Button のサブクラス ButtonOne を定義する。

仮想関数 action() は、実装上は次のような関数と等価になる。

暗黙の第一引数 this が補われる。 なお this->display の this-> は (他の変数と区別がつく限り) 省略可能であるので、省略して記述をより簡潔にすることもできる。

このサブクラス ButtonOne の変数 (オブジェクトと呼ぶ) を宣言すると、

暗黙のうちに action が関数 ButtonOne::action() を指すようになる。 実装上には、次のような構造体を使った C プログラムとほぼ等価になる。

action が別な関数を指すようにするには、クラス Button の別なサブクラスを定義し、

このサブクラスの変数を宣言すればよい。

このようにすると、マウス・ボタンが押されたときに、押されているボタンの種類によって、処理を変えることが出来る。

式 b->action() は、action が指す関数を呼び出す。

式 b->action() は、実装上は次のような C の式と等価である。

仮想関数は暗黙の第一引数として、呼ばれた this ポインタを取るので、b が第一引数として渡される。

ここで vtbl は仮想関数へのポインタを集めた配列であり、action は vtbl が指す配列の 0 番目の要素であると仮定している。 もし仮想関数が 1 つでなく、複数個ある場合には、 どの仮想関数であるかによって、vtbl[0] の代わりに vtbl[1], vtbl[2], ... のようになるかもしれない。

実はクラス Button は実装上、次のような C の構造体と等価になる。

元のクラス定義にあった action がなくなって、代わりに vtbl が追加されている。 vtbl は、クラス定義の中に宣言されている仮想関数へのポインタの配列を指すポインタである。 上の例の場合、仮想関数は action() ひとつだけなので、vtbl が指す配列の要素も action 1 つだけである。

仮想関数を指すポインタを直接、構造体の中に埋めこまず、vtbl を介して参照するようになっている理由は、同じクラスのオブジェクトの間でポインタを共有するためである。 同じクラスのオブジェクトの場合、仮想関数は同じなので、共有することで個々の構造体の大きさを小さくすることができる。

一方、仮想関数を呼び出すたびに、vtbl の参照が余分に必要になるので、実行速度は単純に仮想関数へのポインタを構造体に埋め込む方法にくらべて遅くなる。


非仮想関数

C++ では、仮想関数を宣言する際、virtual を先頭に付加しないこともできる。 例えば、

などと、redraw() を非仮想関数として宣言できる。

この関数も

のように呼ぶことができるが、この関数は実装上、次の C の式と同等である。

非仮想関数は vtbl が指す配列には含まれず、上の式でも vtbl は参照されない。 このため非仮想関数の呼び出しは、C の関数呼出しとほぼ同等のコストで実行される (ただし必ず this ポインタが引数として渡される)。

どの非仮想関数を呼び出すかは、ポインタ変数 b の型を見てコンパイラが静的に決定する。 このため、

とサブクラス ButtonOne で redraw() を定義しても、

を実行したとき、最後の redraw() の呼び出しで呼ばれるのは、クラス Button の redraw() である。 クラス ButtonOne の redraw() ではない。 これは、コンパイラが変数 b の型を見て判断しているためで、変数 b の型が ButtonOne* でなく Button* であるので、Button::redraw() が呼び出される。


クラスの継承

オブジェクト指向言語の機能のうち、重要なもののひとつはクラスの継承機構である。 サブクラスでは、親クラスで宣言された仮想関数に加え、新たな仮想関数を宣言することもできる。

この例のサブクラス B では、f(), g() に加え、m(), n() が宣言されている。 このような場合、もしクラス A の vtbl が指すポインタ配列に f(), g() の順でメソッドが格納されているとすると、 サブクラス B の vtbl が指すポインタ配列には、f(), g(), m(), n() の順でメソッドが格納される。 クラス A で宣言されている仮想関数 k() が配列の i 番目に格納されているのなら、クラス B の仮想関数 k() も i 番目に格納される。

このように配置しないと、クラス B のオブジェクトを指しているポインタを、A* 型のポインタ変数に代入できなくなってしまう。 なぜならコンパイラは、ポインタ変数 a が A* 型であるとすると、

という式を、変数 A の型にもとづいて、

と等価な機械語に変換するからである。 もし、変数 a が指しているオブジェクトがクラス A のとき、g() を指す関数ポインタが配列の 1 番目に格納されており、クラス B のとき 0 番目に格納されているとすると、このプログラムは正しく動かない。

仮想関数を追加するのではなく、変数を追加した場合も同様な規則でコンパイルされる。

は実装上、以下のような構造体と同等である。

このようにコンパイルするので、クラス B のオブジェクト(の先頭)を指すポインタを、A* 型のポインタ変数に代入することができる。 クラス B に対応する構造体の先頭部分は、クラス A に対応する構造体と配置が同じだからである。


課題2

simple.c ではボタンがひとつだけである。 関数ポインタを使うようにプログラムを改造して、かつボタンがふたつになるようにせよ。 関数ポインタを使うように改造する代わりに、C++ で書きなおしてもよい。

追加するボタンが押されたときの処理はプログラムの終了ではなく、別の処理にすること。 例えば、printf() で適当なメッセージを表示する、ボタン表面の文字(ラベル)がボタンを押すたびに "on", "off" に交互に切り替わるようにする、などが考えられる。 あるいは追加するボタンは、押しても何もおこらないことにしてもよい。

プログラムをコンパイルするには

とする。Makefile を使ってもよい。

なおコンパイルには teacup.bitmap も必要である。




目次へ戻る

Copyright (C) 1999-2000 Shigeru Chiba

Email: chiba@is.tsukuba.ac.jp