よしたろうブログ

駆動設計・アーキテクチャ・変更容易性とかの話が好きです。

『決定版:Javaの継承と抽象化 ~ 第3部 抽象クラス ~ 』

はじめに

本記事は、「継承」「抽象クラス」「インターフェイス」の各々について、それらの相互作用について学ぶことをテーマにした全4部構成記事の第3部「抽象クラス」について、です。

『継承・インターフェイス・抽象クラス』シリーズ

  1. Javaの継承』~型と実装の継承・合成と委譲・多重継承問題~
  2. Javaインターフェイス』~歴史・型の継承・ポリモーフィズム情報隠蔽・多重継承問題へのソリューション~
  3. Javaの抽象クラス』~抽象化とは・抽象クラスとサブクラスの粒度差・具体的な使用例(Template Methodパターン)~
  4. Javaの抽象クラスとインターフェイスの違い』~表・使い分け・組み合わせ爆発問題・抽象骨格実装 ~

以上の構成を予定しています。

  • 基本的な情報
  • 特徴・メリット・デメリット
  • 目的や注意点
  • オブジェクト指向の中での役割

などなど深堀していきます。

それぞれの特徴とメリット・デメリット、トレードオフ。登場した当時の歴史的背景や、何に対してのソリューションなのか?オブジェクト指向の目的である「変更容易性」のある設計に対してどの様な貢献をするのか?

など、統一的かつ網羅的な内容にしようと思っています。

言語が用意しているシステムが、何をしたいのか?何のためにあるのか?どんな背景や文脈の元で登場したのか?歴史の中でどう変化していったのか?

  • 再利用性が高い
  • 独立性の高い
  • 変更に対して影響が少ない
  • 変更の影響が特定しやすお
  • 機能追加・拡張が容易
  • 意図がわかる一意な名付け
  • データ抽象化・情報隠蔽
  • データと処理を一つの固まりとして扱える

などですね、そんなことが出来るエンジニアになれる様に頑張ります。

ピンポイントな情報を見たい場合は目次を用いて抽出してください。長いです。

また、以下の前置き前提知識はシリーズ全てに記述しています。

前置き

継承については以下の様に明示します。

  • 継承(extends):実装・型(仕様)の継承を指す
    • クラス・抽象クラスでの継承のこと
  • 継承(implements):仕様(型)の継承を指す
  • 継承:概念そのものを指す
    • 親の性質と子の性質が同一であること

本記事では幾つかの目線で同一のものを説明します。

  • 言語の歴史的背景によるJava現行仕様の「正統性」の目線
  • オブジェクト指向の目的である「変更容易性」の目線

大きく分けてこの二つかなと思います。ですが、双方密接にからみあう要因ですので両方の目線から話すこともあれば片方の目線でのみ語ることもあります。そのあたりはなるべく明示したいところですが難しい点もあるので文脈・コンテキストから読み取っていただく必要があるかもしれません。というか、あります。もうしわけないっす。

前提知識

Java における『継承』には2種類の目的があります。

型(仕様)の継承:implements

どのようなメソッドを持っているか、どのように振る舞うかを継承。型・メソッド名・引数と引数の型・戻り値の型、といったシグネチャ

実装(コード)の継承:extends

どのようなデータ構造を使い、どのようなアルゴリズムで処理するかを継承。 仕様を実現するための具体的なメソッド・実装の詳細、コードのことです。

  • 型(仕様)継承は『implements』(インターフェース)
  • 実装継承・型(仕様)継承は『extends』(クラス・抽象クラス)

Javaでは、二種類に継承を分けて、型(仕様)の継承のみ多重継承できるようにしています。これはデータ構造の衝突やクラス階層の複雑化などを回避するのが目的です。また、extends による継承は「型(仕様)の継承」「実装の継承」両方とも行われます。

これらは『実装の多重継承』が引き起こす問題に対する言語毎の対応の結果、という歴史の背景があります。『実装の多重継承』はメリットがある一方で大きなデメリットを生み出す可能性もあるので、Javaでは『型』と『実装』を同時に多重継承することを禁止する、という方法で安全を確保しています。

言語毎で『実装の多重継承問題』に対する対応は異なります。実装の多重継承が許可されている言語(C++PerlRuby)では、Mix-in(ミックスイン)と呼ばれるテクニックがあります。細かくは言語毎に異なる様です。Javaにもその概念がある様ですが、本記事では言及しません。というか、具体的にイメージできなかったので言語化できませんでした。いつかしたいと思います。

変更容易性の実現という目線でみた場合、インターフェイスと抽象クラスの存在意義

オブジェクト指向の三大要素は

ですが、これらの目的は

『変更容易性の高いシステム・ソフトウェア』

の開発です。 開発コストと保守コストがありますが、主に保守コストに対して注目した設計手法なのがオブジェクト指向です。 この中でもっとも重要な考え方が『カプセル化』です。

クラス指向なオブジェクト指向プログラミング言語は、『変更されない箇所を軸に、頻繁に変更されるであろう箇所をクラスに抽出する』ことを想定しています。

変更される場所と変更されない場所を分離することで、「変更されない部分の再利用」そしてより重要な「変更されない部分を利用するプログラムの再利用」が可能になり、変更される側は変更に強い構造にできるようになります。その構造のサンプルとしてデザインパターンが存在しています。

オブジェクト指向は変更されやすい・されづらい箇所を分離しただけではありません。変更されやすい箇所の変更に際し、他の箇所に影響が及ばない様な仕組みを作ってくれています。

上記の双方を実現するための概念が「継承」「ポリモーフィズム」「カプセル化」です。もうひとつ必要な概念があります。

「抽象化」

です。設計段階での抽象化とはちがい、実装レイヤーでの低い場所での抽象化の話になりますが抽象化の作業自体は同じです。抽象化と多態性ポリモーフィズム)には密接な関係があります。

抽象化とは、抽象化したい複数のオブジェクトに、共通で存在する概念や性質を定義・抜き出し、それ以外のものには注目しない、ということです。決して共通の機能を取り出すわけではありません。

テレビのリモコン、エアコンのリモコン、オーディオのリモコン、照明のリモコン

これらの複数のオブジェクトの重要な共通点・性質を考えてみます。これが「抽象化」です。 ざっくりいうなら「生活家電(広義では情報機器)を操作できる」でしょうか。

抽象化は、オブジェクト指向プログラミングで使用される最も基本的な概念の1つです。抽象化は様々な恩恵を与えてくれます。

Java では、抽象化は抽象クラスまたはインターフェースを使用して行われます。2つの主な違いは、抽象クラスは部分的な抽象化も提供できるのに対し、インターフェイスは常に完全な抽象化を提供することです(Java8からは変わった。詳細は後述)。

抽象化を用いる事で「ポリモーフィズム」や「カプセル化」が表現できます。

インターフェイス

インターフェイスに対してプログラミングを行う事でポリモーフィズムが実現できます。この「インターフェイスに対してプログラミングを行う」というのは、抽象メソッドに対してプログラミングを行うという意味になります。

例えば、インターフェイスは『What:なにをするか(できるか)?』と、それを行うのに必要・必須な抽象メソッドが定義されています。ですが、『How:どうやって実現するか?』は定義されていません。その定義を行うためには具象クラスでは、継承した抽象メソッドの具体的な内容を定義する必要があります。

また、クラスやオブジェクトの情報隠蔽を行う際に、呼び出し側と情報隠蔽された側での接続部分にもなります。パッケージやクラスのアクセス修飾子で非公開・公開の設定を制御する情報隠蔽を行う場合、使う側と非公開なプログラムと繋ぐ接点としての公開部分でもあります。

非公開 - 公開(インターフェイス)- 使う側・呼び出し側

の様な形です。

また、内部の詳細を隠して機能を表示する「データの抽象化」も行います。実装の詳細を隠し、機能・振る舞いのみを外部に提示することが可能です。

まとめると、以下になります。

  1. 呼び出す側と実装の間にインターフェイスが介在することで結合度がさがる(疎結合
  2. 呼び出し側から実装の詳細が隠蔽・分離される(データ抽象化・ポリモーフィズム
  3. インターフェイスや抽象クラスを実装する具象クラス(インスタンス)を入れ替えることで同じ操作で応答を変化させることができる(ポリモーフィズム
  4. カプセル化されたモジュール間での通信経路(情報隠蔽カプセル化
  5. 情報隠蔽が行えることによる開発当初の設計意図の保持(プロダクトの劣化防止)

抽象クラス

抽象クラスでは、複数のオブジェクトで共通する概念を表現するコードを抽出し、それをスーパークラスの具象メソッドとして定義します。簡単にいえば共通部分を括り出して重複を削除しています。大事なのは、その括り出される対象のオブジェクト達の選び方です。

括り出される対象のオブジェクト達であるサブクラスと、括り出されたコードの抽出先であるスーパクラスとが「is - a」の関係であることです。つまり、括り出したコード部分はスーパークラス・サブクラスの根幹的な性質である必要がります。これはクラスの継承でも同様のことが言えます。

この「is - a」の文章が成立しないのであれば抽象クラスとしてまとめるべきでもなければ、それを継承してもいけないのです。継承は機能の受け継ぎとして用いられるイメージがつよいかもしれません。その側面もあると思いますが、それのみで継承を行うのは間違いです。単純に機能の再利用がしたい場合はコンポジションとデリゲーションを使用すべきです。

抽象メソッド

抽象メソッドは複数のオブジェクト(サブクラス)がそれぞれで表現したいことを、それぞれのサブクラスで具象メソッドとして定義します。例えば、抽象クラス『人間』を継承するサブクラス「黒色人種」「白色人種」「黄色人種」「黒褐色人種」があったときに、スーパークラスに「肌の色」という抽象メソッドがあります。サブクラスではそれを具象化し、それぞれのサブクラスで「黒色」「白色」「黄色」「黒褐色」と具象メソッドで定義するというとです。色には色の機能があるのでここではメソッドして扱っています。

呼び出し元と呼び出し先のインターフェイス・抽象クラスは同じなのに、インターフェイス・抽象クラスの具象クラスとなるインスタンスによって応答が異なる(具象クラスでの具体的な実装が異なることで)というポリモーフィズムが実現できる様になります。ポリモーフィズムが実現できるのはインターフェイスでも抽象クラスでも同じです。

抽象クラスとインターフェイスには、上記の他に大きな違いがいくつもあります。

  1. 実装の継承か、型の継承か
  2. 多重継承問題に対する立ち位置
  3. メンバ変数の定義が制限されているかいないか
  4. クラスツリーから独立しているか
  5. アクセス修飾子

上記の様な大きな違いがあります。以降からは「継承」「抽象クラス」「インターフェイス」の基本的な部分から説明します。本記事では継承と抽象クラス・インターフェイスをひとまとめにして話してますが、本来は階層の異なる概念なので、ごちゃ混ぜにならない様に注意してください。そうならない様に私も気をつけます........

3. 抽象(Abstract)クラスとは

抽象クラスは、クラスとインタフェースの中間に位置するもので、型を定義して(クラスと同じように)具体的な実装を含めることができますが、具体的な実装のない抽象メソッドを持つこともできます。抽象クラスは実装の詳細が未定義であり、継承されたサブクラスで埋める必要があります。部分的に実装されたクラスと考えることもできます。

抽象メソッドは処理内容が定義されていないメソッドなので実装の詳細は、この抽象クラスを継承するサブ(具象)クラスで記述する必要があります。それを行わない場合はコンパイルエラーとなります。

抽象クラス・抽象メソッドは、サブ(具象)クラスに継承されることで本来の役割を果たすことができます。

3-1. 抽象クラスの特徴

  1. 抽象クラスを継承したサブクラスでは、抽象クラスの抽象メソッドのオーバーライドを強制 
    1. override しないとコンパイルエラー
    2. override は「上書き」ではなく『再定義』という意味
  2. サブクラスでコンストラクタの記述が必須x
  3. 直接インスタンス化できない
    1. 黒人・白人・黄色人を汎化 → Abstract Humanクラス 作成
    2. そのような抽象的なクラスはインスタンス化できない
  4. 実装の多重継承はできない
    1. 継承は実装・型の双方を行う単一継承のみ
  5. 具象メソッド・抽象メソッドの双方を持つことが可能
  6. アクセス修飾子は protected / public の使用が可能
  7. メンバ変数に制限はない
  8. クラス階層の一部を構成する
  9. クラス間での結合度はインターフェイスより高いがクラス同士の結合よりは低い
  10. 関係性は「is - a」と「リスコフの置換原則」(抽象クラスに限らず通常のクラスの継承も)が前提

3-2. 抽象クラスのメリット

  1. 共通処理を抽象クラスで実装しておけばサブクラスでは省略可能になる(結合が強すぎる場合、デメリットの危険性は多いにある)
    1. 処理の一部・骨組みを共通化し再利用ができる
  2. 抽象クラスを継承することでメソッドを統一化する事が可能になり、何をしているか把握しやすい
    1. 抽象メソッドの実装により、ポリモーフィズムが行える(メソッド・シグネチャが統一されるため)
  3. 複数人の大規模の開発において、共通処理をするメソッド名に統一性を持たせ、処理の画一性をある程度は制御できる
  4. メソッド実装漏れ、メソッド名の間違いがあればコンパイルエラーされるのでコーディングミスを防ぐ
  5. サブクラスをスーパークラスの型として共通に扱うことができるためポリモーフィズムを表現できる
  6. 共通の処理は抽象クラスに押し込めてカプセル化できる(不完全)
  7. クラス間で密接に関係していることを表現できる

3-3. 抽象クラスのデメリット

あくまで一部の抽象化であり、継承(extends)を行うためデメリットは継承(extends)と非常に近いです。

  1. サブクラスはスーパークラスの実装にある程度は依存しているので変更に弱いかもしれない
    1. 抽象化が正しく、抽象クラスの具象メソッドが適切であるか?
    2. 抽象メソッドと具象メソッドのバランス、具象クラスとの協調関係の調整が必須
  2. 継承の階層が深まるのは非常に危険だが、誰でも簡単にできてしまう
  3. 抽象クラスと具象クラスの関係性「is - a」を破ってしまう様な継承(extends)が簡単に行える
    1. 具体的なクラスを抽象化したクラスの関係が汎化(is - a)という
  4. 階層が深くなりすぎたり関係性が破られてしまうと、可読性・保守性・拡張性・再利用性は大きく損なわれる
    1. 単一継承といえど複雑化してしまうと、問題発生時の切り分けにも困る
    2. 単一継承はツリー構造で単純な構造です。対して多重継承はネットワーク構造で複雑になりがちです
  5. 漫然と使用するといつでもどこでも上記が起こり得る
  6. 継承(extends)はクラスツリー内での実行に制限があるため「組み合わせ爆発」が起こりうる(「抽象クラスとインターフェイスの違い」の項にて説明)
  7. 多重継承問題
  8. カプセル化の破壊(具象メソッドに対して)
    1. スーパークラスの実装を理解し再利用するため、ホワイトボックス再利用と呼ばれます
    2. 結局、実装継承はカプセル化違反
  9. クラス数が増える

3-4. 抽象クラスの目的と注意点

抽象クラスは、実装を完成させるための土台や共通処理を提供することを意図した仕組みです。ですが、あくまで機能の再利用といった単純な視点で使用するものではありません。『第一部 継承 』にて継承の使用は非常に慎重に行うべきだと説明しました。

前提知識の項でも抽象化について説明しています。

抽象化とは、抽象化したい複数のオブジェクトに、共通で存在する概念や性質を定義・抜き出し、異なるものには注目しない、ということです。決して共通の機能を取り出すわけではありません。

テレビのリモコン、エアコンのリモコン、オーディオのリモコン、照明のリモコン

これらの複数のオブジェクトの重要な共通点・性質を考えてみます。これが「抽象化」です。 ざっくりいうなら「生活家電(広義では情報機器)を操作できる」でしょうか。

3-4-1. 抽象化はプログラミングのあらゆる場所で使用されている

マジックナンバーを変数に代入する。処理を一つの関数にまとめる。処理とデータをクラスにまとめる。これらには役割や処理の本質を表す名前をつけますよね。良 い名付けも必要で、良い名付けはそれだけでなにをしているか知覚することができます。これが抽象化です。パッケージも抽象化です。名付けと本質が異なれば、実装の詳細を見に行かないとわからなくなる為、非常に認知的負荷が上昇するので名付けも重要です。また、抽象化も不適切な階層構造を形成したり、粒度によっては認知的負荷を上昇させるので注意が必要です。

抽象化された本質・性質を適切に名付けする必要があります。

これらができた上で初めてカプセル化が実現し、ポリモーフィズムも成り立ちます。ポリモーフィズムは結局のところ、具象クラスの隠蔽化であり、つまるところカプセル化です。

抽象クラスとは、複数のクラスから共通する概念を抽象化し抽出されたクラスのことです。決して共通の機能ではありません。それは間違った解釈です。その場合はコンポジションとデリゲーションを行うべきです。

3-4-2. 実用例 ~ Abstract Pattern ~

コードのほとんどは、以下からの引用になります。

デザインパターン紹介 —GoF以外のパターンを紹介します— 結城浩

抽象クラスなし

MusicPlayerとVideoPlayerという2つの「プレイヤー」のクラスがあります。 このクラスたちは同じメソッド群を持っていますが、 互いに関連付けられていません。 Mainクラスはそれぞれのプレイヤーを使っていますが、 両者を取りまとめることはできません。

class MusicPlayer {
    public void load(String title) {
        /* load the music */
    }
    public void play() {
        /* play the music */
    }
    public void pause() {
        /* pause playing */
    }
    public void stop() {
        /* stop it */
    }
}

class VideoPlayer {
    public void load(String title) {
        /* load the video */
    }
    public void play() {
        /* play the video */
    }
    public void pause() {
        /* pause playing */
    }
    public void stop() {
        /* stop it */
    }
}

class Main {
    public static void main(String[] args) {
        MusicPlayer mplay = new MusicPlayer();
        mplay.load("bach.mid");
        mplay.play();

        VideoPlayer vplay = new VideoPlayer();
        vplay.load("mi2.avi");
        vplay.play();
    }
}

抽象クラスを用いた場合

では、Abstract Classパターンを使った例をお見せしましょう。

今度は抽象クラスPlayerを作ります。 そして、共通なメソッド群を抽象メソッドとして宣言します。 MusicPlayerとVideoPlayerは共通の親クラス(基底クラス、スーパークラス)として、 Playerクラスを持つことにします(extends Playerの部分に注目)。

上の例と比べてみてください。

abstract class Player {
    public abstract void load(String title);
    public abstract void play();
    public abstract void pause();
    public abstract void stop();
}

class MusicPlayer extends Player {

    @Override
    public void load(String title) {
        /* load the music */
    }

    @Override
    public void play() {
        /* play the music */
    }

    @Override
    public void pause() {
        /* pause playing */
    }

    @Override
    public void stop() {
        /* stop it */
    }
}

class VideoPlayer extends Player {

    @Override
    public void load(String title) {
        /* load the video */
    }

    @Override
    public void play() {
        /* play the video */
    }

    @Override
    public void pause() {
        /* pause playing */
    }

    @Override
    public void stop() {
        /* stop it */
    }
}

class Main {
    public static void main(String[] args) {
        Player mplay = new MusicPlayer();
        mplay.load("bach.mid");
        mplay.play();

        Player vplay = new VideoPlayer();
        vplay.load("mi2.avi");
        vplay.play();

        Player[] player = { mplay, vplay };
        for (int i = 0; i < player.length; i++) {
            player[i].stop();
        }
    }
}

さらに、Mainクラスを見てください。ここでは、MusicPlayerのインスタンスmplayとVideoPlayerのインスタンスvplayをあわせて、1つのPlayerの配列に押し込んでいます。そしてforループで回してstopメソッドを呼び出しています。この部分、player[i]に格納されているのがどのクラスのインスタンスなのかを調べていないことに注目してください。 これは、ポリモルフィズムの典型的な例です。これができるのは、Playerという共通の親クラスがあるからです。

Abstract Classパターンを使わないと、MusicPlayerとVideoPlayerはばらばらでした。でも、Playerという抽象クラス(abstract class)を導入することで、ばらばらだったMusicPlayerとVideoPlayerがまとまり、 1つの「型(type)」を作ったことになるのです。

VideoPlayerクラス と MusicPlayerクラス は抽象クラス Player を継承(extends)しているので、実装と型を継承しています。今回は実装メソッドを定義していません。ここで重要なのは、抽象クラスのサブクラスが共通の型も継承している為、Player型の配列にサブクラスを代入可能にしている点です。

お互い違うクラスのインスタンスですが、抽象クラス型の変数に、サブクラスのインスタンスを代入できているのがポイントです。 抽象クラス型変数に代入されてるインスタンスからはサブクラスに共通の抽象メソッドを呼び出すことができるようになります。同じメソッドの名前でもこのそれぞれ違う動作になります。同じメソッドでも異なる振る舞いを実行できる性質はポリモーフィズムの一種です。

二つのサブクラスは、抽象クラス Player から、「何か」を「読み込み再生し、一時停止・停止させる」という、型(振る舞い)を継承しました。

音楽の再生か、動画の再生か。どちらかによって、実装の詳細が変わります。ですが結局のところ行いたいことはどちらも Player クラスが表現する振る舞いです。「何か」を具象化したのがサブクラスです。ここでは何かとは「動画再生機」「音楽再生機」でした。逆に言えば、二つのサブクラスから、共通する性質(再生機)を抽出したクラスが抽象クラスですね。「is - a」の関係性であり、この関係性を「汎化」ともいいます。is - a の関係性で抽出した性質をスーパークラスで定義するという意味です。 逆にサブクラスにて抽象化された性質が具象化されることを「特化」と言います。

また、スーパークラスである Player クラスにはサブクラスのインスタンスが代入可能となっていました。リスコフの置換原則に則っています。

3-4-3. リスコフの置換原則

リスコフの置換原則(The Liskov Substitution Princiddle (LSP))とは「サブクラスは基底クラスの代わりとして振舞えなければならない」という原則です。

SがTの派生型であれば T型のオブジェクトが使われている箇所は全て S型のオブジェクトで置換可能になります。

多相性とジェネリクス

T型の引数を取ったりT型の戻り値を返したりする関数があったとき、型Tに対して互換性がある別の型Uを持ってきたら、「T型の引数を取ったりT型の戻り値を返したりする関数」と「U型の引数を取ったりU型の戻り値を返したりする関数」に互換性があるかどうか、といったことを考える時の話です。

引用:はらへメモ リスコフの置換原則

リスコフの置換原則とは?

  • Liskov Substitution Principle
  • 派生型は基底型と置換可能でなければならない
    • 型の継承関係の正当性は本来の性質とは別
      • 例: Rectangle → Square
  • 設計者はユーザーの視点で 合理的な仮定 をしなければならない
    • 契約による設計 ( Design By Contract )
      • 事前条件と事後条件を取り決めておく
    • IS-A関係のあるオブジェクトは同等に振る舞うべき
      • 例: SquareはRectangleのように振る舞えない
        • Squareは縦横の長さに別々の値を持てない
  • リスコフの置換原則に違反しているか判断する経験則
    • 派生クラスで機能が退化している
      • 基底クラスでは実装されているのに、派生クラスでは何もしないメソッドなど
    • 派生クラスから例外がはかれる
      • 基底クラスのユーザーが期待しない例外を派生クラスのメソッドが投げるなど
  • リスコフの置換原則は[開放/閉鎖原則]を有効にする主要な役割を果たす原則の一つ

  • 基底クラスでは実装されているのに、派生クラスでは何もしないメソッドなど

こちらはたとえば、WriteRead という抽象クラスがあります。こちらは名前の通り、何かを書き込みし読み込みを行うことを表現しています。この WriteRead を継承したサブクラス Write クラスを作成します。このクラスは名前の通り、書き込みのみを行うクラスです。その場合、スーパークラスではできた読み込みがサブクラスでは出来なくなってしまいます。

これはスーパークラスと派生クラスの『置換不可』であり、サブクラスでの『機能の退化』を意味しているので、リスコフの置換原則違反となります。

動画と音楽を再生するのをイメージし易くしてみましょう。YouTubeで音楽を聴きたい時を想像してみてください。

動画再生と音楽再生は密接に関連していますし、しているべきだということが簡単にわかっていただけると思います。

「再生を止める」

この行為で「音楽」と「動画」が同時に停止されるのが当然ですよね? それをコードで表現したのが上記の抽象クラスを用いた例です。実際にYouTubeでどの様に実装されているかは分かりません。あくまで例として出しているだけです。

もちろん、抽象クラスを用いなくても実装できます。ですが、抽象クラスの方は二つのサブクラスが密接に関連していることが表現されていることに気付くでしょう。ふたつのサブクラスを一つの型として扱っています。この様に、密接に関連するいくつかのクラス間で実装と型を共有したい場合に抽象クラスは適しています。

実装の強制やその他、諸々のルールも持ちろん大事ですが、何のためにあるのかという概念を理解する方が重要です。

抽象メソッドしか定義されていない抽象クラスでは、インターフェイスと変わらないではないかと感じるかもしれませんが、それは上記でも記述していた

密接に関連するいくつかのクラス間で実装と型を共有したい場合に抽象クラスは適しています。

を表現するために分かりやすい例として取り上げています。 実際インターフェイスでは上記の表現が出来ません。

以下では、実装の共有も含めた抽象クラスの使用方法を紹介します。

3-4-3. 実用例 ~ Template Method Pattern ~

引用:Wiki~Template Method パターン~

Template Method パターン(テンプレート・メソッド・パターン)とは、GoF (Gang of Four; 4人のギャングたち) によって定義されたデザインパターンの1つである。「振る舞いに関するパターン」に属する。Template Method パターンの目的は、ある処理のおおまかなアルゴリズムをあらかじめ決めておいて、そのアルゴリズムの具体的な設計をサブクラスに任せることである。そのため、システムのフレームワークを構築するための手段としてよく活用される。

TemplateMethodパターンはアルゴリズムの不変な部分をスーパークラスで実装し、変わりうる部分をサブクラスで実装するパターンです。

AbstractDisplayClass

public abstract class AbstractDisplay {
    // open, print, closeはサブクラスに実装をまかせる抽象メソッド
    protected abstract void open();
    protected abstract void print();
    protected abstract void close();

    // displayはAbstractDisplayで実装してるメソッド
    protected final void display() {
        open();
        for (int i = 0; i < 5; i++) {
            print();
        }
        close();
    }
}

この抽象クラス AbstractDisplay には、抽象メソッドと具象メソッドである protected final void display() が存在しています。

抽象メソッドはサブクラスにて具象化されることは AbstractPattern で説明しました。異なるのは具象メソッドが存在する点ですね。

スーパー(抽象)クラスでは共通な処理(処理順序)を定め、サブ(具象)クラスでその具体的な内容を定めるデザインパターンです。『処理順序』と『(実装の)詳細』の『分離』が重要なポイントです。それにより、具象クラスの方では処理順序を逐一記述する必要がなくなります。抽象クラスの段階で『処理の流れを形作る』のです。処理順序が決まっているけど、特定のケースごとに詳細がひとつひとつ異なる状況で使用します。

protected final void display() の final はサブクラスにて、関数の上書きを防ぐためです。 また、抽象メソッドの可視性が protected なのは、このメソッドが継承(extends)先のサブクラス内でのみ使用が許可されていることを表しています。

共通の実装は抽象クラスでメンテナンスし、各クラス独自の処理については、それぞれのクラスに実装可能です。

この様な形で、サブクラスで実装が異なるという形でポリモーフィズムが可能になっていますが、継承を用いている時点でカプセル化は不完全です。

単一責任原則に反していますし、修正に関しても基底クラスである抽象クラスに機能追加が行われる様であれば開放閉鎖原則にも反することになります。

3-4-4. 実装の継承はやっぱり危険

結局のところ、実装の継承を行う・行えるというのは継承のデメリットをそのまま内包しています。

『第1部 継承』にて継承のデメリットして以下をあげています。詳しくは、記事を御覧ください。

継承(extends)のデメリット 1. サブクラスはスーパークラスの実装に強く依存しているので変更に弱い 1. 共通箇所(不変)をスーパークラスにまとめるためサブクラスがその実装に依存する 2. 継承(extends)の階層が深まるのは非常に危険だが、誰でも簡単にできてしまう 3. スーパークラスとサブクラスの関係性「is - a」を破ってしまう様な継承(extends)が簡単に行える 4. 階層が深くなりすぎたり関係性が破られてしまうと、可読性・保守性・拡張性・再利用性は大きく損なわれる 1. 単一継承といえど複雑化してしまうと、問題発生時の切り分けにも困る 2. 単一継承はツリー構造で単純な構造です。対して多重継承はネットワーク構造で複雑になりがちです 3. サブクラスだけでは何をしているか解らないためスーパークラスを常に参照する必要がある 5. 漫然と使用するといつでもどこでも上記が起こり得る 6. 継承(extends)はクラスツリー内での実行に制限があるため「組み合わせ爆発」が起こりうる(「抽象クラスとインターフェイスの違い」の項にて説明) 7. 多重継承問題(後述) 8. カプセル化の破壊 1. スーパークラスの実装をサブクラスに公開 1. スーパークラスの実装を理解し再利用するため、ホワイトボックス再利用と呼ばれます

昔はよくトランザクションの開始、終了タイミングを揃えるよう、抽象クラスを使い、テンプレートメソッドパターンにしていたようです。しかしこれは多くのフレームワークでは、トランザクションが必要なメソッドにアノテーションを宣言すれば事足りる様になっています。

抽象クラスやインターフェースは、構築していくシステムの『概念』をより正確に定義しコードを分かりやすくするための道具です。Javaの進化と共にアノテーションで共有する概念を宣言したり、DIコンテナ等で共通する処理を注入するなど、別の手段が取れるようになってきているため、抽象クラスが登場する場面は減ってきていると言えそうです。

3-4-5. 抽象クラスとサブクラスの粒度差

例えば、人間/まぐろ/にわとり を抽象化する場合には「動物」となるような、共通した項目に置き換えることです。

上位概念に置き換えるとも言えます。サッカーとバスケットの共通項目・上位概念では「スポーツ」のことです。 「人間」→「哺乳類」→「生命」→「世界」・「地球」→「宇宙」→「神」のような連想ゲームで考えるとわかりやすいかもです。

ですが、この様なプログラミングにおいての例えとしては些か不適切だと感じています。

人間クラス/まぐろクラス/にわとりクラスが存在する場合、これらは動物クラスを継承して作ることができる

と考えてしまうと後々になって不具合を発生させることになると思います。動物クラスが神クラス化しています。スーパクラスとサブクラス間で違いがありすぎるんですね。動物クラスから、それぞれ「人間」「まぐろ」「にわとり」の間にもっと段階が必要です。パッと思いつくだけでも、動物クラスとそれぞれのサブクラス間で「哺乳類」「魚類」「鳥類」という抽象クラスが必要です。サブクラスを抽象化した時に最も近い上位概念を抽象クラスとして定義するべきです。

以下の図はイメージですが、より正確に言えば魚とマグロの間にも抽象概念が存在します。

引用:Wiki

マグロ(鮪、黒漫魚、金鎗魚、眞黒、𩻩)は、スズキ目・サバ科マグロ族マグロ属(学名:Thunnus)に分類される硬骨魚類の総称。

なので、厳密に抽象化するならば「マグロ→マグロ属→マグロ族→サバ科→スズキ目→魚」という抽象化の流れになるわけですね。これを抽象化クラスとその具象クラスで置き換えます。マグロの抽象クラスは『マグロ属』でサブクラスが「キハダマグロ」「クロマグロ」「ビンナガマグロ」になると。

引用元:スズキ目(Perciformes)について

3-4-6. 適切な抽象クラスは具象クラスとほぼ同じ

抽象クラスとそ具象クラスの適切な関係性は以下の様な形だと思っています。前提知識の項で使用した説明です。加えて「is - a」の関係性は必須です。また、リスコフの置換原則も守るべきでしょう。

抽象クラス『人間』を継承するサブクラス「黒色人種」「白色人種」「黄色人種」「黒褐色人種」があったときに、スーパークラスに「肌の色」という抽象メソッドがあります。サブクラスではそれを具象化し、それぞれのサブクラスで「黒色」「白色」「黄色」「黒褐色」と具象メソッドで定義するというとです。色には色の機能があるのでここではメソッドして扱っています。

こちらの話や Abstract Pattern でもわかる様に、抽象クラスと具象クラスはほとんど同じ「もの」です。対してインターフェイスは「振る舞い・性質」を表します。抽象クラスは「もの」を表します。

抽象クラス動物と具象クラス人間や犬は、実はこの考え方からすると同じだとも言えるしこの記事で言う様に同じではない、とも言えます。抽象の段階・階層は何が適切か?というのはケースバイケースなのでしょう。重要なのは、抽象クラス・具象クラス間で抽象レベルの乖離をどれほど許容するかなのではないでしょうか?解離があればあるほど具象クラスでの表現の幅は増えるでしょうが、抽象クラスが神クラス化する可能性が高くなるように思います。乖離がない場合は上記の逆になりますね。まさしく表裏一体、トレードオフです。常にベストな使い方などないのですね。

抽象クラスであるPlayer を再生機と定義します。再生機・人間・マグロ属は厳密には具体的な存在ではありません。概念でしかありません。

簡単な考え方ですが、再生機・人間・マグロ属の絵を描いてみてください。人によってきっと異なるでしょう。インスタンス化できない抽象クラスとはそういう扱いになるべきなのです。対して、具象クラスであるサブクラスの黒人や白人、キハダマグロ・ビンナガマグロを描いてくださいと言ったらほとんどの人で描かれるものは一致するはずです。

ということで抽象クラスの話はここで一旦終了になります。次は最後の第四部として抽象クラスとインターフェイスの使い分けを説明したいと思います。