Javaプログラミング基礎 講義資料

誤った継承の使用と弊害

継承を用いるとスーパクラスの属性やメソッドを取り込むことができます。 すなわち、サブクラスからはスーパクラスの属性を参照することや、 変更することが可能になるということです。 これは、 「本来オブジェクトは内部の状態を見せることなくデータと手続きを一体化し、 外部からはメソッドを用いてのみオブジェクトにアクセスする、」 というカプセル化の利点を減らしてしまいます。

このため、継承は使う必然性が認められる場面でのみ用いるべきである、 と考えておくべきです。 継承を使う必然性が認められる場面とは、 サブクラスはスーパクラスの一種である、 という関係が明確に成り立つ場合です。 英語では、 Sub class is a kind of super class. と書くことから、 あるクラスが別のクラスのサブクラスであることを、 「is-a の関係」と言います。

継承を利用すべきではない場面

以前の演習問題 で作成したクラス CD の演奏の様子を表わすことのできる CD プレイヤ (クラス CDPlayer) を 考えてみましょう。

クラス CDPlayer を定義するために、 クラス CD を継承し、 CD に演奏機能を付け加えたクラスを定義すれば便利である、 と以下のようなプログラムを考えることができるかも知れません。

class CD {
    private String artist;
    private String title;
    protected Music[] tracks;

    public void setArtist(String name) { ... }
    public String getArtist() { ... }
    public void setTitle(String t) { ... }
    public String getTitle() { ... }
    public void setTracks(int trackNo, Music song) { ... }
    public int getNumberOfTracks() { ... }
    public Music getTracks(int trackno) { ... }
}

class CDPlayer extends CD {
    public void load() { ... }  // CDプレーヤにCDをセットする
    public void play() { ... }  // CDを演奏する
    public void play(int trackNo) { ... } // 同上
    public void eject() { ... } // CDを取り出す
}

クラス CD を継承したクラス CDPlayer では、 CD のタイトルやアーティスト、曲といった属性をそのまま使えるので、 CD を演奏する、ということだけに注目すれば便利なように見えます。

継承を用いるべきかどうかを判断する一つの方法は、 サブクラスのオブジェクトがスーパクラスの性質を持っているか、 すなわち、サブクラスはスーパクラスと同種の仲間であるかを考えることです。

この例では、 サブクラスである CD プレイヤが、CD の仲間であるということになってしまいます。 また、CD プレイヤの中に、 アーティスト名やタイトルという属性が存在してしまうことになります。 これらは、CD の属性としては正しくても、 CD プレイヤの属性としてはあってはならないものです。 このような関係は、is-a の関係ではありません。 したがって、継承を用いるのは適切ではありません。

また、仮に、別の場面でここで定義した CD を用い、 CD をたくさん収納できる棚 (コンテナ) クラスを作成した場合、 CD を入れるはずの棚に CDPlayer まで収納できることになってしまいます。 このように、論理的に間違ったオブジェクトの取り扱いができてしまうと、 設計に矛盾が生じたり、プログラムの間違いにつながります。

以上のように、 必然性があり継承を行った結果としてプログラム再利用ができる という利益が得られるのであって、 単に機能を取り込みたい (実装を共有したい) という理由から 継承を用いるのは避けるべきである、 ということを理解する必要があります。

問題

「パソコン」というクラスを考えてみます。 パソコンにはキーボード、ディスプレイ、ディスクドライブなどがあります。 キーボードとディスプレイ、ディスクドライブ等のクラスをまとめて継承し、 これらの機能を統合したパソコンというクラスを作成する方法が考えらます。 これは、継承の正しい利用方法でしょうか。

包含: クラスのメンバに他のクラスのオブジェクトを取り込む方法

他のクラスの機能を利用するための良い方法は、 「包含」を用い、 必要なクラスのオブジェクトをメンバ (属性) として内部に持つことです。

これを利用し、クラス CD と CDPlayer は次のように設計するのが適当です。

class CD {
    private String artist;
    private String title;
    private Music[] tracks;

    public void setArtist(String name) { ... }
    public String getArtist() { ... }
    public void setTitle(String t) { ... }
    public String getTitle() { ... }
    public void setTracks(int trackNo, Music song) { ... }
    public int getNumberOfTracks() { ... }
    public Music getTracks(int trackno) { ... }
}

class CDPlayer {
    private CD cd;  // CD オブジェクトを属性に持つようにする

    public void load(CD disc) { ... }
    public void play() { ... }
    public void play(int trackNo) { ... }
    public void eject() { ... }
}

クラス CDPlayer のメンバに、 クラス CD のオブジェクトを示す変数が宣言されています。
(CD cd;)

CD を演奏する際には、 属性である cd を通じてクラス CD の機能を利用することができます。

問題

「パソコン」というクラスを考えてみます。 パソコンにはキーボード、ディスプレイ、ディスクドライブなどがあります。 これらの機能を統合するために、 キーボードとディスプレイ、ディスクドライブ等のクラスをメンバに持つ、 パソコンのクラスを作成する方法が考えられます。 これは、包含の正しい利用方法でしょうか。

インタフェース

今日の2つめの話題は「インタフェース」についてです。 Java の「インタフェース」の使用方法は、継承と似ています。

継承の目的の一つに、 スーパクラスを起点としてサブクラス群をグループ化するということがありました。 継承によってクラスをグループ化するということは、 同じ型として扱えるようにするということと、 同じオブジェクトとして振る舞うための要素を持たせる、 すなわち、オブジェクトのインタフェースを統一するという意味がありました。

継承がインタフェースを定める機能とクラスの拡張を行う機能があるのに対して、 Java の「インタフェース」は、 純粋にクラスのインタフェースだけを定義するための機能です。

インタフェースの目的は、 クラスのインタフェースを定義することにより、 同じインタフェースを持つクラス群をグループ化することです。 つまり、異なったクラスのオブジェクトでも、 そのインタフェースが同じであれば、 同じグループの一員であるとみなし、仲間として扱うことができるのです。

例題: 円と矩形

円を示すクラス Circle と、 矩形を表すクラス Rectangle についての例を示します。

CircleRectangle は、 図形であるという性質は共通だと考えられます。 図形には、面積を求めるメソッドと、 外周の長さを求めるメソッドが存在するべきだと考えることができます。

このような場合、図形に共通するクラスの利用方法を定義しておき、 CircleRectangle を図形共通の方式で使うことができれば便利です。 図形共通の方式を決めておくためにインタフェースという機能を用います。

interface Shape {
    public int area();
    public int outline();
}


class Circle implements Shape {
    private int radius;
    private static final int PI = 3;

    public Circle(int r) {
        radius = r;
    }

    public int area() {
        return radius*radius*PI;
    }

    public int outline() {
        return 2*radius*PI;
    }
}

class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int w, int h) {
        width = w;
        height = h;
    }

    public int area() {
        return width * height;
    }

    public int outline() {
        return 2*width + 2*height;
    }
}

class SomeShapes {
    public static void main(String[] args) {
        Circle c1 = new Circle(10);
        Circle c2 = new Circle(20);
        Rectangle r1 = new Rectangle(10, 10);
        Rectangle r2 = new Rectangle(10, 20);

        Shape variousShapes[] = new Shape[4];
        variousShapes[0] = c1;
        variousShapes[1] = c2;
        variousShapes[2] = r1;
        variousShapes[3] = r2;

        for (int i = 0; i < variousShapes.length; i++) {
            System.out.println("shape " + i);
            System.out.println("    Area: " + variousShapes[i].area());
            System.out.println("    Outline: " +
                               variousShapes[i].outline());
        }
    }
}

インタフェースの定義

最初の interface で始まるブロックに、 図形として備えているべきインタフェース Shape が定義されています。 ここでは、面積を求めるメソッド area と、 外周の長さを求めるメソッド outline を宣言しています。

インタフェースの定義の文法は次のようになります。

interface インタフェース名 {
    属性の宣言;
    ...
    メソッドの宣言;
    ...
}

ここでのメソッドの宣言は、中身のないメソッドの宣言です。 メソッドの返り値の型、メソッド名、引数のリストの宣言文のみを書き、 メソッドの中身を記述しません。

インタフェースにおけるメソッドの宣言は、 それ自体を実行して何か仕事をするものではありません。 その役割は、 クラスがグループに属するために必要なメソッドの枠組みを定義することです。 インタフェースのメソッド宣言で定義するのは、 メソッドの枠組みだけで、その中身の内容は書きません。 実際のメソッドの仕事内容は、 クラスの定義の中で記述します。 インタフェースで定められたメソッドの内容をクラスの中で定義することを、 「インタフェースを実装する」と言います。

インタフェース定義は、クラス定義の文法と似ていますが、 インタフェースからインスタンスを生成することはできません。 インタフェースは単にクラスの枠組みを決めているのに過ぎないのです。

この例題では、インタフェース Shape の定義によって、 クラスが Shape というグループに属する要件として、 メソッド areaoutline を備えている必要があると決めています。

インタフェースの実装

あるインタフェースを実装するクラスを定義する場合、 文法的には次のように書きます。

class クラス名 implements インタフェース名 {
    ......
    クラス定義の内容
}

インタフェースを実装するクラスには、 インタフェース内で定義されている全てのメソッドを宣言する必要があります。

この例題では、 Shape を実装するクラスとして CircleRectangle を定義しています。 CircleRectangleShape インタフェースを持つグループの一員として、 同じように扱うことができるのです。

一つのクラスで複数のインタフェースを実装することも可能です。 その場合、次のように implements に続けて実装するインタフェース名を コンマで区切って並べます。

class クラス名 implements インタフェース名1, インタフェース名2, ... {
    ......
    クラス定義の内容
}

また、あるクラスを継承し、 なおかつインタフェースを実装するということもできます。 したがって、クラス定義の一般的な文法は次のようになります。

class クラス名 extends スーパクラス名 
               implements インタフェース名1, インタフェース名2, ... {
    ......
    クラス定義の内容
}

実は、Java では継承できるスーパクラスは一つのみである (単一継承, single inheritance) という決まりがあります。 しかし、インタフェースは多重に実装することが可能です。

スーパクラスが一つしか許されない単一継承に対して、 スーパクラスが複数存在する場合を 多重継承(multiple inheritance)と言います。 Java では多重継承を行うことはできませんが、 C++やSmallTalkなどの他の言語には多重継承ができるものもあります。 多重継承では、 クラス階層の設計を正しく行わないと、 複数のスーパクラスから受け継いだ性質に矛盾や不整合を生じる場合があります。 Java では、多重継承によって複数のクラスの性質を受け継ぐことを禁止し、 その代わり「クラスの使い方」であるインタフェースを複数実装することで、 実装上の矛盾や不整合を防いでいます。

インタフェースによるオブジェクトのグループ化

異なったクラスのインスタンスでも、 同じインタフェースを実装していれば、 同じグループの一員として扱うことができます。 これは継承のグループ化の機能とほぼ同一のものです。

この例では、クラス Circle のインスタンスも、 クラス Rectangle のインスタンスも、 インタフェース Shape を実装している「仲間」のため、 Shape 型の変数に代入することが可能です。

        Circle c1 = new Circle(10);
        Circle c2 = new Circle(20);
        Rectangle r1 = new Rectangle(10, 10);
        Rectangle r2 = new Rectangle(10, 20);

        Shape variousShapes[] = new Shape[4];
        variousShapes[0] = c1;
        variousShapes[1] = c2;
        variousShapes[2] = r1;
        variousShapes[3] = r2;

このようにすることで、 Circle のオブジェクト Rectangle のオブジェクトを Shape の一員として同一に扱うことが可能になります。 CircleRectangleShape としての メソッドが用意されていることから、 計算方法は異なっても、面積と外周長を計算することができるわけです。

例えば、 次のように繰り返しを用いて、 異なるクラスのインスタンスに対するメソッドの実行を、 インタフェースで宣言されているメソッドを用いて一括して行うことができます。

        for (int i = 0; i < variousShapes.length; i++) {
            System.out.println("shape " + i);
            System.out.println("    Area: " + variousShapes[i].area());
            System.out.println("    Outline: " +
                               variousShapes[i].outline());

このようにインタフェースによってクラスをグループ化するということは、 同じ型として扱えるようにするということと、 同じオブジェクトとして振る舞うための要素を持たせるという意味があります。