Edited at

15分でわかる かんたんオブジェクト指向

More than 1 year has passed since last update.

最近オブジェクト指向1について説明する機会が多く、これからもっと多くなりそうなので、文章にまとめることにしました。

オブジェクト指向とは何かという説明はたくさんありますが、 何のためにオブジェクト指向があり、どのように使うのか を、具体例を交えて説明した文章がなかなか見つからなかったので自分で書いてみました。個人差はあると思いますが、目安として 15分程度で読了 2できる分量を想定しました。

本投稿は継続的にアップデートして改良していきたいと考えています。わかりづらい点やおかしな点などあれば、コメントか @koher までツイートいただけるとうれしいです。


対象


  • オブジェクト指向の考え方がよくわからない人

  • プログラミングの初歩(条件分岐、ループ、配列、関数など)はわかっている人

  • 作りたいものを自分で考えてある程度作れる人


一人のデータ

プログラムで会員や顧客、ユーザーなど人のデータを扱うことはよくあります。そのようなケースを例に考えてみましょう。

話を簡単にするために、人は次の三つのデータだけを持っていると考えます。


  • 姓 (familyName)

  • 名 (givenName)

  • 年齢 (age)

プログラムで書くと次のような感じでしょうか3String は文字列、 Integer は整数を表す型、 println は文字列を表示して改行する関数だとします。


(A)

// 変数を定義して値を代入

String givenName = "Albert"; // 名
String familyName = "Einstein"; // 姓
Integer age = 26; // 年齢

println(givenName + " " + familyName); // フルネームを表示


実行結果は次のとおりです。

Albert Einstein


複数人のデータ

(A) では一人だけでしたが、複数人のデータを扱うには配列にします。


(B)

String[] givenNames = ["Albert", "Isaac", "Galileo"];

String[] familyNames = ["Einstein", "Newton", "Galilei"];
Integer[] ages = [26, 43, 46];

// 全員のフルネームを表示
for (Integer i = 0; i < 3; i++) {
println(givenNames[i] + " " + familyNames[i]);
}


実行結果は次の通りです。

Albert Einstein

Isaac Newton
Galileo Galilei


オブジェクトにまとめる(クラスとフィールド)

(B) のコードは少し読みづらいです。 givenName, familyName, age は人に属しているのに、それらがばらばらの配列に格納されているからです。そこで、三つの値を一つの オブジェクト にまとめたいという気持ちになります。


(C)

class Person { // 人を表す型を定義

String givenName;
String familyName;
Integer age;
}

// Person型の変数personを定義し
// givenName, familyName, ageの値を設定
Person person = {
givenName: "Albert",
familyName: "Einstein",
age: 26
};

// フルネームを表示
println(person.givenName + " " + person.familyName);

// 年齢を1増やす
person.age = person.age + 1;


このコードでは、 givenName, familyName , age を持った Person (人)という新しい型を定義し、そのオブジェクトに "Albert", "Einstein", 26 という値を持たせています。

Person を定義するときに class というキーワードを使っていますが、オブジェクト指向では Person のようなオブジェクトの設計図(オブジェクトがどのような値を持っているのか定義するもの)を クラス と呼びます。

また、givenName, familyName, age のことを Person クラスの フィールド と呼びます。フィールドはオブジェクトにひも付いた変数のようなものです。


メソッド

先程から何度もフルネームを表示していますが、よく書く処理は関数にすると便利です。


(D)

// フルネームを返す関数

String getFullName(Person person) {
return person.givenName + " " + person.familyName;
}

// フルネームを表示
println(getFullName(person));


このような関数を定義してみましたが、この関数は必ず Person クラスとセットで使われるはずです。どうせなら、 Person クラスと一緒に定義できて、次のように使えると便利です。


(E)

class Person {

String givenName;
String familyName;
Integer age;

// このオブジェクトのフルネームを返す
String getFullName() {
return givenName + " " + familyName;
}
}

// フルネームを表示
println(person.getFullName());


このように、クラスに所属する関数のことを メソッド と呼びます。

(D)getFullName(E)getFullName の違いに注目して下さい。 (E)getFullName は引数に person をとりませんが、メソッドの外側で定義された givenNamefamilyName にアクセスできています。メソッドはそのクラスのフィールドに自由にアクセスすることができます。

フィールドの値はクラスではなくオブジェクトごとに存在します。 person1person2 という変数に異なる Person オブジェクトが代入されているとすると、 person1.getFullName() を呼んだときには person1 の、 person2.getFullName() を呼んだ時には person2givenNamefamilyName からフルネームが作られます。


コンストラクタ

クラスにはフィールドやメソッドと並ぶ重要な構成要素があります。それが コンストラクタ です。

コンストラクタはオブジェクトが生成されるときに呼ばれるメソッドのようなものです。コンストラクタでフィールドを初期化することで、フィールドが未初期化なオブジェクトが生成されるのを防止できます。


(F)

class Person {

String givenName;
String familyName;
Integer age;

// コンストラクタの実装
Person(String initialGivenName, String initialFamilyName, Integer initialAge) {
// 引数で与えられた値でフィールドを初期化
givenName = initialGivenName;
familyName = initialFamilyName;
age = initialAge;
}

...
}

// コンストラクタでオブジェクトを生成と同時に初期化
// (コンストラクタは「new クラス名(...)」で呼び出す言語が多い)
Person person = new Person("Albert", "Einstein", 26);



カプセル化

これまでは person.givenName というようにフィールドに直接アクセスしてきました。しかし、それはオブジェクト指向において望ましくないこととされています。

それでは、フィールドに格納された値にアクセスするにはどうすればよいのでしょうか。答えは↓です。


(G)

class Person {

String givenName;
String familyName;
Integer age;

... // コンストラクタとgetFullNameメソッドを省略

// givenNameを返すだけのメソッド
String getGivenName() {
return givenName;
}

// givenNameを変更するだけのメソッド
void setGivenName(String newGivenName) {
givenName = newGivenName;
}

// familyNameを返すだけのメソッド
String getFamilyName() {
return familyName;
}

// familyNameを変更するだけのメソッド
void setFamilyName(String newFamilyName) {
familyName = newFamilyName;
}

// ageを返すだけのメソッド
Integer getAge() {
return age;
}

// ageを変更するだけのメソッド
void setAge(Integer newAge) {
age = newAge;
}
}

// 姓と年齢を表示
println(person.getFamilyName() + " (" + person.getAge() + ")");

// 年齢を1増やす
person.setAge(person.getAge() + 1);


クラスの外部からは person.getGivenName() のようにメソッドを介してフィールド値にアクセスするようにします。このように、オブジェクトのフィールドにはアクセスせず、メソッドだけを通してオブジェクトを操作する手法を カプセル化 といいます。

また、 getXxx のようなメソッドのことを gettersetXxx のようなメソッドのことを setter と呼び、getterとsetterをあわせて アクセサアクセサメソッド と呼びます。

どうしてこのようなまどろっこしいことをするのでしょう。アクセサを実装するのも面倒ですし、 person.age = person.age + 1 の方が person.setAge(person.getAge() + 1) よりもずっとシンプルです。


カプセル化の利点 (1)

次のような例を考えてみます。

姓や名を単独で表示することに比べてフルネームを表示する方がずっと多いとします。そうすると、 PersongivenNamefamilyName をばらばらに持ち getFullName で毎回フルネームを生成するよりも、 Person の内部ではフルネームの形で持っておいて、 必要なときに名や姓を生成する方が効率的です。 (G) を書き換えると次のようになります4


(H)

class Person {

// givenNameとfamilyNameをばらばらに持つ代わりにfullNameで持つ
String fullName;
Integer age;

... // コンストラクタを省略

String getFullName() {
// 単にfullNameを返すだけで良い
return fullName;
}

String getGivenName() {
// fullNameのスペースより前の部分を返す
// (splitはStringを分割してString[]を返すStringクラスのメソッドとする)
return fullName.split(" ")[0];
}

void setGivenName(String newGivenName) {
// newGivenNameを使ってfullNameを更新
fullName = newGivenName + " " + getFamilyName();
}

String getFamilyName() {
// fullNameのスペースより後の部分を返す
return fullName.split(" ")[1];
}

void setFamilyName(String newFamilyName) {
// newFamilyNameを使ってfullNameを更新
fullName = getGivenName() + " " + newFamilyName;
}

... // ageのアクセサを省略
}

// 姓と年齢を表示
println(person.getFamilyName() + "(" + person.getAge() + ")");

// 年齢を1増やす
person.setAge(person.getAge() + 1);


(G)(H) の最後(姓と年齢を表示)を見比べてみて下さい。 Person の実装を変えたのに、 Person を使うコードは何も変わっていません。

Person がカプセル化されていればクラスの内外をつなぐのはメソッドだけとなり、内部と外部は分断されます。すると、クラス内部の実装に変更を加えても外部のコードには影響を与えないので、外部は何も変更する必要がないのです。

もし、フィールドに直接アクセスしているとどうなるでしょう。 person.givenName と書いていた箇所をすべて person.fullName.split(" ")\[0\] と書き換えなければなりません。そのような箇所はプログラム中に何百、何千と存在するかもしれません。

このように、カプセル化を実践することでプログラムの修正が容易になり、メンテナンス性が高まります。また、プログラマはクラスの内部実装を気にすることなく、メソッドだけを見て「何ができるのか」さえ知ればそのクラスを使うことができます。


カプセル化の利点 (2)

setAge メソッドは年齢をセットしますが、負の数を渡されると困ります。負の数を渡された場合のエラー処理方法は色々考えられますが、ここでは0に丸めて値を格納するというケースを考えてみましょう。

そのためには setAge メソッドを次のように実装すれば十分です。


(I)

class Person {

...
void setAge(Integer newAge) {
if (newAge < 0) {
age = 0;
} else {
age = newAge;
}
}
...
}

このように、メソッドを介してフィールドにアクセスするなら、単に代入したり参照したりするだけでなく付加的な処理を加えることができます。


継承

ここまで getFullName は西洋式に『名 姓』の順に文字列を結合していました。しかし、日本人のように『姓 名』の順に表示したいこともあります。そこで、フルネームが『名 姓』の順になる WesternPerson (西洋人)クラスと、『姓 名』の順になる EasternPerson (東洋人)クラスを考えてみます。

WesternPersonEasternPerson を別々に実装することもできますが、二つのクラスは getFullName 以外はまったく同じです。同一の処理を複数箇所に書くのはムダですし、片方にバグがあればもう片方も修正しなければならないのでメンテナンス性も下がります。何とか共通部分を一つのコードで書くことはできないでしょうか。

それを実現するのが 継承 です。クラスは別のクラスを継承することで、そのクラスの持つ機能を自分自身に取り込むことができます。

WesternPersonEasternPerson の共通機能を Person として実装し、それを継承して getFullName だけを別々に実装するようにします。


(J)

// getFullName以外をPersonに実装

class Person {
String givenName;
String familyName;
Integer age;

Person(String initialGivenName, String initialFamilyName, Integer initialAge) {
givenName = initialGivenName;
familyName = initialFamilyName;
age = initialAge;
}

String getGivenName() {
return givenName;
}

void setGivenName(String newGivenName) {
givenName = newGivenName;
}

String getFamilyName() {
return familyName;
}

void setFamilyName(String newFamilyName) {
familyName = newFamilyName;
}

Integer getAge() {
return age;
}

void setAge(Integer newAge) {
age = newAge;
}
}

// Personを継承してWesternPersonを作る
// (extendsは継承を表すときに使われることが多いキーワード)
class WesternPerson extends Person {
// WesternPersonのコンストラクタ
WesternPerson(String initialGivenName, String initialFamilyName, Integer initialAge) {
// 継承元のPersonのコンストラクタを呼んで初期化
// (superは継承元のクラスを表すときに使われることが多いキーワード)
super(initialGivenName, initialFamilyName, initialAge);
}

// PersonではなくここでgetFullNameを実装
String getFullName() {
return givenName + " " + familyName; // 名 姓
}
}

// Personを継承してEasternPersonを作る
class EasternPerson extends Person {
// EasternPersonのコンストラクタ
EasternPerson(String initialGivenName, String initialFamilyName, Integer initialAge) {
// 継承元のPersonのコンストラクタを呼んで初期化
super(initialGivenName, initialFamilyName, initialAge);
}

// PersonではなくここでgetFullNameを実装
String getFullName() {
return familyName + " " + givenName; // 姓 名
}
}

WesternPerson westernPerson = new WesternPerson("Albert", "Einstein", 26);
EasternPerson easternPerson = new EasternPerson("信長", "織田", 47);

println(westernPerson.getFullName()); // Albert Einstein
println(easternPerson.getFullName()); // 織田 信長


このように、継承を使えば共通処理を一箇所にまとめて書くことができます。

クラスがあるクラスを継承した場合、継承元のクラスを スーパークラス 、継承先のクラスを サブクラス と呼びます。上の例では、 PersonWesternPersonEasternPerson のスーパークラスであり、 WesternPersonEasternPersonPerson のサブクラスです。


ポリモーフィズム

WesternPersonEasternPerson をまとめて扱いたいときはどうすれば良いでしょうか。例えば、西洋人と東洋人が混ざった会員一覧を表示したいとしましょう。

実は、 WesternPerson オブジェクトと EasternPerson オブジェクトは Person 型の変数に代入することができます。


(K)

// Person型変数に代入可

Person westernPerson = new WesternPerson("Albert", "Einstein", 26);
Person easternPerson = new EasternPerson("信長", "織田", 47);

異なる型の変数にオブジェクトを代入できるのは気持ち悪いですね。こんなことがなぜ許されるのでしょうか。

継承してメソッドを追加することはできますが、スーパークラスが持っているメソッドをなくしてしまうことはできません。そのため、サブクラスはスーパークラスのすべてのメソッドを持っていることが保証されます。つまり WesternPersonEasternPerson も、 Person の持つどのメソッドを呼ばれても、そのメソッドを持っているので Person として振る舞うことができるのです。だから、 Person 型の変数に WesternPerson オブジェクトや EasternPerson オブジェクトが代入できても問題は起こりません。

このことを、


  • WesternPerson is a Person.

  • EasternPerson is a Person.

ということから、is-a関係 と呼びます。

WesternPersonEasternPersonPerson として扱うことができるのなら、まとめて Person 型の配列に入れて会員一覧を表示できそうです。


(L)

// 西洋人と東洋人が混ざった会員の配列

Person[] people = [
new WesternPerson("Albert", "Einstein", 26),
new EasternPerson("信長", "織田", 47),
new WesternPerson("Isaac", "Newton", 43),
new EasternPerson("秀吉", "豊臣", 61),
new WesternPerson("Galileo", "Galilei", 46),
new EasternPerson("家康", "徳川", 73),
];

// peopleの要素を一つずつpersonに代入して実行されるfor-eachループ
for (Person person : people) {
println(person.getFullName()); // PersonはgetFullNameを持たないのでコンパイルエラー!!
}


さて、困りました。上記のコードでは Person 型の persongetFullName を持っていないので「 Person クラスには getFullName というメソッドはありません。」というコンパイルエラーになってしまいます。 person には WesternPerson オブジェクトや EasternPerson オブジェクトが代入されるので実際には getFullName を持っているのですが、 person に代入されるオブジェクトが何かは実行時に決まることであり、コンパイル時にそれを知ることはできません。

そこで、次のように Person クラスにも getFullName メソッドを実装してみるとどうでしょう?そうすればコンパイラは Person クラスにも getFullName メソッドがあると知ることができます。


(M)

class Person {

...
String getFullName() {
return null; // 無意味な値を返す
}
}

このような状態で次のコードを実行すると何が起こるでしょうか。


(N)

Person person = new WesternPerson("Albert", "Einstein", 26);

println(person.getFullName()); // null? "Albert Einstein"?


PersongetFullNamenull を、 WesternPerson では『名 姓』を返します。 Person 型変数 person に代入された WesternPerson オブジェクトの getFullName を呼ぶと、 PersonWesternPerson か、どちらの getFullName が実行されるでしょうか。

実行結果は次のようになります。

Albert Einstein

スーパークラス( Person )とサブクラス( WesternPerson, EasternPerson )で同名のメソッドが定義されている場合、スーパークラスのメソッドを上書きして挙動を変更することができます。このことを、メソッドの オーバーライド と呼びます。また、上の例のように、メソッドがオーバーライドされていると変数の型に関係なくその変数に代入されているオブジェクトのメソッドが呼ばれます。このような挙動のことを ポリモーフィズム と呼びます。

(M) のように Person にも getFullName を定義し、それをオーバーライドすることで (L) のコードはコンパイルエラーを回避できます。また、実行するとポリモーフィズムによって、次のように正しく会員一覧を表示することができます。同じ Person 型の変数 person に対して getFullName を呼んでいますが、 WesternPerson では『名 姓』の順に、 EasternPerson では『姓 名』の順に表示されます。

Albert Einstein

織田 信長
Isaac Newton
豊臣 秀吉
Galileo Galilei
徳川 家康

ポリモーフィズムによって WesternPersonEasternPerson をより抽象的な Person としてまとめて扱えるようになりました( Person では getFullName が『名 姓』だろうと『姓 名』だろうと、とにかくフルネームを返せば良いという意味で抽象的です)。このポリモーフィズムによる抽象化こそが、オブジェクト指向で一番実現したいことです。


抽象クラスと抽象メソッド

(M) では、 getFullNamenull を返すように実装しました。しかし、 Person クラスは直接使わず WesternPersonEasternPerson を使うので、 PersongetFullName の実装は何でもいいはずです。何でもいいのであれば次のように書きたいです。


(O)

class Person {

...
String getFullName(); // 実装自体を書かない
}

このように実装のないメソッドのことを 抽象メソッド と呼びます。また、抽象メソッドを持つクラスのことを 抽象クラス と呼びます5

抽象クラスのオブジェクトを生成しようとするとコンパイルエラーになります。なぜなら、もし Person オブジェクトが生成できてしまったら、 getFullName メソッドが呼ばれたときに困ってしまうからです。


(P)

// Personは抽象クラスなのでPersonオブジェクトを生成することはできない

Person person = new Person("Albert", "Einstein", 26);

抽象クラスはそのオブジェクトを生成することができないので、継承して、抽象メソッドをオーバーライドして使うことが前提のクラスとなります。ただし、 Person オブジェクトは生成できませんが、 Person 型の変数は定義できることに注意して下さい。 次のコードは正しいコードです。


(Q)

// Personは抽象クラスだけどPerson型の変数は定義できる

Person person = new WesternPerson("Albert", "Einstein", 26);

抽象クラスに対して、抽象メソッドを持たない普通のクラスのことを 具象クラス と呼びます。この例では、 Person は抽象クラス、 WestenPersonEasternPerson は具象クラスになります。


ポリモーフィズムがうれしいより現実的な例

PersongetFullName の例は単純すぎてポリーモーフィズムの利点がわかりづらいかもしれません。より現実的にうれしい例として、ZIPファイルを展開して元のバイト列を復元する関数を考えてみましょう。


(R)

// ZIPファイルを展開してバイト列を得る関数

Byte[] unzip(File file) {
...
}

もし、ファイルだけでなくZIP形式のバイト列を入力として展開したいとするとどうでしょう。次の関数も必要です。


(S)

// ZIP形式のバイト列を展開してバイト列を得る関数

Byte[] unzip(Byte[] bytes) {
...
}

圧縮されたファイルを展開するには符号理論に基づいた複雑な処理が必要です。これらの二つの関数をバラバラに実装するのは大変です。

本来必要なのはファイルやバイト列から順次データを取り出し展開することです。そこで、順次データを取得する部分を InputStream というクラスで抽象化してみましょう。


(T)

class InputStream {

// 次の1バイトを取得するメソッド
Byte read();
}

また、これを継承して FileInputStreamByteArrayInputStream も作ります。名前の通り、前者はファイルから1バイトずつデータを読み出し、後者はバイト配列から1バイトずつデータを読み出します。


(U)

class FileInputStream extends InputStream {

// Fileを受け取り、InputStreamとして働く
FileInputStream(File file) {
...
}

Byte read() {
... // ファイルから次の1バイトを取得
}
}

class ByteArrayInputStream extends InputStream {
// Byte配列を受け取り、InputStreamとして働く
ByteArrayInputStream(Byte[] bytes) {
...
}

Byte read() {
... // 配列から次の1バイトを取得
}
}


この InputStream クラスを使えば、 unzip 関数は次のようにまとめられます。


(V)

// InputStreamから得られたZIP形式のデータを展開してバイト列を得る関数

Byte[] unzip(InputStream in) {
...
}

この方法であれば、ファイルとバイト列に対して似たような展開処理を別々に実装する必要はありません。加えて、 HttpInputStreamStandardInputStream を実装すれば、 unzip 関数に一切手を加えることなしに、サーバーにおかれたZIPファイルや標準入力で渡されたZIPファイルなども処理できるようになります。

このように、プログラムの再利用性が飛躍的に高まることがポリモーフィズムのうれしいところです。


まとめ

会員情報などを想定して Person という人の情報(姓、名、年齢)を持つオブジェクトを例に挙げ、 何のためにオブジェクト指向があり、どのように使うのか を説明しました。特に、 カプセル化継承ポリモーフィズム を用いたプログラムの抽象化について説明しました。

本投稿では、オブジェクト指向の本質ではない次の項目を意図的に省略しています。そのうち補足の投稿を書くかもしれません。


  • アクセス制御

  • プロパティ

  • 多重継承

  • インタフェース

  • 静的メソッド





  1. 本投稿では、現在主流であるカプセル化、継承、ポリモーフィズムによるオブジェクト指向を扱います。 



  2. 経験則的に15分程度であれば電車移動などで連続して確保しやすいこと、あまり気合を入れなくても読み始められること、オブジェクト指向を説明するのに必要不可欠な分量から15分としました。本投稿の序文と「対象」、「まとめ」、コードを除いた文字数がおよそ6千数百字、本文中にアルファベットが多いことを考えると日本語換算で6000字程度の分量と思われます。日本人の平均読書スピードは600字/分ほどのようなので、本文に10分、コードに5分で想定しています。 



  3. 本投稿のコードはJavaをベースにしていますが、オブジェクト指向を説明するのに都合が良いように改変した架空の言語で書かれています。 



  4. 実はこのコードは正しくありません。 givenNamefamilyName にスペースを含む文字列が与えられるとおかしなことになります。しかし、そんなケースを考えても本筋とは関係なく話が複雑になるだけなのでここでは無視します。 



  5. 抽象メソッドを一つも持たなくても、 abstract などのキーワードを明示的に付与することで抽象クラスにできる言語もあります。