継承と委譲の使い分けと、インターフェースの重要性について
Tweetこの記事は、「継承と委譲はどういった性質の違いがあり、どういった基準で使い分けているか?」「インターフェースは何が嬉しいのか?」といった点に関して自分なりに言語化を試みたものです。
TL;DR
- 継承は子が親と同じ能力や責務を獲得する。委譲は子が親を単なるツールとして所有するだけで、能力や責務は同じにはならない。
- 子にとって親は単なるツールである場合は委譲を使うのが良い。継承してしまうと子が複数の責務を負うことになり、そのことによる不都合が生じる。
- 逆に、子が親と同じ責務を持つべき場合には継承を使う。委譲を使うと、子が親と同じ能力を持っていないことによる不都合が生じる。
- インターフェースを使うことで責務の分離を強制することができる。インターフェースを適切に設計することができれば、改修時の影響範囲が最低限に留まるなどシステムの保守性や堅牢性が向上し、テストの容易性も得られる。
継承と委譲の違いと使い分け
例として、「SQL データベースからユーザー情報を取り出したり保存したりする機能」を作ろうとしたときに、以下のような実装方針になったとします。
- DB の接続状態を保持したり SQL クエリを実行したりする部分のロジックは、ユーザー情報の取得に限らず、データベースから情報を取得するとき全般で使い回せそう(使いまわしたい)
- DB の操作周辺に関して使い回せそうな部分はクラスに切り出しておいて(ここでは
Database
クラスとする) 、ユーザー情報の取得や保存をするためのクラスUserRepository
からDatabase
クラスを何らかの方法で利用するようにすれば、Database
クラスの DB 操作機能を使いまわせて良い感じ
ここで、UserRepository
からDatabase
クラスを利用する方法としては、継承または委譲が思い浮かびます。
結論から言うと委譲を使ったほうが良いのですが、継承を使って以下のようにやってしまいたくなるかもしれません。
委譲を使うべき場面で継承を使った例
class Database {
void connectToDatabase(String dbHost, String dbName, ...) {
// DBへの接続ロジック
}
Result doSQL(String sql) {
// 接続しているDBにクエリを投げ、その結果を返すロジック
}
// そのほかデータベースの接続状態の管理など
}
class UserRepository extends Database {
User getUser(int userId) {
String sql = String.format("SELECT * FROM users WHERE user_id=%d", userId);
Result result = this.doSQL(sql)
// クエリの実行結果をもとにユーザーオブジェクトを作って返す
}
}
継承を使ったこの実装だと、UserRepository
はDatabase
を継承しているのでDatabase
と同等の振る舞いをできるようになります。
が、そのことによっていくつか不自然な点が生じてしまいます。
継承によって生じた歪み
継承によって、UserRepository
は「特定のユーザーを取得する」という本来持たせたかった役割の他に、Database
の持つ「DB との接続状態の保持や、DB
への SQL
クエリ送信」といった役割も獲得してしまっています。これは単一責務の原則
に反することになります。
本来 UserRepository
からすると、Database
は DB 接続のための単なるツールとして使いたいだけで、Database
自身になりたいわけではありません。
1 つのクラスが複数責務を持ってしまうとどうなるか
継承などによって 1 つのクラスが複数の責務を持ってしまうと、以下のようなデメリットが生じる場合があります。
- とある責務を期待してそのクラスを使いたいときに、期待していない機能までくっついてくることになり、クラスの再利用性が低くなる。
- 単体テストがしずらくなる。
- 今回の例だと、DB との接続機能だけをモックしてテストするということが困難1なため、UserRepository のテストのためには実際の DB を用意しなくてはいけない。
- 1
つの操作に対して複数の責務それぞれから起因する仕様が同時に絡んでくることになり、それらの責務を独立してテストできる場合に比べてテストケースが膨らむ。
m + n
ケースで良かったところが、m × n
ケースになったりする。
- 親クラスがパブリックメソッドを持つような場合、それらのメソッドは子クラスのメソッドとしても呼び出せるようになってしまう。今回の例だと、「ユーザーの取得」とは直接は関係のない
DB 操作のメソッドも
UserRepository
の利用側が使えるようになり、UserRepository
が意図とは異なる使い方をされてしまう可能性が生じる。
UserRepository
とDatabase
のように 期待される役割が異なっている 2 つのクラス間で継承関係を持つと、上記のようにいくつか不自然な点が出てしまいます。
そういう場合は代わりに委譲を使うのが適していると言えます。
委譲を使って書き直した例
Database
は先程と同じ実装のままで、UserRepository
が変更になります。
class UserRepository {
Database database;
UserRepository(Database database) {
// UserRepositoryのインスタンス生成時にDatabaseのインスタンスをセット
this.database = database;
}
User getUser(int userId) {
String sql = String.format("SELECT * FROM users WHERE user_id=%d", userId);
Result result = this.database.doSQL(sql)
// クエリの実行結果をもとにユーザーオブジェクトを作って返す
}
}
継承はせず、Database
のインスタンスを保持しておいて、必要に応じてそれを呼び出す(委譲)ようになっています。
Database
のインスタンスは、UserRepository
のオブジェクト生成時のコンストラクタなどで渡すようにします。
UserRepository
はDatabase
を継承していないのでDatabase
としては振る舞えず、DB 接続ロジックなどの実装についてもよく知りません。DB のデータ
⇔User オブジェクトの変換ロジックにのみ責任を負っています。
このように、移譲を使うことで単一責務の原則を守ることができ、前述した継承を用いた際のデメリットについても解消できます。
「期待される役割が異なっている」クラスの機能を利用したい際は、継承ではなく委譲を用いるのが適切 と言えます。
継承の使いどき
逆に「子には親と同じ役割が期待される」場合には継承が適しています。
たとえば、Car
(車)クラスとElectoricCar
(電気自動車)クラスを考えます。
Car
のほうがElectoricCar
よりも意味が広く抽象度は高いですが、Car
を利用するときもElectoricCar
を利用するときも「加速する/減速する」「窓を開ける/閉める」といった基本的な動作は共通して可能であることが期待されます。
「電気自動車を運転する」ことを、「車を運転する」と言い換えても間違いではないように、Car
とElectoricCar
に期待される役割は基本的な部分では一緒であると言えます。
このような場合に委譲を使うと以下のようになります。
継承を使うべき場面で委譲を使った例
class Car {
void SpeedUp() {
// ...
}
void OpenWindow() {
// ...
}
// ...
}
class ElectoricCar {
Car car;
void SpeedUp() {
car.SpeedUp();
}
void OpenWindow() {
car.OpenWindow();
}
// ...
// 電気自動車特有のロジック
void Charge() {
// ...
}
}
加速や減速、窓の開閉など、Car
の持っている機能の数だけElectoricCar
にボイラープレートコードが増えていきます。Car
に「ライトを点ける」動作が増えた場合にはElectoricCar
にも変更を加えなくてはいけません。新たにHybridCar
を増やそうとしたりすると目も当てられない感じになっていきます。
また、2 つのクラスは継承関係に無いので、ダックタイピングを許さないような言語だとこのままではElectoricCar
をCar
として扱うこともできません。
継承を使って書き直した例
Car
は先程と同じ実装のままで、ElectoricCar
が変更になります。
class ElectoricCar extends Car {
// 電気自動車特有のロジック
void Charge() {
// ...
}
}
当然といえば当然ですが委譲を使った際と比べて圧倒的にスッキリします。Car
に新たなメソッドが増えるなどの変更が入っても基本的にはElectoricCar
に変更を入れる必要はありません。継承関係を持っているためElectoricCar
はCar
型としても振る舞えます。
「子には親と同じ役割が期待される」場合は、委譲ではなく継承を用いるのが適切と言えます。
ここまでのまとめ
- 子に親と同じ役割が期待される場合、親と同様に振る舞えるようになる継承を使うのが良い
- 子に親と同じ役割が期待されない場合、子にとって親は単なるツールである可能性が高いため委譲を使うのが良い
インターフェースの使いどころ
UserRepository
について再び考えると、極端な話UserRepository
の利用側は、user
さえ取得できればそれで良くて、その裏側にあるのがデータベースだろうが、.txt
ファイルに保存されていようが、オンメモリだろうがなんだって良いはずです。
こういう「裏の実装は複雑な状態などを取りうるかもしれないが、使う側としては目的が達成できれば何でもいい」場合はインターフェースの出番であることが多いです。
そこで、IUserRepository
という「ユーザーを取得・保存する」役割のインターフェースを作り、UserRepository
がIUserRepository
を実装するようにしてみます。
interface IUserRepository {
User getUser(int userId);
void saveUser(User user);
}
class UserRepository implements IUserRepository {
Database database;
UserRepository(Database database) {
this.database = database;
}
User getUser(int userId) {
String sql = String.format("SELECT * FROM users WHERE user_id=%d", userId);
// ...
}
// ...
}
UserRepository
の利用側のロジックは例えば以下のようになります。
class SomeApplicationService {
IUserRepository userRepository;
void changeUserEmail(String email) {
User user = userRepository.getUser(userId);
user.setEmail(email);
userRepository.saveUser(user);
}
}
IUserRepository
はインターフェースのため、実装に関して全く言及をしていません。そのため、
IUserRepository
を利用しているSomeApplicationService
側としては、
「とにかく user が取得できることを期待しており、実装は問わない(実装の違いによってSomeApplicationService
が影響を受けることはない)」ということをコードで表明していることになります。
インターフェースは、何を公開し、何を隠すべきか?ということを明示的に定義し、それを強制させることができるというのが大きな魅力です。
インターフェースの恩恵
インターフェースIUserRepository
は、DB の接続、状態管理、難しい SQL クエリといった色々な辛さがある外部世界をフィルタリングし、「なんか知らんけど userId を投げるといい感じに user を返してくれるし、user を投げるといい感じに user を保存してくれる」という、キレイで抽象的な世界だけを見えるようにしてくれている
と言えます。
インターフェースを挟むことで、SomeApplicationService
はUserRepository
への依存から開放されています。
このことによって、もしUserRepository
の内部ロジックに何らかの大規模な変更が入ったとしても、仮に DB が MySQL から NoSQL
に変わったとしても、インターフェースが変わらない限りは、SomeApplicationService
まで連鎖的に変更が波及するようなことはありません。
この性質はテストの際にも大いに役立ちます。例えばSomeApplicationService
のテスト用にIUserRepository
を実装したインメモリのInMemoryUserRepository
を用意しておいて、テストのときだけそれに差し替えるといったことも可能となるため、よりテストが容易になります。
同じ要領で、インターフェースを使うと、たとえば「ユーザーにメールを飛ばす」といったクラスを、テストのときだけは「送っているように見えて実際には何もしない」クラスに差し替えるようなことも可能です。
インターフェースの重要性
インターフェースは、大規模な開発においては非常に重要な役割を果たします。 インターフェースだけきちんとしてればその内部実装はまぁある程度雑でも案外困らないな、というのが最近の自分の考えです。 逆に、初期にインターフェースの設計をミスると、いざ改修が必要になった時には既に手遅れになっているような場合も多く、その場合は大いなる苦しみを伴いながら作りかえを行うことになります。 インターフェースの設計がその後のシステムの保守性・堅牢性の明暗を分けると言っても過言では無いため、インターフェースを設計するときは、抽象度は本当に適切なのか?余計な情報まで受け取ったり渡したりしてしまってないか?など、慎重に考えるとよいです。
インターフェースという概念
ここまで、インターフェースと言った際になんとなく Java の interface を意識して書いてきましたが、ダックタイピングが可能な言語ではわざわざ実装と別にインターフェースクラスを定義する必要性が薄い場合もあります。また、Java の interface のような言語機能がない言語も存在するかと思います。 ただ、そういった場合でもインターフェースの重要さは変わらないと考えます。インターフェースの本質は、「どういう情報を公開情報としてクラスやオブジェクト同士がコミュニケーションを取るか」という点であり、そのことについて思考の余地がある言語であればインターフェースについて扱っていることになります。 そこにある違いは、インターフェースという概念を具現化した言語機能が明示的に存在するか否かというだけであり、インターフェースの本質的な概念はプログラミング言語にかかわらず普遍的に存在し、とても大事な概念であるという点については変わりません。
おわりに
自分なりに継承/委譲/インターフェースについて解説してみました。 わからない点や質問などあればぜひコメントいただけると嬉しいです。
Footnotes
-
実態としてはこの表現はあまり正しくなく、例えば Java なんかはテスト用のモックライブラリを使うと好き勝手にメソッドをモックできるのであまり困らない。 ↩