まずは以下の図をご覧ください。

オブジェクト指向の継承の話でよくある例ですが、Animalクラスを継承したDogクラス、Catクラスがあります。犬を鳴かせたいとき、つまりDogクラスのMakeSound()を呼び出したい場合は、UserクラスはパターンA, Bどちらのようにアクセスすべきでしょうか?
実際どちらでも実装はできるのですが、オブジェクト指向における模範解答はAとなります。
Bでしか実装できないと思った方、よく分からなかった方にはこの記事は参考になるかもしれません。
あるいは、以下の用語に不安がある方もこの記事が参考になるかもしれません。
- ポリモーフィズム
- アップキャスト
- (C++)virtual
この記事ではサンプルコードとしてC++を用いて継承やポリモーフィズム周りの解説をします。C++を用いるのはを静的型付け言語の方がアップキャストについての説明がしやすいからですが、静的型付け言語であれば記述は似たようなものかと思います。
解説
おそらくほとんどの言語において、A(親クラスへのアクセス)でも B(子クラスへのアクセス)のどちらでも実装はできます。実際の開発においてもどちらの実装もあるのではないかと思います。
ただオブジェクト指向の基本としてはAがいわば模範解答になります。主な目的はポリモーフィズムの実現です。
まずは簡単なのでBの実装(子クラスへのアクセス)から見ていきます。
/* Bの実装例 */
#include <iostream>
// 親クラス(Animal)
class Animal {
public:
void MakeSound() const {
std::cout << "この処理は呼ばれない前提" << std::endl;
}
};
// 子クラス(Dog)
class Dog : public Animal {
public:
void MakeSound() const {
std::cout << "Woof!" << std::endl;
}
};
// 子クラス(Cat)
class Cat : public Animal {
public:
void MakeSound() const {
std::cout << "Meow!" << std::endl;
}
};
// テスト用main関数
int main() {
Dog dog;
Cat cat;
// 子クラス(Dog, Cat)のインスタンス経由でメソッドを呼び出している
dog.MakeSound();
cat.MakeSound();
return 0;
}
出力結果:
Woof!
Meow!
このコードは機能しますが、冷静に見ると継承を使う意味がありません。Animalクラスを作るだけ手間が無駄にかかっているだけで、DogクラスとCatクラスを(継承を使わず)個別に定義すれば良い話です。
実際AnimalクラスのMakeSound()は定義せずにこのコードは動くのですが、このようにアクセス方法が意図と違った場合にエラーを出すような実装はたまに見かけることがあります。
では、それぞれの動物が名前を保持するメンバ”name”を持つとすればどうでしょうか。
#include <iostream>
// 親クラス(Animal)
class Animal {
protected:
std::string name; // 名前を保持するメンバを追加
public:
Animal(const std::string& name) : name(name) {}
void MakeSound() const {
std::cout << "この処理は呼ばれない前提" << std::endl;
}
};
// 子クラス(Dog)
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {} // 親クラスのメンバを使える
void MakeSound() const {
std::cout << "Woof!" << std::endl;
}
};
// 子クラス(Cat)
class Cat : public Animal {
public:
Cat(const std::string& name) : Animal(name) {} // 親クラスのメンバを使える
void MakeSound() const {
std::cout << "Meow!" << std::endl;
}
};
// テスト用main関数
int main() {
Dog dog("Pochi");
Cat cat("Tama");
dog.MakeSound();
cat.MakeSound();
return 0;
}
出力結果:
Pochi:Woof!
Tama:Meow!
こうすると、継承を使う意味が出てきます。子クラスでnameメンバを書かずに済みます。これは犬と猫の共通部分に着目したわけで、実際このように親クラスを共通データや処理の置き場として継承が使われている例は多いと思いますし、継承を使う1つの動機にはなります。ですがこの実装ではポリモーフィズムは実現できていません。
次にAnimalクラス経由で犬と猫を鳴かせるAの実装を見てみます。”name”メンバは説明の本質ではないため省略しました。
/* Aの実装例 */
#include <iostream>
// 親クラス(Animal)
class Animal {
public:
// 実装する必要が無くなる(純粋仮想関数)
virtual void MakeSound() const = 0;
// 仮想デストラクタ
virtual ~Animal() = default;
};
// 子クラス(Dog)
class Dog : public Animal {
public:
void MakeSound() const override {
std::cout << "Woof!" << std::endl;
}
};
// 子クラス(Cat)
class Cat : public Animal {
public:
void MakeSound() const override {
std::cout << "Meow!" << std::endl;
}
};
// テスト用main関数
int main() {
// アップキャストによりAnimal型のインスタンスを生成
Animal* dog = new Dog();
Animal* cat = new Cat();
dog->MakeSound();
cat->MakeSound();
delete dog;
delete cat;
return 0;
}
出力結果:
Woof!
Meow!
C++でこのような動作を実現するには、virtual, overrideを使うことと、呼び出し側でアップキャストを使うことが必要になります。呼び出し側で
Animal* dog = new Dog();
のようにすることで、変数dog自体は親クラスであるAnimal型でありながら、動作は子クラスであるDog型の動作になります。これをアップキャストといいます。
犬と猫が鳴くという結果は同じですが、こうすると何が良いでしょうか。
それはAnimalという共通のインターフェイスを使って鳴くことを実現できるためです。例えば以下のようにしてvectorにインスタンスを入れてループで処理することができるようになります。
// クラス定義は省略
int main() {
// スマートポインタを使うことで各インスタンスの個別deleteは不要になる
std::vector<std::unique_ptr<Animal>> animals;
animals.emplace_back(std::make_unique<Dog>());
animals.emplace_back(std::make_unique<Cat>());
for (const auto& animal : animals) {
animal->MakeSound();
}
return 0;
}
例えば状況に応じて適切なインスタンスを取得できるような仕組みを整えることで、クラスのユーザー側は同じインターフェイスを使いながら、内部の動作が適切に切り替わるようになります。(ファクトリパターン)
ユーザー側はクラス内部の実装を細かく気にする必要が無くなるため、クラスの使い勝手の向上、コードの分離度を高めるなどのメリットがあります。
具体的な適用例
DogクラスやCatクラスだと、いまいち有用性が見えてこないかもしれません。実際の開発で役立ちそうな例として、以下のようなパターンが考えられます。
- 親クラスはファイルの読み込み機能を持ったクラス。子クラスとして本番用データを読み込むクラス、デバッグ用ダミーデータを読み込むクラス。
- 親クラスとして何らかのハードウェア(例えばディスプレイ)へのアクセスクラス。子クラスとして型番の異なるディスプレイのクラス。
クラスの利用側は常に親にアクセスするだけで、本番用のデータなのかダミーデータなのか、ディプレイの実際の型番は何なのかを気にする必要がなくなります。
これをポリモーフィズムを使わず実現する場合、if文でメソッドの動作を切り替えたり、C++であれば#ifなどのプリプロセッサを使って内部処理を切り分けることになるかもしれません。
実際それでできてしまうので、そのように実装されていることもあります。(私の感覚ではそちらの方が多いようにも思えます)
ただそうすると条件分岐が増えてコードの見通しが悪くなります。virtualを使ったポリモーフィズムによる実装も、実行時にクラスの実態の型を見て切り分けているわけなので、あくまでコード上の条件分岐が減るというのが実際のところですが、それでも目的別にコードを分けて書くことでコードの見通しを向上させる効用は十分あります。
コメント