オブジェクト指向とは何か?ということを真面目に調べていくと、オブジェクト指向には二種類ある、という話に突き当たる。sumim氏のQuora回答などを参照。
- Smalltalkの設計者アラン・ケイによる、メッセージング重視のオブジェクト指向
- C++の設計者ストラウストラップによる、クラス重視のオブジェクト指向
今回はこの後者のオブジェクト指向について、ストラウストラップの論文「「オブジェクト指向プログラミング」とは何か?」What is "Object-Oriented Programming"?(1991)の内容を(適宜行間を補いつつ)まとめてみる。
当然サンプルコードはC++(ほぼ原論文のコードのコピペです)。
ストラウストラップのオブジェクト指向
ストラウストラップの論文は、様々なプログラミングパラダイムを、利用される抽象化によって整理したものと理解できる。抽象化は、何を どんな言語機能で抽象化するのかによって区別される。
複数のプログラミングパラダイム間には以下のような依存関係が存在する。
- 手続き型プログラミング
↑依存
- データ抽象
↑依存
- オブジェクト指向
なお手続き型プログラミングに依存する別のパラダイムとして、データ隠蔽も扱われる。
手続き型プログラミング
手続き型プログラミングは、言語機能としての手続き(関数、サブルーチン)を使った、処理の抽象化を利用するプログラミングである。
例えば平方根を計算する手続きを考えてみよう。
double sqrt(double arg)
{
// 平方根を計算するアルゴリズムを記述する
}
クライアント側。
double root2 = sqrt(2);
sqrt
という手続きを定義することによって、平方根を求める処理が抽象化されている。具体的なアルゴリズムは捨象され、クライアント側では手続きの名前・引数・返り値だけが記述される。
データ隠蔽
データ隠蔽を使ったプログラミングでは、言語機能としてのモジュールを使った、データの抽象化を利用する。
スタックを抽象的に捉えたサンプルを提示しよう。
インターフェース定義。
char pop();
void push(char);
const stack_size = 100;
スタックの実装。
#include "stack.h"
static char v[stack_size];
// "static"キーワードにより、この変数は
// このファイル(このモジュール)内でしか参照できない
static char* p = v;
char pop()
{
// ...
}
void push(char c)
{
// ...
}
クライアント側。
#include "stack.h"
char c = pop(push('c'));
if (c != 'c') error("impossible");
プログラムを複数のモジュールに分割し、スタックの提供側/クライアント側を分けることで、スタックというデータ構造が抽象化されている。スタックが具体的にどのようなデータ構造で実現されているのかは捨象され、クライアント側ではスタックに対する操作(pop
, push
)のみを記述してコーディングできる。
なおデータ隠蔽が可能になるためには、手続き型プログラミングが前提になる。なぜなら、pop
push
のような操作のアルゴリズムには、具体的なデータ構造の記述が混入してしまう。データに対する操作を手続きとして定義し、アルゴリズムを捨象しなければ、具体的なデータ構造を捨象したことにならない。
データ抽象
ストラウストラップによれば、データ隠蔽によるプログラミングには、型の恩恵が受けられない問題がある。
例えば、複数のスタックをデータ隠蔽の手法で取り扱うとしよう。インターフェースは次のようになる。
class stack_id;
stack_id create_stack(int size);
destroy_stack(stack_id);
void push(stack_id, char);
char pop(stack_id);
ただし、クライアント側で次のような誤ったコードを書いてしまうかもしれない。
stack_id s1;
stack_id s2;
s1 = create_stack(200);
// s2の指すスタックの初期化を忘れた!
char c1 = pop(s1, push(s1, 'a'));
if (c1 != 'c') error("impossible");
char c2 = pop(s2, push(s2, 'a'));
if (c2 != 'c') error("impossible");
destroy_stack(s2);
// s1の指すスタックの破棄を忘れた!
double
やchar
のような組み込み型のデータは、自動的に初期化等が行われるため、こういった心配はない。IDEのサポート等も含め、型があることの恩恵はいろいろあって、モジュールで分割するだけではそれが受けられない。
そこで、スタックのような抽象化されたデータも一つの型として表現できるといい。ユーザーが自らデータ型を定義でき、組み込み型同様のサポートを受けられる言語機能を、抽象データ型という。ストラウストラップの論文の用語法では、抽象データ型を使ってデータの抽象化を行うパラダイムは、データ抽象と呼ばれる(データ隠蔽のパラダイムでもデータの抽象化を行っているので、わかりにくい用語法だと思う)。
例えば複素数を抽象データ型として定義するとこう。C++ではクラスを抽象データ型として使える。
class complex {
double re, im;
public:
complex(double r, double i) { re=r; im=i; }
complex(double r) { re=r; im=0; }
friend complex operator+(complex, complex);
friend complex operator-(complex, complex);
friend complex operator-(complex);
friend complex operator*(complex, complex);
friend complex operator/(complex, complex);
// ...
};
手続きの実装はこんな感じ。
complex operator+(complex a1, complex a2)
{
return complex(a1.re+a2.re, a1.im+a2.im);
}
クライアントはこんな感じになる。
complex a = 2.3;
complex b = 1/a;
complex c = a+b*complex(1, 2.3);
c = -(a/b)+2;
モジュールを使った場合と同様に、複素数を表現する具体的なデータ構造は捨象され、複素数に対する操作だけを記述したプログラムになっている。その上、抽象データ型としての複素数のデータについて、組み込み型のデータと同様のサポートを受けられる。
オブジェクト指向
ストラウストラップが言うには、抽象データ型だけでは抽象化が足りない場合がある。問題が起こるのは次のような事例。
様々な形をshape
という抽象データ型で表現し、draw
という手続きによってその形を描画するプログラムを作る。
インターフェースはこうなる。point
などは抽象データ型としてあらかじめ用意されていることにする。
enum kind { circle, triangle, square };
class shape {
point center;
color col;
kind k;
// ...
public:
point where() { return center; }
void move(point to) { center = to; draw(); }
void draw();
void rotate(int);
};
draw
はこんな感じで実装されるだろう。
void shape::draw()
{
switch (k) {
case circle:
// ...
break;
case triangle:
// ...
break;
case square:
// ...
}
}
オブジェクト指向についてなんとなく知っている人なら、この設計のマズさがわかると思う。
- switchの各caseとして、本質的に別種の処理を一つの手続きに入れ込む羽目になる
- 新しい形を扱いたくなった時、caseを一つ一つ追加する、データの実現形態を修正するといったことを、既存のコードと矛盾しないよう気をつけながら行う羽目になる
逆にcircle
, triangle
といった抽象データ型を定義することもできる。この場合はswitchの枝分かれや、形を追加するときの困難といった問題はないが、
-
circle
,triangle
という別々の型になってしまい、コード上で同じ型として扱うことができない- 例:
shape
のリスト、みたいな型をつけて一緒くたに扱えない
- 例:
- 共通の実装を何度も書かなければならない
ストラウストラップによればこの問題の本質は、抽象データ型の共通性を抽象できないことにある。すなわち、circle
やtriangle
といった形を表す抽象データ型のうち、共通する要素を取り出し、個別の特徴は差分として表現することができれば、問題は解決する。
これを可能にする言語機能こそ継承に他ならない。継承を使って、抽象データ型自体を抽象化するプログラミングスタイルが、オブジェクト指向ということになる。
オブジェクト指向のスタイルでshape
のプログラムを書くと次のようになる。
class shape {
point center;
color col;
public:
point where() { return center; }
void move(point to) { center = to; draw(); }
virtual void draw();
virtual void rotate(int);
// ...
};
class circle : public shape {
int radius;
public:
void draw() { /* ... */ };
void rotate(int) {}
};
// その他、triangleやsquareもshapeを継承して定義する
共通部分はshape
というクラスで表現され、それを継承したcircle
やtriangle
の定義で個別的な特徴を表現している。
クライアント側はこんな書き方ができる。
void rotate_all(shape* v, int size, int angle)
{
for (int i = 0; i < size; i++) v[i].rotate(angle);
}
クライアント側では複数のデータ型に共通して可能な操作、および共通する型としてのshape
のみが記述される。shape
の実装においては、複数のデータ型に共通する実装のみが記述される。どちらでも、個々のデータ型の特徴は捨象できている。
結果、switchの枝分かれはなくなり、新しい形が必要になっても既存のコードに手を加える必要はない。
このようにまとめると、「クラス」(抽象データ型)や「継承」がストラウストラップのオブジェクト指向にとっていかに大事かがよくわかると思う。
コメント
オブジェクト指向≠良い
ストラウストラップは、オブジェクト指向プログラミングは抽象データ型の間に共通性がある場合は役に立つが、そうでないときは役に立たない、と書いている。役立つ領域としてインタラクティブな描画システム、役立たない領域として算術、を挙げている。
「抽象データ型の共通性」って何?
ストラウストラップによると、継承は抽象データ型の共通性を表現する。その後のオブジェクト指向の発展を考えると、ここで言う共通性は次の二つに分けるべきだろう。
- インターフェースの共通性
- 実装の共通性
例えばGoFのデザパタ本(1994)では、インターフェースの共通性を表現する継承は問題ないが、実装の共通性を表現する継承は注意して使うべき、とされている。モダンな言語であるGoやRustだと、実装の継承が濫用されないように、クラス継承自体が言語から排除されている。
Haskellだってオブジェクト指向?
いわゆる「オブジェクト指向」でない言語でも、ストラウストラップの「オブジェクト指向プログラミング」を実現することは可能であるように思われる。
例えばHaskellにはクラスも継承もない。だが、モジュールレベルの可視性を制御することによって、抽象データ型を定義することは可能である(こちらを参照)。
さらに、インターフェースの共通性を型クラスによって表現することができる。型クラスにデフォルト実装を定義できるから、実装の共通性を表現することもできる。
というわけで、クラスや継承こそないが、ストラウストラップの言う「オブジェクト指向」に近いものは、Haskellの言語機能を使っても実現できると言えよう。