『決定版:Javaの継承と抽象化 ~ 第3部 抽象クラス ~ 』
はじめに
本記事は、「継承」「抽象クラス」「インターフェイス」の各々について、それらの相互作用について学ぶことをテーマにした全4部構成記事の第3部「抽象クラス」について、です。
前部 「インターフェイス」
- 『Javaの継承』~型と実装の継承・合成と委譲・多重継承問題~
- 『Javaのインターフェイス』~歴史・型の継承・ポリモーフィズム・情報隠蔽・多重継承問題へのソリューション~
- 『Javaの抽象クラス』~抽象化とは・抽象クラスとサブクラスの粒度差・具体的な使用例(Template Methodパターン)~
- 『Javaの抽象クラスとインターフェイスの違い』~表・使い分け・
組み合わせ爆発問題・抽象骨格実装 ~
以上の構成を予定しています。
- 基本的な情報
- 特徴・メリット・デメリット
- 目的や注意点
- オブジェクト指向の中での役割
などなど深堀していきます。
それぞれの特徴とメリット・デメリット、トレードオフ。登場した当時の歴史的背景や、何に対してのソリューションなのか?オブジェクト指向の目的である「変更容易性」のある設計に対してどの様な貢献をするのか?
など、統一的かつ網羅的な内容にしようと思っています。
言語が用意しているシステムが、何をしたいのか?何のためにあるのか?どんな背景や文脈の元で登場したのか?歴史の中でどう変化していったのか?
- 再利用性が高い
- 独立性の高い
- 変更に対して影響が少ない
- 変更の影響が特定しやすお
- 機能追加・拡張が容易
- 意図がわかる一意な名付け
- データ抽象化・情報隠蔽
- データと処理を一つの固まりとして扱える
ピンポイントな情報を見たい場合は目次を用いて抽出してください。長いです。
3. 抽象(Abstract)クラスとは
抽象クラスは、クラスとインタフェースの中間に位置するもので、型を定義して(クラスと同じように)具体的な実装を含めることができますが、具体的な実装のない抽象メソッドを持つこともできます。抽象クラスは実装の詳細が未定義であり、継承されたサブクラスで埋める必要があります。部分的に実装されたクラスと考えることもできます。
抽象メソッドは処理内容が定義されていないメソッドなので実装の詳細は、この抽象クラスを継承するサブ(具象)クラスで記述する必要があります。それを行わない場合はコンパイルエラーとなります。
抽象クラス・抽象メソッドは、サブ(具象)クラスに継承されることで本来の役割を果たすことができます。
3-1. 抽象クラスの特徴
- 抽象クラスを継承したサブクラスでは、抽象クラスの抽象メソッドのオーバーライドを強制
- override しないとコンパイルエラー
- override は「上書き」ではなく『再定義』という意味
- サブクラスでコンストラクタの記述が必須x
- 直接インスタンス化できない
- 黒人・白人・黄色人を汎化 → Abstract Humanクラス 作成
- そのような抽象的なクラスはインスタンス化できない
- 実装の多重継承はできない
- 継承は実装・型の双方を行う単一継承のみ
- 具象メソッド・抽象メソッドの双方を持つことが可能
- アクセス修飾子は protected / public の使用が可能
- メンバ変数に制限はない
- クラス階層の一部を構成する
- クラス間での結合度はインターフェイスより高いがクラス同士の結合よりは低い
- 関係性は「is - a」と「リスコフの置換原則」(抽象クラスに限らず通常のクラスの継承も)が前提
3-2. 抽象クラスのメリット
- 共通処理を抽象クラスで実装しておけばサブクラスでは省略可能になる(結合が強すぎる場合、デメリットの危険性は多いにある)
- 処理の一部・骨組みを共通化し再利用ができる
- 抽象クラスを継承することでメソッドを統一化する事が可能になり、何をしているか把握しやすい
- 複数人の大規模の開発において、共通処理をするメソッド名に統一性を持たせ、処理の画一性をある程度は制御できる
- メソッド実装漏れ、メソッド名の間違いがあればコンパイルエラーされるのでコーディングミスを防ぐ
- サブクラスをスーパークラスの型として共通に扱うことができるためポリモーフィズムを表現できる
- 共通の処理は抽象クラスに押し込めてカプセル化できる(不完全)
- クラス間で密接に関係していることを表現できる
3-3. 抽象クラスのデメリット
あくまで一部の抽象化であり、継承(extends)を行うためデメリットは継承(extends)と非常に近いです。
- サブクラスはスーパークラスの実装にある程度は依存しているので変更に弱いかもしれない
- 抽象化が正しく、抽象クラスの具象メソッドが適切であるか?
- 抽象メソッドと具象メソッドのバランス、具象クラスとの協調関係の調整が必須
- 継承の階層が深まるのは非常に危険だが、誰でも簡単にできてしまう
- 抽象クラスと具象クラスの関係性「is - a」を破ってしまう様な継承(extends)が簡単に行える
- 具体的なクラスを抽象化したクラスの関係が汎化(is - a)という
- 階層が深くなりすぎたり関係性が破られてしまうと、可読性・保守性・拡張性・再利用性は大きく損なわれる
- 単一継承といえど複雑化してしまうと、問題発生時の切り分けにも困る
- 単一継承はツリー構造で単純な構造です。対して多重継承はネットワーク構造で複雑になりがちです
- 漫然と使用するといつでもどこでも上記が起こり得る
- 継承(extends)はクラスツリー内での実行に制限があるため「組み合わせ爆発」が起こりうる(「抽象クラスとインターフェイスの違い」の項にて説明)
- 多重継承問題
- カプセル化の破壊(具象メソッドに対して)
- クラス数が増える
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 ~
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)のデメリット
- サブクラスはスーパークラスの実装に強く依存しているので変更に弱い
- 共通箇所(不変)をスーパークラスにまとめるためサブクラスがその実装に依存する
- 継承(extends)の階層が深まるのは非常に危険だが、誰でも簡単にできてしまう
- スーパークラスとサブクラスの関係性「is - a」を破ってしまう様な継承(extends)が簡単に行える
- 階層が深くなりすぎたり関係性が破られてしまうと、可読性・保守性・拡張性・再利用性は大きく損なわれる
- 単一継承といえど複雑化してしまうと、問題発生時の切り分けにも困る
- 単一継承はツリー構造で単純な構造です。対して多重継承はネットワーク構造で複雑になりがちです
- サブクラスだけでは何をしているか解らないためスーパークラスを常に参照する必要がある
- 漫然と使用するといつでもどこでも上記が起こり得る
- 継承(extends)はクラスツリー内での実行に制限があるため「組み合わせ爆発」が起こりうる(「抽象クラスとインターフェイスの違い」の項にて説明)
- 多重継承問題(後述)
- カプセル化の破壊
昔はよくトランザクションの開始、終了タイミングを揃えるよう、抽象クラスを使い、テンプレートメソッドパターンにしていたようです。しかしこれは多くのフレームワークでは、トランザクションが必要なメソッドにアノテーションを宣言すれば事足りる様になっています。
抽象クラスやインターフェースは、構築していくシステムの『概念』をより正確に定義しコードを分かりやすくするための道具です。Javaの進化と共にアノテーションで共有する概念を宣言したり、DIコンテナ等で共通する処理を注入するなど、別の手段が取れるようになってきているため、抽象クラスが登場する場面は減ってきていると言えそうです。
3-4-5. 抽象クラスとサブクラスの粒度差
例えば、人間/まぐろ/にわとり を抽象化する場合には「動物」となるような、共通した項目に置き換えることです。
上位概念に置き換えるとも言えます。サッカーとバスケットの共通項目・上位概念では「スポーツ」のことです。 「人間」→「哺乳類」→「生命」→「世界」・「地球」→「宇宙」→「神」のような連想ゲームで考えるとわかりやすいかもです。
ですが、この様なプログラミングにおいての例えとしては些か不適切だと感じています。
人間クラス/まぐろクラス/にわとりクラスが存在する場合、これらは動物クラスを継承して作ることができる
と考えてしまうと後々になって不具合を発生させることになると思います。動物クラスが神クラス化しています。スーパクラスとサブクラス間で違いがありすぎるんですね。動物クラスから、それぞれ「人間」「まぐろ」「にわとり」の間にもっと段階が必要です。パッと思いつくだけでも、動物クラスとそれぞれのサブクラス間で「哺乳類」「魚類」「鳥類」という抽象クラスが必要です。サブクラスを抽象化した時に最も近い上位概念を抽象クラスとして定義するべきです。
以下の図はイメージですが、より正確に言えば魚とマグロの間にも抽象概念が存在します。
引用:Wiki
マグロ(鮪、黒漫魚、金鎗魚、眞黒、𩻩)は、スズキ目・サバ科マグロ族マグロ属(学名:Thunnus)に分類される硬骨魚類の総称。
なので、厳密に抽象化するならば「マグロ→マグロ属→マグロ族→サバ科→スズキ目→魚」という抽象化の流れになるわけですね。これを抽象化クラスとその具象クラスで置き換えます。マグロの抽象クラスは『マグロ属』でサブクラスが「キハダマグロ」「クロマグロ」「ビンナガマグロ」になると。
3-4-6. 適切な抽象クラスは具象クラスとほぼ同じ
抽象クラスとそ具象クラスの適切な関係性は以下の様な形だと思っています。前提知識の項で使用した説明です。加えて「is - a」の関係性は必須です。また、リスコフの置換原則も守るべきでしょう。
抽象クラス『人間』を継承するサブクラス「黒色人種」「白色人種」「黄色人種」「黒褐色人種」があったときに、スーパークラスに「肌の色」という抽象メソッドがあります。サブクラスではそれを具象化し、それぞれのサブクラスで「黒色」「白色」「黄色」「黒褐色」と具象メソッドで定義するというとです。色には色の機能があるのでここではメソッドして扱っています。
こちらの話や Abstract Pattern でもわかる様に、抽象クラスと具象クラスはほとんど同じ「もの」です。対してインターフェイスは「振る舞い・性質」を表します。抽象クラスは「もの」を表します。
抽象クラス動物と具象クラス人間や犬は、実はこの考え方からすると同じだとも言えるしこの記事で言う様に同じではない、とも言えます。抽象の段階・階層は何が適切か?というのはケースバイケースなのでしょう。重要なのは、抽象クラス・具象クラス間で抽象レベルの乖離をどれほど許容するかなのではないでしょうか?解離があればあるほど具象クラスでの表現の幅は増えるでしょうが、抽象クラスが神クラス化する可能性が高くなるように思います。乖離がない場合は上記の逆になりますね。まさしく表裏一体、トレードオフです。常にベストな使い方などないのですね。
抽象クラスであるPlayer を再生機と定義します。再生機・人間・マグロ属は厳密には具体的な存在ではありません。概念でしかありません。
簡単な考え方ですが、再生機・人間・マグロ属の絵を描いてみてください。人によってきっと異なるでしょう。インスタンス化できない抽象クラスとはそういう扱いになるべきなのです。対して、具象クラスであるサブクラスの黒人や白人、キハダマグロ・ビンナガマグロを描いてくださいと言ったらほとんどの人で描かれるものは一致するはずです。
ということで抽象クラスの話はここで一旦終了になります。次は最後の第四部として抽象クラスとインターフェイスの使い分けを説明したいと思います。
最終部 「抽象クラスとインターフェイスの使い分け」