弊社では未経験中途の方を積極的に採用し、Javaを中心とした研修を行っているのですが、ほとんどの方がつまづくのが抽象クラスです。物事を同一視する"汎化"という考え方と一緒によく使われると思うのですが、文法的には理解できても使い方が理解できないことが多いようです。そこで少し例を挙げて汎化と抽象クラスがどのように使われるのかを見て行きたいなと思います。
サンプルアプリ
ある動物学者がいます。この動物学者が動物園から動物を連れてきて鳴き声を聞いてみる、そんなアプリを考えてみます。登場する動物は、猫、犬、カピバラとします。
起動時にコマンドラインで指定した動物の鳴き声を画面に表示するイメージです。
> java Zoologist Cat
にゃー
何も工夫しない設計の場合
まずはクラス設計です。大抵の場合、要件にある"名詞"が最初のクラス候補になったります。というわけで、こんな感じのクラス構成になるでしょうか。
・動物学者(Zoologist)クラス
動物園から動物を連れてきて鳴き声を聞く、を実行するのが責務となります。処理の流れを制御します。処理の開始場所でもあるので、mainメソッドが定義されます。
・動物園(Zoo)クラス
指定された動物を返します。つまり猫、犬、カピバラのオブジェクトを返します。オブジェクトの生成が責務です。
・猫(Cat)、犬(Dog)、カピバラ(Capybara)
それぞれの動物を表すクラスです。鳴き声を返すことを責務としています。
ではさっそく実装してみましょう。この程度のアプリならちゃちゃっと完成させたいところです。
/**
* 猫クラス
* かわいい。もふもふ。
*/
public class Cat {
public String bark() {
return "にゃー";
}
}
/**
* 犬クラス
* サモエドさんです。
*/
public class Dog {
public String bark() {
return "わふ";
}
}
/**
* カピバラクラス
* カピ「パ」ラではないので注意。
* 意外とおっきいです。
*/
public class Capybara {
public String bark() {
return "かぴっ";
}
}
/**
* 動物園クラス
* 指定された動物のオブジェクトを返す。
* 実は赤字経営。
*/
public class Zoo {
public Cat getCat() {
return new Cat();
}
public Dog getDog() {
return new Dog();
}
public Capybara getCapybara() {
return new Capybara();
}
}
/**
* 動物学者クラス
* 実は動物嫌い
*/
public class Zoologist {
public static void main(String[] args) {
new Zoologist().execute(args[0]); // パラメータチェックは省略
}
public void execute(String animalName) {
// 動物園を準備
Zoo zoo = new Zoo();
// 動物園から動物を連れてくる。
// どの動物が指定されたかによって分岐
if("Cat".equals(animalName)) { // 猫が指定されたら
Cat cat = zoo.getCat();
// 鳴き声を表示
System.out.println(cat.bark());
} else if ("Dog".equals(animalName)) { // 犬が指定されたら
Dog dog = zoo.getDog();
System.out.println(dog.bark());
} else { // カピバラが指定されたら
Capybara capybara = zoo.getCapybara();
System.out.println(capybara.bark());
}
}
}
特に難しくはありませんね。難しいのはカピバラのスペル位でしょうか?
さて、ある日突然、動物園にライオンがやってきました。ライオンの鳴き声も聞きたいですよね?どのようにアプリを改修しましょうか?
・Lionクラスを追加する
・ZooクラスにgetLion()メソッドを追加するように修正
・Zoologistクラスのexecuteメソッド内の分岐に新しく
else if("Lion".equals(animalTpey)) {
を追加するように修正
って感じになるでしょうか。ここで実際の開発を意識してみます。
修正されたプログラムは必ずテストしなければいけません。当然、それに関連する別のプログラムもです。修正が他に影響していないか確認しないといけませんよね?このサンプルアプリはとても小さいのでテストしなおすとしても別に気になりません。
では、これが3000クラスあって、それぞれ1万行あるような大きなアプリだったら?大変ですよね?寝ないで作業しても終わる気がしません。ライオンをちょっと追加するだけなのに、そんなに手間がかかるなんて...もうげっそりです...
オブジェクト指向的な設計
これ以上げっそりするとパンツがゆるゆるになってしまうので、頭を使うことにします。
猫、犬、カピバラは「動物」として見なせます。アプリの説明に「動物を連れてきて~」と書かれてる位だし。ポイントは何をもって"同一"とみなすのか、ということです。猫や犬が動物だというのは、既成概念から来る直感的なものですが、プログラミングにおいてはそうはいきません。何が「同一」なのかをきちんと考え定義する必要があります。これが難しいのです。
このアプリにおいての動物とは、鳴き声を返すものとなります。つまり鳴き声を返す「動物」という概念で猫、犬、カピバラは同じものとみなせるわけです。鳴き声を返しさえすれば、どれであっても「動物」です。これが同一視、すなわち「汎化」です。では「動物」クラスを作ってみましょう。
でもちょっと待ってください。猫、犬、カピバラはわかります。連れて来いと言われれば、連れてこれますよね?でも、動物って?動物連れてきてって言われても、「動物」って抽象的な概念だし...
はい!これが抽象クラスです。汎化(同一視)を考えたときに出てきた「まとめる」ためだけのクラスこそ、抽象クラスです。newできないクラスって何だよって思ったでしょうが、こういう時に使います。
では、これらを踏まえてクラスを設計しなおしてみましょう。
ポイントはCatもDogもCapybaraも全てAnimalとして見られている事です。これらを使う側のZoologistクラスからは、実態が何であれAnimalとしてしか見ていません。そして「汎化」のポイントとなる共通点、bark()メソッドはAnimalクラスに抽象メソッドとして定義されています。
ではでは、これを踏まえて実装してみましょう。
/**
* 鳴き声を返すものを同一視するための動物クラス
* 抽象クラスであり、鳴き声を返すという抽象メソッドが定義される
*/
public abstract class Animal {
// 同一視のポイントとなる共通点は抽象メソッドとして定義する。
// 同一視される子クラスは必ずこのメソッドを定義しなければいけないが、
// その実装内容は個々の子クラスによって違う。
abstract public String bark();
}
/**
* 猫クラス
* かわいい。もふもふ。
* ちゅーる大好き。
*/
public class Cat extends Animal{
@Override
public String bark() {
return "にゃー";
}
}
/**
* 犬クラス
* サモエドさんです。
* ドッグラン大好き。
*/
public class Dog extends Animal {
@Override
public String bark() {
return "わふ";
}
}
/**
* カピバラクラス
* カピ「パ」ラではないので注意。
* 意外とおっきいです。
* 温泉大好き。
*/
public class Capybara extends Animal {
@Override
public String bark() {
return "かぴっ";
}
}
/**
* 動物園クラス
* 指定された動物のオブジェクトを返す。
* 園長が息子に代替わりして、赤字が少し減りました。
*/
public class Zoo {
// 指定された動物オブジェクトを返す
// 実際にnewしているのはCat、Dog、Capybaraクラスだが、
// Animalとして汎化(同一視)しているため、戻り値の型はAnimalとなる。
public Animal getAnimal(String animalName) {
if("Cat".equals(animalName)) { // 猫が指定されたら
return new Cat();
} else if ("Dog".equals(animalName)) { // 犬が指定されたら
return new Dog();
} else { // カピバラが指定されたら
return new Capybara();
}
}
}
/**
* 動物学者クラス
* 実は動物嫌い。
* 親は植木屋さん。
*/
public class Zoologist {
public static void main(String[] args) {
new Zoologist().execute(args[0]); // パラメータチェックは省略
}
public void execute(String animalName) {
// 動物園を準備
Zoo zoo = new Zoo();
// 動物を連れてきて、鳴き声を聞く。
// 「汎化する」をプログラム的にいうと、親クラス型の変数で
// 子クラスのオブジェクトを扱える、ということになる。
// つまりAnimal型変数でCatやDogやCapybaraオブジェクトを扱える。
// CatやDogやCapybaraをAnimalとして同じに扱うことで、分岐がなくなった!
Animal animal = zoo.getAnimal(animalName);
System.out.println(animal.bark());
}
}
どうでしょうか?ポイントの一つ目は、Animalクラスです。猫、犬、カピバラを汎化するための抽象的な動物という概念を表す抽象クラスです。ここには「何をもって同一とみなすのか」を決める抽象メソッドが定義されています。端的に言うとこのメソッドを持っているから同一とみなす、という事です。(抽象クラスには抽象メソッド以外のメソッドも定義できます。)
Cat、Dog、Capybaraクラスの内容は変わりませんが、Animalクラスを継承するようになっています。barkメソッドに変更はありません。が、親クラスの抽象メソッドをオーバーライドしたメソッドとして存在意義が変わりました。(処理は変わりません。)
ここでおさらいです。プログラムのコメントにもありますが、「汎化」するとはプログラム的にはどのようなことでしょうか?
Cat → Animal
Dog → Animal
Capybara → Animal
すなわち
Cat cat = new Cat(); → Animal animal = new Cat();
Dog dog = new Dog(); → Animal animal = new Dog();
Capybara capybara = new Capybara(); → Animal animal = new Capybara();
と記述できるようになるのです。どのクラスのオブジェクトであっても親クラスの型の変数で扱えるようになるのが「汎化」です。
話を戻します。大きく変わったのはZooクラスとZoologistクラスです。
Zooクラスは、Cat、Dog、Capybara型のオブジェクトをそれぞれ返すメソッドが定義されていたのが、Animal型を返すメソッド一つだけになりました。これはメソッドの戻り値の型が、Cat、Dog、Capybaraに分かれていたものが、どのオブジェクトであってもAnimal型として扱えるようになったため、メソッドの戻り値の型がAnimal型一つで間に合うようになったからです。
そして最も注目して欲しいのがZoologistクラスです。Animalとして汎化することで、分岐がなくなり、とてもシンプルになりました。たとえLionクラスが追加されたとしても、Zoologistクラスは一切修正することなく対応できます。テストも不要です。素晴らしい!
「拡張性に優れる」「柔軟な」プログラムに必要とされるのはこういった設計です。これこそが目指す形です。
すごいぞ、汎化と抽象クラス
これが「汎化」をうまく使った設計です。とてもシンプルになりましたよね。もちろん実際の業務システムはこんなに単純ではなく、どんなクラスがあって何を汎化すれば良いのかを考えるのは、とてもとても難しいものです。ですが、大切なのは本質を理解することではないでしょうか。今回の例をきちんと理解して、抽象クラスを使いこなし、良いプログラムをバンバン作れるようになれると良いですね!