Javaによるプログラミングに関する世界観2A(継承)です。オブジェクト指向も参照のこと。
デザインパターンも参照のこと。
継承がなぜ必要なのかを理解するためには、動物のたとえ話をするのがいいでしょう。
たとえば、黒猫と白猫は、同じ猫です。同じ猫のいくらか属性を変えただけで成り立ちます。
黒猫がキャットフードを食べるように、白猫もキャットフードを食べます。
ですが、犬はどうでしょうか。猫と犬はいくらか違います。たとえば、猫はあまり吠えませんが、犬はよく吠えるでしょう。
このような時に、猫に与える適切な機能と犬に与える適切な機能は違います。
このような時、犬と猫は「クラスが違う」と言います。そして、犬を表すDogクラスと、猫を表すCatクラスを作ります。
ですが、犬と猫には共通点もあります。たとえば、ドッグフードかキャットフードかは違うでしょうが、どちらも似たようなものを食べます。
このような時、CatクラスとDogクラスに、共通の「基底クラス」を作ります。たとえば、Animalクラスとなります。
そして、食べる機能はAnimalクラスで実装し、Catクラス、Dogクラス、あるいはPigクラスやBirdクラスであったにせよ、このAnimalクラスを継承した「派生クラス」とします。
こうすることで、Animalクラスで定義した機能は、すべての派生クラスで共通の機能となります。
ですが、待ってください。Animalクラスで「食べる」という機能を作りましたが、それぞれの派生クラスでは食べるものが違います。たとえば、猫はキャットフードや魚を好みますが、犬はドッグフードやビーフジャーキーを好みます。
ですが、すべてのAnimal派生クラスにおいて、この機能の基本は変えたくありません。どの派生クラスでも、「食べる」という共通の名前のインターフェースから、猫や犬に「食べさせることができる」ようにしたいのです。
このような時に、オーバーライドと仮想関数の機能を使います。すなわち、Animalクラスでは、「食べる」という機能の基本的な枠組みと基本ロジックだけを記述しておいて、派生クラスでその具体的な詳細を決められるようにします。
オブジェクト指向において、パラメータは「属性」、機能は「メソッド」と呼ばれます。ここで、属性には「猫の種類」あるいは「犬の種類」といった、どんな値であってもクラス全体の機能には影響を大きく及ぼさないものを設定し、メソッドには「食べる」という機能をつけます。そして、基底クラスの食べるという機能を派生クラスで「上書き」します。機能の上書きのことを「オーバーライド」と呼びます。そして、上書きされたそれぞれの派生クラスの機能を、統一したインターフェースで実行できるようにします。これを「仮想関数」あるいは「ポリモーフィズム」と言います。
このようにすることで、たとえば、ユーロであっても円であってもドルであっても人民元であっても、どんな貨幣であっても成り立つ「貨幣計算ロジック」を記述し、それぞれの貨幣ごとに別々の処理が必要なら、「一般貨幣クラス」を継承した派生クラスを、ユーロや円やドルや人民元ごとに定義し、そのそれぞれの機能を共通のインターフェース(たとえば「買う」あるいは「売る」など)で扱うことができるのです。
ここで記述した動物のたとえ話は、抽象性と具体性の話です。どのようなクラスを抽象的な機能を持つ基底クラスとし、どのような派生クラスでその機能を実装するかという「実装の話」です。
ですが、継承にはもうひとつ大きな機能があります。これは「機能」の追加の話です。
たとえば、ファイルを扱っている時に、そのファイルがローカルではなくネットワーク上にあるものも扱いたいとしたらどうでしょうか。
このような場合、基本の「ファイルクラス」があったとしたら、そのファイルクラスにネットワーク通信のコードを付け足して、「ネットワークファイルクラス」にすることで機能を追加することができます。
あるいは、クイズのプログラムがあったとして、このプログラムは時間待ち機能を持っていないため、どれだけ待っても問題が回答できるとします。このクラスに、時間待ち機能を追加するような場合も、「クイズクラス」に対して「時間制限ありクイズクラス」にすることで実現できます。
このように、継承には「階層的な抽象関数と実装」だけではなく、「新しい機能の追加をする」という意味もあることを忘れないようにしてください。
そもそも、オブジェクト指向において継承を行う意味とはなんでしょうか。
オブジェクト指向を使わず、グローバル変数を使ってプログラムを書く際に、問題となるのは「名前空間の汚染」だけではなく、「プログラムを再利用した上での機能の追加」です。
たとえば、グローバル変数を用いて、スタックやキューのプログラムを書く場合、そのスタックやキューのプログラムは、データの保持のためにグローバル変数を使います。
この場合、たとえばスタックであれば、プッシュやポップのような関数をインターフェースとして提供するでしょう。
ですが、この既に存在するスタックプログラムに、さらにキューの機能を付け足すためにはどうすればいいでしょうか。
単純に、エンキューやデキューの機能を付け足したいならば、既にあるスタックプログラムのソースコードを書き換えれば、確かにそれは可能です。
ですが、このような場合、既に存在するスタックの処理を壊すことがないように、とても慎重にプログラムを書き換えなければいけません。
また、プログラムは元のプログラムが書き換えられるたびに更新しなければなりません。スタックのプログラムが変わってしまえば、インターフェースがもし維持されたとしても、再度キューのプログラムを慎重に追加しなければなりません。
グローバル変数を用いてその変数専用の関数を書くプログラムは、名前空間を汚染するだけではなく、機能を再利用して追加するとかいったことが難しいのです。
Javaのオブジェクト指向を用いた継承を使うことで、この問題は解決します。publicで定義されたスタッククラスをextendsでスタック&キュークラスとして継承することで、スタックだけを実装したクラスに、キューの機能を付け足すことができます。
もちろん、スタックにキューの機能を追加したとして、効率性の問題をどうするのかという問題はありますが、ここでは効率性は考えません。スタックのデータを継承することで、キューの機能を追加することができます。
グローバル変数を用いたプログラムは、「既に存在するプログラムに対する機能の追加」が難しく、また必ずしも安全であるとは限りません。オブジェクト指向の継承の機能を使うことで、機能を容易かつ安全に追加できます。
クラスを継承した新しいクラスを作ることができる。オブジェクト指向の真骨頂。
キーワード | 説明 |
---|---|
extends | 拡張クラスの宣言をする。 |
継承の例:
public class Cat { public void mew() { System.out.println("にゃーご"); } } public class ExCat extends Cat { @Override public void mew() { System.out.println("ごろごろにゃーん"); } public void printloop(int c) { for (int i = 0; i < c; i++) { mew(); } } }
継承は、既にある財産であるクラスを引き継ぐために行う。
継承について言えることとして、「綺麗な設計をしたコードは、極力元のコードを変更する必要がない」ということが言えます。
たとえば、関数やメソッドを編集する必要がある時、その関数だけではなく別の関数を巻き込んで変更したり、関数の呼び出し元が変更しなければならなくなったり、ということが極力ないべきです。
クラス設計も同じで、極力元のクラスや呼び出し元を変更することなく、必要な部分の最低限の変更で済むようにすべきです。
これはデザインパターンとしても言え、綺麗なデザインパターンを採用することで、極力最低限の変更で済むようにできます。マルチスレッドなどの場合でも、デザインパターンを参考に設計できます。Ruby on Railsでも「くり返しは極力なくする」といった考え方が提唱されています。
後日注記:また、何度も繰り返し同じものを作るよりも、既にある「きちんと動くもの」をベースに作った方が、コードも動きやすく、プログラムの信頼性も高くなる。関数をただ呼び出すだけではなく、「プログラムコードの抜本的な継承」という考え方を、継承によって行うことができる。
継承によって新しい機能をクラスに追加することができる。インスタンス作成時に、オブジェクトのクラスを継承後のものに選ぶことが可能。
継承を使うと、スーパークラスにあるメソッドを新しい名前で上書きあるいは追加することが出来る。
ある意味、クラスの継承はファイルシステムにおけるパスとファイルの関係と良く似ている。
Javaは、ある意味、こうしたエレガントな継承をしたいがために作られた言語だと言っても過言ではない。
スーパークラスを指定せずにクラスを宣言すると、自動的にObjectクラスのサブクラスとして宣言される。
フィールドとメソッドは継承されるが、コンストラクタは継承されないため、注意が必要。
以下は継承の例:
オーバーライドすることで、メソッドを「上書き」することができる。同じメソッド名でオーバーライドしたメソッドを実行することを、「ポリモーフィズム」と呼ぶ。
finalが付いているメソッド(finalメソッド)はオーバーライドできない。
また、スーパークラスにprivateが付いているメソッドやフィールドは、サブクラスからはアクセスできない(継承されない)。
実装を隠蔽したい場合に、フィールドを(publicから)privateにすることがある。
Javaでは、クラスやメソッド定義の上にアノテーションと呼ばれる注釈をつけることで、さまざまな機能を利用できる。これらは「@」を行頭につける。
最近のJavaでは、@Overrideキーワードを使って、オーバーライドするメソッドを明確に記述することができる。@Overrideを書くことで、間違ってメソッド名を書き間違えた場合など、正しくオーバーライドされていない時にエラーを出してくれる。間違いが発覚せずにそのままコンパイルが通って動いてしまうのを水際で防ぐことができる。
アノテーションはもともとJavaでコメントとして注釈をつけるために使われていたもの。自分のアノテーションを自分で作りたいなら以下の記事が参考になる。
クラスを継承した時、派生クラスのコンストラクタから、スーパークラスのコンストラクタを呼び出したい時がある。
また、オーバーライドしたメソッドから、スーパークラスのオーバーライドしていないメソッドを実行したり、同名のメンバ変数を参照したりしたいこともある。
このような時に、super()を使うことで、スーパークラスのコンストラクタ・メソッド・変数にアクセスできる。
特に、クラスを継承した場合、必要な場所にsuper()を記述することを忘れないようにしよう。
また、自分のクラスのコンストラクタを呼び出したい時はthis()を使えばよい。
Javaでは、クラスを継承しても、コンストラクタは継承されません。
クラスを継承した場合、サブクラスのコンストラクタにはスーパークラスのコンストラクタ(ただし引数なし)が自動的に最初に挿入されます。
ですが、引数付きのコンストラクタを呼び出したい時は、super(x, y)などを明示的に記述する必要があります。
(詳細はJava言語プログラミングレッスン 第3版(下) オブジェクト指向を始めようを参照してください。)
2023.01.06編集
ある親クラスを継承した派生クラスがあった時、派生クラスのインスタンスを親クラスの変数に格納することができる。
この時、オーバーライドされたメソッドを実行すると、派生クラスのメソッドが呼び出される。
たとえば、Catを継承したExCatクラスがあった時、
Cat kuro = new ExCat();
とできる。ここで
kuro.mew();
とした時、mew()メソッドがExCatでオーバーライドされていたら、ExCatのmew()メソッドが実行される。これを「ポリモーフィズム」と言い、継承やインターフェースの実装をこのように操作することができる。
このように、Javaでは「インターフェースはそのままで、機能だけを変更する」といったことができる。たとえば、ゲームのキャラクタークラスを作っておいて、勇者と魔法使いを別々の派生クラスにする、といった応用ができる。
(詳しくはJava言語プログラミングレッスン 第3版(下) オブジェクト指向を始めようが参考になります。)
メソッドの内容が定義されておらず、インターフェースとなるAPIの呼び出し規約だけが定義されたメソッドのことを抽象メソッドと呼ぶ。
抽象メソッドを含んだクラスを抽象クラスと呼ぶ。
public abstract class TwoHundred { public abstract void func(int index); public void loop() { for (int i = 0; i < 200; i++) { func(i); } } }
この例では、メソッドを使う側を先に作っておいて、あとでメソッドの側を継承を使って作る、といったやり方が出来るかもしれない。
後日注記:抽象クラスは、ある意味仮想関数やポリモーフィズムのある種の異なる形態のようなもので、親クラスでメソッドを呼び出すコードを書いておいて、派生クラスでそのメソッド本体を実装する。C言語で言えば関数ポインタのようなものに近い。同じJavaの機能であるインターフェース(APIだけを定義して、インターフェースを実装したクラスを共通のAPIを持った型として利用する)と役割的には重なるところがある。
(以上はJava言語プログラミングレッスン 第3版(下) オブジェクト指向を始めようを参考に執筆しました。)
自分の書いたブログ「神々とともに生きる詩人」2021/01/21より。
継承の用語について。
スーパークラスに対するサブクラスのように、継承して派生クラスを作る関係を、is-a関係と呼ぶ。
is-a関係では、親クラスのメソッドを派生クラスが呼び出せる。
これに対して、クラスのメンバ変数として、インスタンスをクラス内に包有する関係を、合成あるいはhas-a関係と呼ぶ。
has-a関係では、メンバ変数のインスタンスを通じて、そのオブジェクトのメソッドや機能を呼び出せる。
(詳しくはJava言語プログラミングレッスン 第3版(下) オブジェクト指向を始めようが参考になります。)
ネットでは、クラスの継承よりも包有あるいはインターフェースを用いた方が良いという意見もあるようです。理由は、「柔軟性が低く変更が難しいから」であるとのこと。
ただし、あえて僕の意見を言えば、同じことができるならどちらでも良いと思います。継承には、仮想関数やポリモーフィズムの仕組みが使えるというメリットもあります。
Javaのクラスライブラリでは、すべてのクラスがObjectクラスを継承して実現されている。そのため、すべての型をObject型として扱うことができる。
後日注記:スーパークラスを指定せずにクラスを宣言すると、自動的にObjectクラスをスーパークラスとして継承したサブクラスとしてそのクラスが宣言される。
また、toString()などのObjectクラスで定義されているメソッドはすべてのクラスで使用できる。toString()は文字列型としてそのオブジェクトを扱う時に呼び出されるメソッド。
toString()を自分でオーバーライドして、オブジェクトを文字列型に変換する際の処理を自分で書くこともできる。
たとえばSystem.out.println()などに自分のクラスのオブジェクトを渡すような場合、そのオブジェクトは自動的に文字列型として扱われるため、自動的にtoString()メソッドが実行されるので、具体的な処理はtoString()メソッドをオーバーライドして記述すればよい。
(詳細はJava言語プログラミングレッスン 第3版(下) オブジェクト指向を始めようを参照してください。)
詳しくは以下の書籍が参考になります。
Javaの継承は、大規模なソフトウェア開発を行う際に便利です。
大規模なソフトウェア開発では、自分がすべてのソフトウェア部品をひとりで開発するわけにはいきません。
誰かが作ったソフトウェア部品をほかの誰かが使い、それぞれが作ったソフトウェア部品を組み合わせることで、大規模なソフトウェア開発は行われます。
このような際に必要なのは、ほかの機械製品を作る場合と同じで、「規格外の仕様を使わず、みんなで共通の方法で操作できるような外部とのインターフェースを使い、カプセル化を行うこと」です。
そして、Javaでは、そのような「ソフトウェア部品同士を組み合わせる」ということを、オブジェクト指向という方法でエレガントに行えます。
オブジェクト指向とは、すなわち「自分の実装の範囲内で、自分のすべき仕事をすべて行う」ということです。
ほかの誰かに迷惑をかけることなく、ほかの誰かの仕事を自分が勝手に書き換えることなく、自らの担当する仕事内容の中で、自らの仕事をすべて行うことができ、その仕事を外部のほかの誰かが詳細に知らなくても、必要最低限の使い方の仕様だけを知っていれば、自分の作った機能を他人が使えるようにすること、これを「カプセル化」や「隠蔽」と呼びます。
ですが、時に、ほかの誰かが作ったプログラムに対して、自分が独自の機能を付け足したくなることがあります。
そのような時に、Javaでは、「継承」という機能を使って、簡単に、他人に迷惑をかけずに機能を自分で追加することができます。
機能を追加するために必要なのは、親クラスとextendsを宣言して新しいクラスを作り、そこで付け足したい機能を追加するだけです。誰か他人の書いたプログラムをわざわざ書き換える必要はありません。また、そのような機能の追加をした際に、親クラスと派生クラスは同じやり方で操作できるため、メインのプログラムや制御を行うプログラムの変更も必要最低限で済みます。最初から継承されることを前提として親クラスを書くこともできますし、派生クラスの集団をすべて同じやり方で操作するようなメインプログラムも書くことができます。
そう、Javaのオブジェクト指向と継承の機能により、大規模なソフトウェア開発が非常にやりやすくなります。かつてFORTRANやCOBOLが使われていたメインフレームなどの大規模なソフトウェア開発で、最近急速にJavaが使われているのは、大規模なソフトウェア開発でJavaのオブジェクト指向がとても便利だからです。オブジェクト指向がなければ、複数のプログラマが作ったソフトウェア部品を組み合わせることは極めて難しくなります。さまざまなところにコードが混在し、機能を追加するだけで何日間もかかるような従来の開発を、Javaは非常に高い生産性で短期間に行えるようにするのです。
2023.05.06
継承は、特定の場合にしか有効でない処理をスマートに記述できます。
たとえば、攻撃を受けて防御する場合に、普通のキャラクターであれば通常のダメージを受けるところで、特定の特性を持ったキャラクターは特殊防御の力でダメージを受けない場合などが挙げられます。
すべてのキャラクターの防御の処理で、特殊防御能力を持っているかどうかをif文で比較することもできますが、さまざまな特殊能力を持ったキャラクターが多くなると記述が膨大になってしまいます。
このような際に、継承、オーバーライド、ポリモーフィズムなどを使い、特定のキャラクタークラスの防御のメソッドを上書きすることで、エレガントに処理を記述できます。
また、カードゲームを作る場合などで、ターンが始まった時やターンが終わる時に何かの処理を追加したい場合があります。このような時も、ターンの最初と最後の処理を「フック」のように上書きすることで、処理を実行できます(実際は単に継承を行うことは難しいので、メソッドチェーン的な何かを作り、処理の最後に特定の処理を追加するようなクラスにします)。
注意すべきこととして、すべてがオブジェクト指向の継承で書けるわけではありません。if文を使わなければ処理が書けないこともあります。たとえば、特殊防御ではなく特殊攻撃のような場合、自分とは異なるクラスのメソッドに対する処理になるため、if文を使わずに書くことは難しい場合があります。また、仮想関数の乱用は、逆にコードの保守性を悪化させることがあります。何事もバランスが肝心です。優れた機能にはメリットだけではなくデメリットもあるということを知っておきましょう。
ゲーム開発も参照のこと。
2023.05.14