前回はオブジェクト指向の基本について簡単に説明しました。今回はオブジェクト指向機能の目玉ともいえる「継承」について取り上げます。まず最初に、一般的なオブジェクト指向で使われている継承について簡単に説明します。
「継承 (inheritance : インヘリタンス)」は簡単に言うとクラスに「親子関係」を持たせる機能です。子供のクラスは親クラスの性質を受け継ぐことができます。プログラミング言語の場合、引き継ぐ性質は定義されたインスタンス変数やメソッドになります。プログラムを作る場合、今まで作ったプログラムと同じような機能が必要になることがありますが、継承を使うことでその機能を受け継ぎ、新規の機能や変更される機能だけプログラムする、いわゆる「差分プログラミング」が可能になります。
クラスを継承する場合、その元になるクラスを「スーパークラス」とか「ベースクラス」と呼びます。そして、継承したクラスを「サブクラス」と呼びます。この呼び方は言語によってまちまちで統一されていません。C++の場合は、元になるクラスを基本クラスといい、継承するクラスを派生クラスとか導出クラスといいます。
たとえば、クラス Foo1 を継承してクラス Foo2 を定義しましょう。クラス Foo1 にはメソッド bar() が定義されています。クラス Foo2 にメソッド bar() は定義されていませんが、Foo2 のオブジェクトに対して bar() を呼び出すと、スーパークラス Foo1 のメソッド bar() が実行されるのです。
メソッドの選択は次のように行われます。まず、オブジェクトが属するクラス Foo2 にメソッド bar() が定義されているか調べます。ところが、Foo2 には bar() が定義されていないので、スーパークラスである Foo1 に bar() が定義されているか調べます。ここでメソッド bar() が見つかり、それを実行するのです。このように、メソッドが見つかるまで順番にスーパークラスを調べていきますが、最上位のスーパークラスまで調べてもメソッドが見つからない場合はエラーになります。
継承したクラスのメソッドとは違う働きをさせたい場合、同名のメソッドを定義することで、そのクラスのメソッドを設定することができます。これを「オーバーライド (over ride)」といいます。メソッドを選択する仕組みから見た場合、オーバーライドは必然の動作です。メソッドはサブクラスからスーパークラスに向かって探索されるので、スーパークラスのメソッドよリサブクラスのメソッドが先に選択されるわけです。
A /|\ / | \ B C D / \ / \ E F 図 1 : 単一継承におけるクラスの階層
継承には「単一継承」と「多重継承」の 2 種類があります。単一継承は、ただ一つのクラスからしか機能を継承することができません。したがって、クラスの階層は図 1 のような木構造 [*1] で表すことができます。
継承は何段階に渡って行われてもかまいません。たとえばクラス E の場合、スーパークラスが B で、B のスーパークラスが A に設定されています。サブクラスは複数あってもかまいません。たとえば、A のサブクラスは B, C, D の 3 つがあり、B のサブクラスは E, F の 2 つがあります。図 1 では、クラス A のスーパークラスはありませんが、ほかのクラスはただ一つのスーパークラスを持っています。オブジェクト指向プログラミング言語では Smalltalk, Java, Ruby が単一継承です。
これに対し、多重継承は複数のクラスを継承することができます。このため、クラスの階層は木構造ではなく、図 2 のようなグラフ [*2] で表すことができます。
A / \ / \ B C / \ / \ / \ / \ D E F 図 2 : 多重継承におけるクラスの階層
クラス E に注目してください。スーパークラスには B と C の 2 つがあります。多重継承では、単一継承と同じくサブクラスを複数持つことができ、なおかつ、スーパークラスも複数持つことができるのです。C++は多重継承をサポートしています。スクリプト言語では Perl や Python が多重継承です。
実をいうと、M.Hiroi は多重継承に対してあまりいいイメージを持っていません。私見ですが、多重継承はメリットよりもプログラムを複雑にするデメリットの方が大きいのではないか、と思っています。とくに、図 2 のクラス A, B, C, E のような菱形の関係をC++でプログラムする場合、とても複雑な問題を引き起こすことが知られています。
Java の場合、基本的には単一継承ですが「インターフェース (interface)」という機能により、メソッドの「仕様」だけは多重継承できるようになっています。インターフェースは次回で詳しく説明します。まずは最初に、一般的な継承の仕組みについて説明します。
一般的なオブジェクト指向プログラミング言語の場合、継承によって引き継がれる性質は定義されたインスタンス変数やメソッドになります。図 3 を見てください。
クラス Foo にはインスタンス変数 a, b とリーダーメソッド getA(), getB() が定義されています。次に、クラス Bar を定義します。Bar は Foo を継承し、Bar 固有のインスタンス変数 c とリーダーメソッド getC() を定義します。Foo と Bar のインスタンスを生成すると、図 3 に示したように、Bar のインスタンスにはクラス Foo で定義された変数 a, b も含まれます。このように、Foo のインスタンス変数が Bar に継承されます。
Foo のインスタンスを生成すると、もちろん変数 a, b は含まれていますが、Bar のインスタンスとメモリを共有することはありません。クラスはオブジェクトの設計図です。設計に共通な部分があったとしても、それから生み出されるインスタンスは別々の実体で、インスタンス変数を共有することはないのです。
クラス Bar にはメソッド getC() しか定義されていませんが、クラス Foo を継承することにより、メソッド getA() と getB() を呼び出すことができます。Bar のインスタンスに対して getA() を呼び出すと、クラス Bar には getA() が定義されていないのでスーパークラス Foo を調べ、そこで定義されている getA() が呼び出されます。もちろん、取り出される値は Bar のインスタンスにある変数 a の値です。このように、Foo のメソッドが Bar に継承されます。
class ┌─ Foo ─┐ ┌─ instance ─┐ ├─────┤ ├───────┤ │ 変数 a │────→│ 変数 a │ ├─────┤ ├───────┤ │ 変数 b │ │ 変数 b │ └─────┘ └───────┘ method : getA(), getB() │ 継承 ↓ ┌─ Bar ─┐ ┌─ instance ─┐ ├─────┤────→├───────┤ │ 変数 c │ │ 変数 a │ └─────┘ ├───────┤ method : getC() │ 変数 b │ ├───────┤ │ 変数 c │ └───────┘ 図 3 : 一般的な継承
それでは、具体的に Java の継承を説明しましょう。スーパークラスは class 文で指定します。クラス名の後ろに extends を付けて、その後ろにスーバークラス名を指定します。Java は単一継承が基本なので、指定できるスーパークラスは一つだけです。継承に必要な設定はこれだけです。
簡単な例として、図 3 のクラスを実際にプログラムしてみましょう。次のリストを見てください。
リスト : クラス Foo の定義 (sample60.java) class Foo { int a, b; // コンストラクタ Foo() { System.out.println("Called Foo()"); } Foo(int x, int y) { a = x; b = y; } // アクセスメソッド int getA() { return a; } int getB() { return b; } void setA(int n) { a = n; } void setB(int n) { b = n; } } class Bar extends Foo { int c; // コンストラクタ Bar() { System.out.println("Called Bar()"); } Bar(int x, int y, int z) { super(x, y); c = z; } Bar(int z) { c = z; } // アクセスメソッド int getC() { return c; } void setC(int n) { c = n; } } public class sample60 { public static void main(String[] args) { Foo a = new Foo(1, 2); Bar b = new Bar(10, 20, 30); Bar c = new Bar(); Bar d = new Bar(100); System.out.println(a.getA()); System.out.println(b.getA()); System.out.println(b.getC()); // System.out.println(a.getC()); コンパイルエラー } }
クラス Foo のフィールド変数 a, b はコンストラクタで初期化します。メソッド getA() と getB() の定義は簡単です。与えられたインスタンスから値を取り出すだけです。次にクラス Bar を定義します。extends の後ろにスーパークラス Foo を指定します。これで Foo のフィールド変数とメソッドを継承することができます。
Bar のコンストラクタではフィールド変数 a, b, c を初期化します。変数 a, b の初期化は Foo のコンストラクタで行っていますね。このメソッドを呼び出すことができれば、わざわざ Bar のコンストラクタで a, b の初期化を行う必要はありません。いいかえれば、スーパークラスのメソッドと同じプログラムを書かなくてもよいわけです。
Java の場合、super を使ってスーパークラスのメソッドを呼び出すことができます。Bar のコンストラクタで super(x, y) を実行すると、スーパークラス Foo のコンストラクタ Foo(int x, int y) { ... } が呼び出されます。また、super() を実行すると引数なしのコンストラクタ Foo() { ... } が呼び出されます。
また、インスタンスメソッドでスーパークラスのメソッドを呼び出す場合は次のように行います。
super.method(args, ...)
コンストラクタの場合、super だけでスーパークラスのコンストラクタを呼び出すことができますが、インスタンスメソッドの場合は呼び出すメソッド名 method が必要になります。
このような処理を「メソッド結合 (method combination)」といいます。この例は簡単すぎるので、あまりメリットを感じないかもしれません。ところが、スーパークラスのメソッドが複雑な処理を行っていて、サブクラスのメソッドでも同様の処理が必要な場合には、その力を十分に発揮してくれるでしょう。
また、自クラスの中で多重定義された同名のメソッドもしくはコンストラクタを呼び出す場合は this を使います。たとえば、引数を持つコンストラクタの中で this() を実行すると、引数なしのコンストラクタを呼び出すことができます。なお、コンストラクタで super または this の呼び出しがない場合、最初に super() の呼び出しが自動的に行われます。
それでは実行してみましょう。
$ javac sample60.java $ java sample60 Called Foo() Called Bar() Called Foo() 1 10 30
コンストラクタ Bar() は自動的に super() が呼び出されるので、Called Foo() が表示されてから Called Bar() が表示されます。Bar(100) も super() が自動的に呼び出されるので、Called Foo() が表示されてから、変数 c に 100 が代入されます。
スーパークラスのメソッドは Bar のインスタンスからでも呼び出すことができます。b.getA() は 10 になりますし、自クラスのメソッド b.getC() は 30 になります。なお、Foo のインスタンス a からサブクラスのメソッド getC() は呼び出すことができません。コンパイルエラーになります。
継承はクラスに新しい機能を追加するだけではなく、メソッドをオーバーライドすることで機能を変更することができます。なお、オーバーライドと多重定義 (オーバーロード) はまったく異なる機能です。混同しないように注意してください。
それでは、簡単な例を示しましょう。フィールド変数の合計値を求めるメソッド sum() を定義します。次のリストを見てください。
リスト : メソッドのオーバーライド (sample61.java) class Foo { int a, b; // コンストラクタ Foo() { System.out.println("Called Foo()"); } Foo(int x, int y) { a = x; b = y; } // アクセスメソッド int getA() { return a; } int getB() { return b; } void setA(int n) { a = n; } void setB(int n) { b = n; } // 合計値を求める int sum() { return a + b; } } class Bar extends Foo { int c; // コンストラクタ Bar() { System.out.println("Called Bar()"); } Bar(int x, int y, int z) { super(x, y); c = z; } Bar(int z) { c = z; } // アクセスメソッド int getC() { return c; } void setC(int n) { c = n; } // 合計値を求める @Override int sum() { return super.sum() + c; } } public class sample61 { public static void main(String[] args) { Foo a = new Foo(1, 2); Bar b = new Bar(10, 20, 30); System.out.println(a.sum()); System.out.println(b.sum()); } }
クラス Foo でメソッド sum() を定義します。そして、クラス Bar でメソッド sum() をオーバーライドします。オーバーライドはスーパークラスにあるメソッドと同じ名前のメソッドを定義するだけです。オーバーライドするときは、メソッド名を間違わないように注意してください。このとき、@Override という「アノテーション (annotation)」を使うと、タイプミスを防ぐことができます。アノテーションは JDK 5 から導入された機能で、@Override のほかにもいろいろな機能が用意されています。
たとえば、クラス Bar でメソッド sum() を sam とタイプミスすると、次のようにコンパイルエラーとなります。
$ javac sample61.java sample61.java:37: エラー: メソッドはスーパータイプのメソッドをオーバーライドまたは実装しません @Override ^ エラー1個
@Override を使わないと、コンパイルは正常に終了します。すると、b.sum() の実行は、オーバーライドしたメソッドを呼び出すのではなく、スーパークラスのメソッド sum を呼び出すことになるので、正しい値を求めることはできません。
Bar のメソッド sum() では、super.sum() でスーパークラスのメソッド sum を呼び出して、その結果に変数 c の値を足し算します。実行結果は次のようになります。
$ javac sample61.java $ java sample61 3 60
正常に動作していますね。
クラス変数とクラスメソッドはインスタンス変数とインスタンスメソッドと同様に継承されます。簡単な例を示しましょう。
jshell> class Foo { ...> static int x = 1; ...> static int getX() { return x; } ...> static void setX(int n) { x = n; } ...> } | 次を作成しました: クラス Foo jshell> class Bar extends Foo {} | 次を作成しました: クラス Bar jshell> var a = new Bar() a ==> Bar@25f38edc jshell> a.getX() $4 ==> 1 jshell> Foo.getX() $5 ==> 1 jshell> a.setX(10) jshell> a.getX() $7 ==> 10 jshell> Foo.getX() $8 ==> 10 jshell> Bar.getX() $9 ==> 10
クラス Foo にはクラス変数 x とクラスメソッド getX(), setX() が定義されています。クラス Bar はクラス Foo を継承しているので、アクセス権があればクラス変数 x にアクセスすることができます。また、クラスメソッド getX(), setX() は、Bar からでも Bar のインスタンスからでも呼び出すことができます。
Java の場合、クラス名はデータ型を表す識別子として利用することができます。継承はフィールド変数やメソッドに作用するだけではなく、データ型にも作用します。サブクラスに属するインスタンスはデータ型も継承されるため、スーパークラスのデータ型として取り扱うことができるのです。インスタンスが属するクラスを判定する演算子 instanceof を使って調べてみましょう。次の例を見てください。
jshell> class Foo {} | 次を作成しました: クラス Foo jshell> class Bar extends Foo {} | 次を作成しました: クラス Bar jshell> class Baz extends Bar {} | 次を作成しました: クラス Baz jshell> var a = new Foo() a ==> Foo@3eb07fd3 jshell> var b = new Bar() b ==> Bar@69d0a921 jshell> var c = new Baz() c ==> Baz@799f7e29 jshell> a instanceof Foo $7 ==> true jshell> a instanceof Bar $8 ==> false jshell> b instanceof Bar $9 ==> true jshell> b instanceof Foo $10 ==> true jshell> c instanceof Baz $11 ==> true jshell> c instanceof Bar $12 ==> true jshell> c instanceof Foo $13 ==> true
クラス Bar はクラス Foo を継承しています。Foo のインスタンス a は instanceof でチェックすると、Foo では true になり、Bar では false になります。ところが、Bar のインスタンス b は、Bar で true になるのは当然ですが、Foo のサブクラスなのでデータ型が継承され、Fooでも true になります。そして、Bar を継承したクラス Baz の作って、そのインスタンスを instanceof でチェックすると、Foo, Bar, Baz のどれでも true になります。
このように、クラスを単一継承してサブクラスを作ると、サブクラスはスーパークラスの部分集合として考えることができます。図 4 を見てください。サブクラス Baz は Bar や Foo に含まれているので、そのインスタンスに Bar や Foo のメソッドを適用することができるわけです。
┌──────────┐ │ Foo │ │ ┌──────┐ │ │ │ Bar │ │ │ │ ┌──┐ │ │ │ │ │Baz │ │ │ │ │ │ │ │ │ │ │ └──┘ │ │ │ │ │ │ │ └──────┘ │ │ │ └──────────┘ 図 4 : クラスとサブクラスの関係
クラスを定義するとき、class の前に final 修飾子を付けると、そのクラスを継承したサブクラスを作ることはできません。つまり、継承を禁止することができます。また、メソッドを定義するとき final 修飾子を付けると、サブクラスでオーバーライドすることができなくなります。
クラスでメソッドを定義するとき、キーワード abstract を付けるとメソッドの型だけを宣言することができます。これを「抽象メソッド (abstract method)」といいます。そして、抽象メソッドを持つクラスを「抽象クラス (abstract class)」といい、new でインスタンスを生成することはできません。抽象クラスと抽象メソッドは次のように定義します。
abstract class クラス名 { ... abstract データ型 メソッド名(データ型 引数, ...); ... }
抽象メソッドを持つクラスは必ず abstract を宣言してください。なお、抽象メソッドがないクラスでも abstract を宣言することができます。もちろん、そのクラスは抽象クラスになります。
抽象クラスは継承されることを前提としたクラスで、抽象メソッドはサブクラスにおいて具体的に定義されます。抽象クラスでは、サブクラス共通のメソッドを定義します。このとき、抽象メソッドを呼び出してもかまいません。サブクラスのインスタンスが生成されるとき、そのサブクラスでは抽象メソッドが具体化されているはずなので、実際にはサブクラスのメソッドが呼び出されることになります。
それでは簡単な例題として、図形の面積を求めるプログラムを作ってみましょう。次のリストを見てください。
リスト : 図形のクラス (sample64.java) abstract class Figure { abstract String kindOf(); abstract double area(); void print() { System.out.print(kindOf() + ": area = " + area()); System.out.println(); } } // 三角形 class Triangle extends Figure { double altitude, base; Triangle(double a, double b) { altitude = a; base = b; } @Override String kindOf() { return "Triangle"; } @Override double area() { return altitude * base / 2.0; } } // 四角形 class Rectangle extends Figure { double width, height; Rectangle(double w, double h) { width = w; height = h; } @Override String kindOf() { return "Rectangle"; } @Override double area() { return width * height; } } // 円 class Circle extends Figure { double radius; Circle(double r) { radius = r; } @Override String kindOf() { return "Circle"; } @Override double area() { return radius * radius * Math.PI; } } public class sample64 { public static void main(String[] args) { Triangle a = new Triangle(2.0, 2.0); Rectangle b = new Rectangle(2.0, 2.0); Circle c = new Circle(2.0); a.print(); b.print(); c.print(); } }
クラス Figure は抽象クラスです。メソッド kindOf() と area() が抽象メソッドで、kindOf() は図形の種類を文字列で返し、area() は図形の面積を計算して返します。kindOf() と area() はサブクラスで定義します。
print() は図形の種別と面積を表示するメソッドです。ここで、抽象メソッド kindOf() と area() を呼び出しています。実際には、print() を呼び出したインスタンス (this) が属するクラスのメソッドが呼び出されます。つまり、ポリモーフィズムにより適切なメソッドが呼び出されるわけです。
クラス Triangle, Rectangle, Circle は Figure を継承します。サブクラス固有のメソッド (コンストラクタ) も定義されていますが、どのクラスも抽象メソッド kindOf() と area() を具体化しています。なお、スーパークラスの抽象メソッドをすべて具体化しないと、そのサブクラスも抽象クラスになるため、コンパイルでエラーになります。ご注意ください。
それでは実行例を示します。
$ javac sample64.java $ java sample64 Triangle: area = 2.0 Rectangle: area = 4.0 Circle: area = 12.566370614359172
正常に動作していますね。
Java では、サブクラスのインスタンスをスーパークラスのデータ型に変換することができます。型変換のことを「キャスト」といいます。特に、サブクラスをスーパークラスに変換することを「アップキャスト」といい、スーパークラスのインスタンスをサブクラスのデータ型に変換することを「ダウンキャスト」といいます。
Java の場合、サブクラスのインスタンスをスーパークラスの変数 (メソッドの引数) や配列などに代入するとき、自動的にアップキャストが行われます。コンパイルエラーは発生しません。これに対し、スーパークラスのインスタンスをサブクラスの変数などに代入するとき、プログラマが明示的にキャストしないとコンパイルエラーになります。
簡単な例を示しましょう。
リスト : キャスト (sample65.java) class Foo { void display() { System.out.println("Foo"); } } class Bar1 extends Foo { @Override void display() { System.out.println("Bar1"); } } class Bar2 extends Foo { @Override void display() { System.out.println("Bar2"); } } class Baz { void display() { System.out.println("Baz"); } } public class sample65 { public static void main(String[] args) { Foo a = new Foo(); Bar1 b = new Bar1(); Bar2 c = new Bar2(); Baz d = new Baz(); Foo a1 = b; // アップキャスト Foo a2 = c; a.display(); a1.display(); // ポリモーフィズム a2.display(); Bar1 b1 = (Bar1)a1; // ダウンキャスト b1.display(); // Bar1 b1 = a1; キャストしないとコンパイルエラー // Foo a3 = (Foo)d; キャストしてもコンパイルエラー // Bar2 b2 = (Bar2)a1; 間違ったダウンキャスト } }
$ javac sample65.java $ java sample65 Foo Bar1 Bar2 Bar1
各クラスのインスタンスを生成して変数 a, b, c, d にセットします。Bar1, Bar2 は Foo のサブクラスなので、Foo の変数 a1 に b を、a2 に c を代入することができます。これがアップキャストです。そして、a からメソッド display() を呼び出すと Foo と表示されますが、a1, a2 から display() を呼び出すと、ポリモーフィズムが働いて Bar1, Bar2 の display() が呼び出され、Bar1 と Bar2 が表示されます。
逆に、Bar1 の変数 b1 に a1 を代入するには明示的にキャストする必要があります。これがダウンキャストです。(Bar1) のように型変換するデータ型をカッコで囲み、それを a1 の前に付けます。これで変数 b1 に a1 を代入することができます。そして、b1 からメソッド display() を呼び出すと Bar1 と表示されます。
また、a1 を Bar2 に間違えてダウンキャストすると、コンパイルは成功しますがプログラムを実行するとエラーが発生します。それから、Foo と継承関係のない Baz のインスタンスは、キャストしても Foo や Bar の変数に代入することはできません。ご注意くださいませ。
次に、図形のオブジェクトを配列にまとめて格納することを考えてみましょう。Triangle, Rectangle, Circle は型が違うので、同じ配列に格納することはできません。この場合、スーパークラス Figure に型変換すると同じ配列に格納することができます。次のリストを見てください。
リスト 7 : キャスト (sample66.java) public class sample66 { static double sumOfArea(Figure[] ary) { double sum = 0.0; for (Figure a: ary) sum += a.area(); return sum; } public static void main(String[] args) { Figure[] a = { new Triangle(2.0, 2.0), new Rectangle(2.0, 2.0), new Circle(2.0), }; System.out.println(sumOfArea(a)); } }
$ java sample66.java $ java sample66 18.566370614359172
main() で Figure 型の配列 a を用意します。ここで、サブクラスのインスタンスを生成して配列 a を初期化していますが、アップキャストが行われるので正常にコンパイルできます。
関数 sumOfArea() は図形の面積の合計値を求めます。型変換した場合、サブクラスの情報は失われるため、サブクラス独自のメソッドを呼び出すことはできません。型変換したスーパークラスのメソッドしか利用できませんが、ポリモーフィズムによりサブクラスのメソッドが呼び出されるため、図形の面積を正しく計算することができます。
次に示すクラスを定義してください。
リスト : 解答例 (qo6.java) class Pair { private String value1; private int value2; // コンストラクタ Pair(String a, int b) { value1 = a; value2 = b; } // アクセス関数 String first() { return value1; } int second() { return value2; } void setFirst(String a) { value1 = a; } void setSecond(int a) { value2 = a; } @Override public String toString() { return "(" + value1 + ", " + value2 + ")"; } } class Triple { private String value1; private int value2; private double value3; // コンストラクタ Triple(String a, int b, double c) { value1 = a; value2 = b; value3 = c; } // アクセス関数 String first() { return value1; } int second() { return value2; } double third() { return value3; } void setFirst(String a) { value1 = a; } void setSecond(int a) { value2 = a; } void setThird(double a) { value3 = a; } @Override public String toString() { return "(" + value1 + ", " + value2 + ", " + value3 + ")"; } } public class q06 { public static void main(String[] args) { var a = new Pair("foo", 10); var b = new Triple("bar", 20, 1.2345); System.out.println(a); System.out.println(b); a.setSecond(a.second() * 10); System.out.println(a); b.setSecond(b.second() * 10); b.setThird(b.third() * 100); System.out.println(b); } }
$ javac q06.java $ java q06 (foo, 10) (bar, 20, 1.2345) (foo, 100) (bar, 200, 123.44999999999999)