オブジェクト指向について考える
オブジェクト指向は、人によって理解が違って、それを上手く共有出来ないと凄い認識違いが起きたりするので、ここで自分の考え方をまとめてます。
ここでいうオブジェクト指向は、クラスベースのオブジェクト指向のことです。
制限と拡張
オブジェクト指向は、それまで出来ていたことに対する制限とそれまで出来なかったことという拡張の2つの側面があります。
この2つは、全くの別の概念として説明されることが多いですが、どちらも「相手に必要な情報しか渡さない」と考える事が出来ます。
アルゴリズムとデータ構造
まず、オブジェクト指向の前に、プログラムについて考えます。
プログラム=アルゴリズム+データ構造
この2つは、ほとんどの場合依存関係にあり、片方が変われば、もう一方も変わります。
例えば、プログラマなら誰でも知っているスタックを実装する場合、後入れ先だしを実現するアルゴリズムは、データ構造を配列にするのか、連結リストにするのかで変わります。
/* 配列を使った実装 */ typedef struct { int size; ELEM items[STACKSIZE]; } STACK; void push(STACK *stack, ELEM item) { if (stack->size == STACKSIZE) { fputs("Error: stack overflow\n", stderr); abort(); } else stack->items[stack->size++] = item; } ...
/* データ構造は連結リスト */ typedef struct stack { ELEM item; struct stack *next; } STACK; void push(STACK **stack, ELEM item) { STACK *node = malloc(sizeof(STACK)); if (node == NULL){ fputs("Error: no space available for node\n", stderr); abort(); } else { node->item = item; node->next = empty(*stack) ? NULL : *stack; *stack = node; } } ...
アルゴリズム
アルゴリズムとは、もともとは数学上の問題の解法を意味していたのですが、コンピュータの登場とともに、プログラムとしての性質を帯びるようになりました。
数学の世界では、アルゴリズムの実行速度はどうでもいいことなんですが、コンピュータの場合そうは行きません。
数学的にきれいでも、終わらなきゃ話にならないので、データの持たせ方に工夫が必要、すなわちデータ構造をきちんと考える必要があります。
データ構造
データをプログラム上で表現するには、変数を使います。
変数には型があり、以下のようなものがあります。
基本データ型
構造を持たない基本データ型として、以下のようなものがある。
これらは、構造を持つデータの要素として使われます。
- 整数型
- 実数型
- 文字列型
- 論理型
- ポインタ型(無い言語もある)
配列
配列とは、同じ型をひとまとめにしたもので、最も基本的なデータ構造です。
配列の要素は、添え字によって識別します。例えば、n個の要素を持ったAという配列は、以下のような構造になっており、各要素には、A[0]、A[1]、...、A[n-1]でアクセスします。
配列は、同じデータ型の集合であるため、データ構造を表現するには弱すぎます。
データ構造を表現するには、異なる型をもったデータを一まとめにしたものが必要になります。
構造体
構造体とは、異なる型を持ったデータを一まとめにしたものです。
配列が同じ型しか取れないのに対し、構造体は別の型のデータを持てます。
また、ある構造体が別の構造体を持つことも可能です。
さて、これで、データ構造の表現は出来るようになりましたが、ここで、困ったことがあります。
最初に述べたように、アルゴリズムとデータ構造は依存関係にあるので、
構造体を直接扱っていると、データ構造が変わったときに、その構造体を使っている箇所を全て書き直さなければならなくなりません。
また、データの整合性を保つために構造体のメンバ同士の依存関係を全部覚えている必要があります。
これでは、さすがに開発効率や堅牢性、保守性が悪すぎるので、抽象データ型という概念を考えます。
抽象データ型(Abstract Data Type)
抽象データとは、データの型と、その型に対する一連の操作を組にしたものです。
データの持つべき性質とデータに施すことが出来る操作を表すもので、データが実際にどのように表現されているか、各操作がどのように実現されているかは規定しません。
以下は、スタックの例です。
typedef struct _stack* Stack; /* stackの構造は公開しない */ Stack stack_create(); /* スタックの作成 */ void stack_push(Stack stack, void *item); /* スタックにデータを積む */ void *stack_pop(Stack stack); /* スタックからデータを降ろす */ void stack_delete(Stack stack); /* スタックの削除 */
このstackが何で実現されているかは公開せず、stackを使いたいときは、必ず決められた関数で行います。
要は、データ構造と操作を、抽象データ型を実装した1つの型として見ます。
こうすることで、データの内容や構造が変化しても、実装の箇所だけ修正すれば良くなり、堅牢かつ保守性/拡張性が高くなります。
また、内部の構造を隠蔽し、必要な手続きだけを公開することをカプセル化と言います。
カプセル化を言語仕様に
抽象データ型を導入することで、カプセル化を行い、堅牢なシステムが作れるようになりました。
・・・とはいえ、これはあくまで気持ちの問題で、やろうと思えば中の構造に直接アクセス出来るし、意識してなくてもうっかりアクセスしてしまったりする可能性が否定出来ません。
また、複数人の開発などでは、意識の共有が出来てないと、やっぱりこの堅牢性は怪しくなってきます。
そこで、このカプセル化を言語仕様でサポートするようになります。
クラス
抽象データ型の定義と実装を見て、気づいた方も多いかと思いますが、カプセル化を言語仕様でサポートするために出来たのが「クラス」です。
それまでの言語で、何が問題だったかと言うと、データと操作が全くの別物で、プログラマが頭の中でセットで考えなければならないことでした。
クラスは、データ+操作でひとまとまりとして扱えるようにし、さらにデータ及び操作に関してアクセス制御が出来るようになりました。
これにより、インタフェース外からのアクセスが言語レベルで出来なくなり、堅牢かつ保守性の高いプログラムが作れるようになったのです。
pubic class Stack { private final int STACK_SIZE = 100; private int size; private ELEM items[STACK_SIZE]; public void push(ELEM item) { ... } }
オブジェクト指向へ
データと操作がひとまとまりになると、何だか現実のもの(オブジェクト)をそのまま表現出来るような気がしてきます。
ここから、いわゆるオブジェクト指向という考え方が生まれました。
オブジェクト指向は、いろいろな経緯を経て、現在では以下の2種類に分類出来ます。
- Smalltalk流
- すべてのデータは「オブジェクト」。なので、純粋なオブジェクト指向といえる
- オブジェクトに「メッセージ」を送ると、対応 した処理が実行される
- C++流
- データは、基本型、配列、構造体、オブジェクトがある
- メッセージというものはなく、関数(メソッド)呼び出しで処理を実行する
インスタンス
オブジェクトと、ただのデータを区別したいためか、新しい用語も沢山生まれました。
その中に、インスタンスという言葉があります。
普通に変数が指してるメモリ上のデータ本体の事なんですが、このデータ本体がオブジェクトになったことで、インスタンスと呼ぶことになりました。
# 変数が指してるデータは構造体。この時は特に呼び方はない Stack *stack; stack = create_stack();
# 変数が指してるデータがオブジェクト。この時は、インスタンスという Stack *stack; stack = new Stack();
以上より、カプセル化やクラスは、概念自体はオブジェクト指向よりも昔から使われており、C++やJavaなどのオブジェクト指向言語(以下OOPL)は、それを言語仕様でサポートしただけと言えます。
しかし、OOPLと非OOPLには、決定的な違いがあります。それは、OOPLでは、複数の型に属することが出来る点です。
ポリモーフィズム
クラスの登場により、データ+操作を1つの型として扱えるようになり、オブジェクトの実装とそれを使うアルゴリズムの結合度を抑えることが出来るようになりました。
実装とアルゴリズムの結合度が低いということは、それぞれの変更や修正の影響が少ないことを意味し、結果、開発効率や保守性が高くなります。
これにより、この抽象データ型を入力として受け取り、公開されているインタフェースを使ってある処理をするようなアルゴリズムが書けるようになります。
そうすると、このアルゴリズムは、データ構造ではなく、インタフェースに依存することになります。
- これまで
- 抽象データ型を使った場合
ここで、実際の構造ではなく、インタフェースという点がポイントです。
インタフェースが同じものなら、実際の構造が違ってもこのアルゴリズムを利用出来そうな気がします。
そこで、ポリモーフィズムです。
ポリモーフィズムは、複数の型に属することが出来る性質です。
アルゴリズムが必要としている型に属してさえいれば、このアルゴリズムが使えるようになります。
- 複数の型に属せる場合
さて、何となくイメージが伝わったでしょうか。
次に、視点をオブジェクトを使う側から、使われるオブジェクトに移して見ましょう。
オブジェクトは、利用者によって公開するインタフェースを変えているように見えます。
言い換えると、利用者がそれぞれ必要としているインタフェースのみを公開し、それ以外のインタフェースは隠蔽しています。
そう、カプセル化です。
こういう記述はほとんど見ないですが、ポリモーフィズムは、利用者毎にカプセル化が出来る機構と見ることも出来るのです。
クラスによるカプセル化で、オブジェクトの実装とそれを使うアルゴリズムの結合度を低くすることが出来ました。
ポリモーフィズムによるカプセル化では、さらにオブジェクトとそれを使うアルゴリズムの結合度を低くすることが出来ます。
オブジェクトとアルゴリズムの結合度が低いと言うことは、それぞれとっかえひっかえが出来ることを意味します。
継承
ポリモーフィズムを実現する方法として、まず継承が提供されています。
継承は、親子関係や階層構造を表現するための機構で、継承したクラスは、親クラスの型にも属することになります。
- Driveクラスを継承したDVD Driveクラスは、Driveクラスとしても扱える
ただ、継承の場合注意が必要で、全然関係のないクラス同士が密切に結合してしまう危険があります
継承するということは、親の実装を一緒に持つ事を意味しており、カプセル化により切り離す事が出来ていた実装を再び結合させてしまうという本末転倒な事が起きてしまいます。
- USB接続が行いたいがために、USBを持っているDriveクラスを継承する図
- USB接続は出来るけども...
そこで、実装を持たないInterfaceという概念を再導入します。
Interface
Interfaceは、抽象データ型をそのまま定義する機構で、実装は全て各クラスに委ねられています。
今までの流れから、このインタフェースという概念が重要なのはお分かりかと思いますが、これを使うと、クラス同士の結合度を低く保ったまま同じ型に属させる事が出来るようになります。
つまり、全く違う物を、ある特定の場面だけ同列に扱うことが出来るのです。
- USBをInterfaceとした場合の図
- USB型に属しているため、USB接続の時は同じように扱うことが出来る