いまさらですが、職業Javaプログラマーなら理解しておいてほしい「継承」の意味について
正しく意味を理解している方にとっては、まったく常識レベルの話であり、何をいまさらと思われる方々も多いかと思いますが、大規模案件のレガシーコードなど、私が仕事で見かけるJavaのコードを読むと、「このコードを書いたSEやPGの方々は、はたして継承の意味を正しく理解していないのではないか」と思われる設計のコードに出会うことが少なからずあります。現在では改良されましたが(Javaプログラミング能力認定試験の問題がかなり改善されていました - 達人プログラマーを目指して)、以前のJavaプログラム認定試験の問題は、そうした不適切な設計がされている典型的な例となっていたのですが、実際、SI業界ではあのような品質のコードのシステムが今でも現役で多数稼動しているというだけでなく、現在でも新たに生み出されているというのは残念ながら紛れもない事実のようなのです。
確かに新人研修で「哺乳類を継承して犬クラスと猫クラスができる」といったようなオブジェクト指向の説明を聞いただけで、簡単に理解できるものではありませんが、以降手続き型のレガシーコードしか相手にしていないPGやコードを目にすることもなくExcel方眼紙ばかり描いて(書いて)いるSEの方々は、継承の意味などをきちんと理解する前に忘れてしまっているという方も多いかもしれません。私の経験上、この業界ではこうした基本的な知識が理解されていないという現場が、むしろ、典型的なケースなのではないかという疑いすらあると思えてくるのです。(あの認定試験問題の品質をSI業界の代表的なプログラム品質と考えることの是非 - 達人プログラマーを目指して)
オブジェクト指向プログラミングの方法を理解して使いこなせるようになるには、通常はそれなりの努力と期間を要するものですし、きちんと指導してもらえる先輩に巡り合うということも大切ですが、ここでは、「継承」の意味に絞って、最低限知っておいてほしいポイントについて、今更ですがまとめてみたいと思います。あらかじめお断りしておくと、これを読んですぐに理解できるという保証もできないのですが、あと一歩のところで正しい理解に到達できない人にとってヒントになるところもあるかもしれません。そして、もちろん、継承はオブジェクト指向プログラミングのほんの一部分の要素でしかありませんが、その意味を理解することで、次のステップに進みやすくなるとも考えられるのです。(ここではJava言語を例として説明していますが、C#やVisual Basicなど、業務で利用する主流のオブジェクト指向プログラミング言語でも基本的なポイントはほぼ同じです。)
クラスの継承の文字通りの意味
Java言語ではクラスを継承するときにはextendsというキーワードを使ってクラスを宣言します。これは、文字通り「拡張する」という意味ですが、親クラスの定義を拡張して新しいクラスを定義するという働きがあります。以下のコード例をみてください。*1
class Parent { public String fieldA = "field A"; public String methodA() { return "method A"; } } class Child extends Parent { public String fieldB = "field B"; public String methodB() { return "method B"; } }
この場合、以下のようにChildクラスのインスタンスを生成して、フィールドやメソッドにアクセスすると以下のようになります。
public class Inheritance { public static void main(String[] args) { Child child = new Child(); System.out.println(child.fieldA); // field A System.out.println(child.fieldB); // field B System.out.println(child.methodA()); // method A System.out.println(child.methodB()); // method B } }
もともとの「継承する」「拡張する」という意味の通り、ChildクラスはParentクラスのフィールドやメソッドを継承元の親から引き継いで自分自身で定義しているような動作となっています。つまり、Childを以下のように定義した場合と同じ動作ということですね。
class Child { public String fieldA = "field A"; public String fieldB = "field B"; public String methodA() { return "method A"; } public String methodB() { return "method B"; } }
継承を活用することで、このように共通のデータ(フィールド)や処理(メソッド)を親クラスにまとめて定義することができるのです。
子(サブ)クラスで同一の形のメソッドをオーバーライドする
「なんだ、かんたんじゃないか」これで、一人前に継承を理解できたと考える人もいるかもしれません。ですが、残念ながら話はここで終わらないのです。むしろ、実は、ここまでの話はJava言語の継承の働きの中でも本当に20%というか、継承の本当に威力のあるポイントを見逃していることになるのです。話のクライマックスはまだこれからなのです。
話が面白くなるのは、子クラスが親クラスと同じ形(シグネチャ)のメソッドを定義している場合です。以下の定義を見てください。
class Parent { public String fieldA = "field A"; public String methodA() { return "method A"; } } class Child extends Parent { public String fieldB = "field B"; @Override public String methodA() { // 親クラスのメソッドをオーバーライドする。 return "method A in Child"; } public String methodB() { return "method B"; } }
ここでは、Childクラスにおいて親クラスで定義されているmethodA()と同じ形のメソッドを再度定義しなおしています。この形で、親クラスのメソッドを子クラスで再定義することをオーバーライドと呼んでいます。なお、Java5以降のバージョンでは、正しくオーバーライドしていることをコンパイラにチェックさせるために@Overrideというアノテーションを明示的につけることが推奨されています。(うっかりスペルミスをしたり、シグネチャが異なっていたりするとコンパイルエラーとなります。)
次に、これを実行してみましょう。
public class Inheritance { public static void main(String[] args) { Child child = new Child(); System.out.println(child.fieldA); // field A System.out.println(child.fieldB); // field B System.out.println(child.methodA()); // method A in Child System.out.println(child.methodB()); // method B } }
期待通り、child.methodA()の呼び出しはParentクラス中のメソッドでなく、Chlildクラス中のメソッド呼び出しに置き換えられています。これも、オーバーライドの機構を理解してしまえば、難しいところはないと思います。通常は親クラスのメソッドが継承されてくるのに、子クラスでオーバーライドすると、継承元の親クラスの側のメソッドでなく子クラスのメソッドが呼び出されるということです。共通部分を親クラスに定義しておき、必要に応じて、差分があれば子クラスでオーバーライドできるということで、便利ですね。
継承には型の継承というもう一つの重要な側面がある
実は、多くのJavaプログラマーの理解がこの段階で止まってしまっているのではないかと思われるのですが、Java言語の継承には型の継承というもう一つの重要な側面があります。つまり、今までの説明では「継承とは親クラスのフィールドやメソッドを子クラスで再利用するための便利な方法」という意味しかなかったのですが、それに加えて、子クラスのオブジェクトは親クラスの型と代入互換性があるという性質があるのです。Javaの変数は型をつけて宣言する必要があったことを思い出してください。
int a = 3; String b = "hello"; b = a; // コンパイルエラー
つまり、以上の例のように基本的には同じ型の変数にしか代入できないように、コンパイラがチェックしてくれます。一方、クラスに親子関係があると、親クラスの型の変数に子クラスの型のインスタンスを代入できるという重要な規則があります。
Child child = new Child(); Parent parent = child; // コンパイルOK
この規則は、冷静になって考えると自然なものであると納得ができます。今まで説明した継承のメカニズムによって、子クラスは親クラスのすべてのデータや振る舞いを保持しているのですから、場合によっては親クラスの型であると抽象化して考えても問題ないということですね。そして、以下の結果を見てください。
public class Inheritance { public static void main(String[] args) { Parent parent = new Child(); System.out.println(parent.fieldA); // field A System.out.println(parent.methodA()); // method A in Child } }
ここで、非常に大切なポイントはparent.methodA()の呼び出し結果がParentクラスのmethodA()でなく、ChildクラスのmethodA()にバインドされているという事実です。このポイントはレイトバインディングや仮想メソッド呼び出し*2などと説明されることがありますが、とにかく、宣言されている変数の型ではなく、実際に変数に代入されているインスタンスの型によって実行時の振る舞いが決まるということです。
つまり、継承によるメソッドのオーバーライドが、Java言語でポリモーフィズムを実現する手段となっているという事実を理解してください。これが、実はJava言語における継承の威力の中でも最も重要な働きをするポイントとなっているのです。
ポリモーフィズムについては、以前、ドラゴンボールで学ぶオブジェクト指向 改 - 達人プログラマーを目指してで、
共通のメソッド呼び出しで、対象とするオブジェクトの種類に応じてまったく異なるさまざまな処理を実行可能な性質をポリモーフィズム(多態性)と呼びます。
のように説明しています。なお、この説明だけだと、まだ何が嬉しいのかピンと来ないかもしれませんが、親クラスの型の変数に代入できるという性質は変数だけでなく、メソッドのパラメーターにも同様に成り立ちます。だから、
public void someMethod(Parent parent) { // parentを使って何らかの処理 }
のようなメソッドを一度定義しておくと、Parentクラスの任意の子クラスをパラメーターとして渡して処理をさせることができるというわけです。つまり、このポリモーフィズムの性質を使うことで、拡張性や再利用性の高いライブラリーを作成できるのです。実際、たとえば証券のドメインであれば、ある銘柄の注文を表すOrderクラスを入力として受け取るメソッドを定義しておくと、株式や債券など様々な種類のOrderの子クラスを処理するように拡張させるといった設計が可能になるのです。
型の継承によるポリモーフィズム的な側面にのみ着目したのが抽象メソッドとインターフェース
このようにJavaの継承の構文が持つ働きには
という二つの側面があるのです。特に、後者の概念はなかなか難しく、(私の説明のまずさもありますが)話を聞いただけですぐに理解できないかもしれません。しかし、コードを実際に写経*3するなどして、実際に試しながら、じっくりと理解することにしましょう。Javaプログラミングでは、この壁を越えらえるかどうかが非常に重要なポイントで、ここをいったんクリアできれば、デザインパターンやフレームワークなど、オブジェクト指向プログラミングの広大な世界を冒険する準備ができたことになります。
さて、この型の継承とポリモーフィズムということに着目すると、もともとの親クラスのメソッドの実装は不要になる場合があります。その場合は、以下のように親クラスをabstractクラスにして、オーバーライドするメソッドをabstractメソッドとして宣言することができます。
abstract class Parent { public String fieldA = "field A"; public abstract String methodA(); // 抽象メソッド }
この場合も、前回と同様にParent型の変数のChildのインスタンスを代入して実行すると、正しくChildのメソッドが実行されるのです。
public class Inheritance { public static void main(String[] args) { Parent parent = new Child(); System.out.println(parent.fieldA); // field A System.out.println(parent.methodA()); // method A in Child } }
Parentクラスの抽象メソッドは型としてmethodA()が呼び出せるということをコンパイラに知らせている働きがある一方で、実際にメソッドの実装はポリモーフィズムにより、Childクラスのメソッドにバインドされています。
さらに、Javaの場合、継承による型の継承とポリモーフィズムという点を究極に推し進めたものとしてインターフェースが定義できます。上記のParentをインターフェースに置き換えると以下のようになります。
interface Parent { String methodA(); } class Child implements Parent { public String fieldB = "field B"; @Override public String methodA() { return "method A in Child"; } public String methodB() { return "method B"; } } public class Inheritance { public static void main(String[] args) { Parent parent = new Child(); // Childのインスタンスをインターフェース型に代入 System.out.println(parent.methodA()); // method A in Child } }
(補足)Java言語ではフィールドはオーバーライドできない*4
本エントリに対して重要なコメントをいただきましたので、子クラスで親クラスと同一名のフィールドを定義した場合にどうなるかについて補足させていただきます。通常は、親クラスと同一名のフィールドを子クラスで定義すべきではなく、このセクションで書いた内容の理解は後回しでもよいと思いますが、うっかりバグの原因となることがあるので、注意が必要であるということは知っておく方がよいかもしれません。
実際、子クラスを以下のように定義して確認してみます。
class Parent { String fieldA = "field A"; public String methodA() { return "method A"; } } class Child extends Parent { String fieldA = "field A in Child"; //親クラスと同一名称のフィールドを定義 String fieldB = "field B"; @Override public String methodA() { return "method A in Child"; } public String methodB() { return "method B"; } } public class Inheritance { public static void main(String[] args) { Child child = new Child(); Parent parent = child; // フィールドは宣言されている変数の型で決まる System.out.println(parent.fieldA); // field A System.out.println(child.fieldA); // field A in Child // Child型の変数で隠ぺいされているParentのフィールドを参照するにはキャストすることも可 System.out.println(((Parent)child).fieldA); // field A // メソッドはオーバーライドされてポリモーフィックに呼び出される System.out.println(parent.methodA()); // method A in Child System.out.println(child.methodA()); // method A in Child } }
結果は以上のようになりました。メソッドの場合はすでに説明したように子クラスの実装で親クラスの実装がオーバーライドされており、変数の型によらずに、実際に生成されているオブジェクトの型に応じて子クラスのメソッドが呼び出されるのでした。しかし、同一名のフィールドを定義した場合は変数の型によって、親クラスか子クラスのどちらかのフィールドが参照されています。これはどういうことかというと、フィールドは同一名称であっても決してオーバーライドできないということを意味しています。つまり、たまたま名前が同じ変数が親と子に別々に含まれているだけであって(異なる名前のフィールドの継承とおなじく)子クラスのインスタンスにはどちらの変数も含まれているということになります。ただし、同一名のフィールドをこのように宣言してしまうと変数の型によってどちらかのフィールドが参照できなくなってしまうのです。これは、一般にグローバル変数とローカル変数で同じ名前のものがあると、ローカル変数しか見えなくなってしまうといったことと似ています。それぞれの変数の領域は存在しているのですが、単に名前が隠ぺいされて参照できなくなっているということです。
(補足)staticメソッドもオーバーライドできず、ポリモーフィズムが存在しない
つぎに、staticメソッドの継承について調べてみます。以下の例は、以前の例に対して、staticキーワードを各メソッドに追加しています。
class Parent { String fieldA = "field A"; public static String methodA() { return "method A"; } } class Child extends Parent { String fieldA = "field A in Child"; String fieldB = "field B"; public static String methodA() { // 親クラスと同一形のstaticメソッド。普通はやらない。 return "method A in Child"; } public static String methodB() { return "method B"; } } public class Inheritance { public static void main(String[] args) { Child child = new Child(); Parent parent = child; // staticメソッドはクラスごとに存在しており、変数の型で呼び出し対象が静的に決まる。 // つまり、ポリモーフィズムが存在しない。 System.out.println(parent.methodA()); // method A System.out.println(child.methodA()); // method A in Child System.out.println(((Parent)child).methodA()); // method A // 紛らわしいので、普通はインスタンスでなくて、クラスに対して呼び出す。 System.out.println(Parent.methodA()); // method A System.out.println(Child.methodA()); // method A in Child } }
staticでない普通のメソッドと違い、staticメソッドはクラスごとに別々の実装が独立して存在しており、子クラスで同一名のメソッドを定義してもオーバーライドできません。見かけ上インスタンス経由で呼び出した場合、インスタンスの型でなく宣言されている変数の型によって呼び出し先が決まります。前の節で説明したフィールドの場合と似たように解決されています。
このようにstaticメソッドにはポリモーフィズムが存在せず、コンパイル時に呼び出し先が一つに固定されます。staticメソッドの呼び出しにより、コンパイル時に実装が一つに固定されてしまうということです。(一方、普通のメソッドは、ポリモーフィズムにより、オーバーライドしている子クラスの数だけ無数に実装が存在している可能性があります。)staticメソッドが拡張性が低く、また、クラスを分離した単体試験が難しくなるということと関連しています。
(補足)コンストラクタ、初期化ブロック、static初期化ブロックについて
コメント欄にて川久保さんにご指摘いただきましたので、コンストラクタや初期化ブロックに関して簡単に補足させていただきます。初期化ブロックはともかく、コンストラクタに関する理解は重要だと思います。
コンストラクタは継承されない(しかし、子クラスのコンストラクタから呼び出される)
class Parent { public Parent() { System.out.println("Parent no args constructor"); } public Parent(String arg) { System.out.println("Parent one arg constructor arg = " + arg); } } class Child extends Parent { public Child() { // super(); コメントをはずしても同じ System.out.println("Child no args constructor"); } } public class Inheritance { public static void main(String[] args) { Parent parent = new Parent(); //Parent no args constructor Parent parent2 = new Parent("test"); //Parent one arg constractor arg = test Child child = new Child(); // Parent no args constructor, Child no args constructor // コメントをはずすとコンパイルが通らない。 // コンストラクタはメソッドのようには継承されない // Child child2 = new Child("test"); } }
ご指摘のとおり、Java言語の場合にはコンストラクタは通常のメソッドのように自動的に子クラスに継承されません。したがって、以上の例では文字列のパラメーターをとるコンストラクタは親クラスには存在しますが、子クラスには存在しません。*5ただし、子クラスのコンストラクタ中から親クラスのコンストラクタが呼び出されます。(呼び出しが省略された場合は親クラスのパラメーターのないコンストラクタの呼び出しが自動的に行われる仕様。)
インスタンス初期化ブロックはnew実行時に自動的に実行され、オーバーライドできない
存在自体知らない人も多いかもしれませんが、コンストラクタの代わりに初期化処理を行うための、インスタンス初期化ブロックという仕掛けがJDK1.1のころからあります。以下の例を実行してみると、各クラスのコンストラクタの実行前に呼び出されることがわかります。親クラスの初期化ブロックは自動的に呼び出されるため、子クラスでオーバーライドできません。
class Parent { // インスタンス初期化ブロック { System.out.println("Parent instance initilizer block"); } public Parent() { System.out.println("Parent no args constructor"); } } class Child extends Parent { // インスタンス初期化ブロック { System.out.println("Child instance initilizer block"); } public Child() { System.out.println("Child no args constructor"); } } public class Inheritance { public static void main(String[] args) { // Parent instance initilizer block // Parent no args constructor Parent parent = new Parent(); System.out.println(); // Parent instance initilizer block // Parent no args constructor // Child instance initilizer block // Child no args constructor Child child = new Child(); } }
この動作は、各フィールドの初期化子に似ています。
static初期化ブロックはクラスロード時に自動的に一度だけ実行され、オーバーライドできない
同様に、static初期化ブロックの例も示します。こちらはインスタンスの生成時ではなく、クラスのロード時に一回だけ実行される点が違います。いずれにしても、サブクラスでオーバーライドするということはできません。
class Parent { // static初期化ブロック static { System.out.println("Parent static initilizer block"); } public Parent() { System.out.println("Parent no args constructor"); } } class Child extends Parent { // static初期化ブロック static { System.out.println("Child static initilizer block"); } public Child() { System.out.println("Child no args constructor"); } } public class Inheritance { public static void main(String[] args) { // Parent static initilizer block // Parent no args constructor Parent parent = new Parent(); // Parent no args constructor Parent parent2 = new Parent(); System.out.println(); // Child static initilizer block // Parent no args constructor // Child no args constructor Child child = new Child(); // Parent no args constructor // Child no args constructor Child child2 = new Child(); } }
まとめ
通常、会社の研修や入門書ではオブジェクト指向の考え方と一緒に継承の説明を一気にされることが多いと思います。そのため、なかなか本質を理解しにくかったり、重要なポイントを見逃してしまうということもあると思います。ここでは、カプセル化や関連などオブジェクト指向に関する他の説明はいったん無視して、Java言語における継承の意味に絞って説明を試みてみました。
- 継承は親クラスの定義を子クラスで再利用するための手段
- 親クラス(インターフェース)の型に子クラスを代入することでポリモーフィズムを利用して拡張性の高いライブラリーを作成する手段
という二つの側面があるという点を理解することが大切です。特に、後者はなかなか分かりにくいところがあると思いますので、すぐに理解できなくても悲観することはありません。私も最初そうでしたが、ふとある時突然「そういうことだったのか」という瞬間が来るものです。
SI業界では、プログラミングの研修に十分な時間がとられないことも多く、こうしたごく基本的なことを理解しないまま、設計を行ったり、コードを書いたりするということも多いかもしれません。しかし、より高い生産性のプログラマーを目指すために、こうした基本的な知識を正しく理解しておくことは、プロフェッショナルのプログラマーとして最低限の義務なのではないでしょうか。
なお、今回は最短の説明でJava言語の継承の働きを説明するため、オブジェクト指向の説明はあえて省略してしまいましたが、オブジェクト指向については、以下もご参照ください。
ドラゴンボールで学ぶオブジェクト指向 改 - 達人プログラマーを目指して
(追記)
本エントリでは、オブジェクト指向の概念モデルやクラスライブラリーとして意味のないParentやChildといったクラスを説明に使いました。もちろん、入門書などでは普通は図形や実世界の物などを使って継承関係を説明することが多いと思います。今回の説明のアプローチに対して、非難もあるかと思いますが、オブジェクト指向の意味やモデリングということを同時に理解しようとすると、考えることが多すぎて、逆に理解を妨げるということもあると思うのです。また、クラス設計というのは実際にモデル化すべき対象となる問題やプログラム(すなわちコンテキスト)を考えないと、あまり意味がないということもあります。
したがって、まず、初心者の方はオブジェクト指向の考え方はいったん後回しにして、プログラミング言語の機能としての継承の使い方を形から理解するというところから入るというアプローチもあると思います。その後で、JDKやOSSのクラスライブラリーやフレームワークの使い方を理解しながら、デザインパターンやリファクタリングなどを学習し、その過程で自然にオブジェクト指向的なモデリングの考え方ができるようになれば、DDDなどの本を勉強して実際の問題を適切にクラスで設計できるようになるという流れもあると思います。
多くのオブジェクト指向の説明ではそういった段階を飛び越していきなり概念モデルに入ろうとするため、なかなか理解しにくいところがあるのではと思うのです。(自分のドラゴンボールの説明はそういうアプローチですが。)特に、インターフェースとポリモーフィズムの威力を理解するには、まずデザインパターンの中で、StrategyパターンかCommandパターンあたりから勉強することをお勧めします。以下の本はデザインパターンについて、初心者にもわかりやすく書かれていると思います。
Head Firstデザインパターン ―頭とからだで覚えるデザインパターンの基本
- 作者: Eric Freeman,Elisabeth Freeman,Kathy Sierra,Bert Bates,佐藤直生,木下哲也,有限会社福龍興業
- 出版社/メーカー: オライリージャパン
- 発売日: 2005/12/02
- メディア: 大型本
- 購入: 14人 クリック: 362回
- この商品を含むブログ (98件) を見る
ブクマで
Parent parent = new Child(); の変数名はchildを使いたいなあ。
というコメントをいただいています。確かに、ポリモーフィズムによって呼び出し時のメソッドはオーバーライドされていればChildクラスとして振る舞うので気持ちがわからなくないのですが、あくまでも呼び出し元はParentという型として認識しているという点もポイントかと思います。ポリモーフィズムを説明する時に私がよく使うたとえとして「筆記用具で文字を書く」という文があります。筆記用具は抽象親クラスであり、文字を書く人はあくまでも筆記用具を使っていると考えているのですが、実際に使っているのは筆記用具の子クラスのオブジェクトである鉛筆だったりボールペンだったりするということがあります。文字の太さや色は実際に使っているオブジェクトによって決まるのですが、そのオブジェクトを使う人は特に両者の違いを意識しなくても文字を書けます。変数名にparentを使っているのはそういう気持ちからすれば、一般的に自然だと思います。たとえ話も危険な場合がありますが、ポリモーフィズムは本来は日常我々が行っている抽象化をプログラミングで実現する手段としてはすごく自然な考え方だと思います。
*1:ここではアクセス修飾子の問題はあえて考えなくて済むようにすべてpublicとしています。もちろん、通常、フィールドはprivateなどにしてカプセル化すべきです。
*2:コンパイル時に呼び出し先のメソッドが解決できないため、実行時に判断する必要があるということ。
*3:単に話を聞いたり本を読んだりするだけでなく、エディタやIDEを使って実際に例題を書き写し、動作を確認しながら理解すること。
*4:Java言語はC++など多くのオブジェクト指向言語と同様に、フィールドとメソッドを明確に区別しています。つまり、統一アクセスの原理が言語上はサポートされていません。よって、フィールドをpublicにせず、getterとsetterを定義するということが通常は行われます。C#やVBではプロパティというしくみがあり、また、Scalaではそもそもフィールドとメソッドの区別はよりあいまいです。
*5:この辺りはオブジェクト指向言語によって違いもあるところがあるので、注意が必要です。たとえば、Delphiなどではコンストラクタは継承されます。