継承と委譲の使い分けと、インターフェースの重要性について

この記事は、「継承と委譲はどういった性質の違いがあり、どういった基準で使い分けているか?」「インターフェースは何が嬉しいのか?」といった点に関して自分なりに言語化を試みたものです。

TL;DR

  • 継承は子が親と同じ能力や責務を獲得する。委譲は子が親を単なるツールとして所有するだけで、能力や責務は同じにはならない。
    • 子にとって親は単なるツールである場合は委譲を使うのが良い。継承してしまうと子が複数の責務を負うことになり、そのことによる不都合が生じる。
    • 逆に、子が親と同じ責務を持つべき場合には継承を使う。委譲を使うと、子が親と同じ能力を持っていないことによる不都合が生じる。
  • インターフェースを使うことで責務の分離を強制することができる。インターフェースを適切に設計することができれば、改修時の影響範囲が最低限に留まるなどシステムの保守性や堅牢性が向上し、テストの容易性も得られる。

継承と委譲の違いと使い分け

例として、「SQL データベースからユーザー情報を取り出したり保存したりする機能」を作ろうとしたときに、以下のような実装方針になったとします。

  1. DB の接続状態を保持したり SQL クエリを実行したりする部分のロジックは、ユーザー情報の取得に限らず、データベースから情報を取得するとき全般で使い回せそう(使いまわしたい)
  2. 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)
    // クエリの実行結果をもとにユーザーオブジェクトを作って返す
  }
}

継承を使ったこの実装だと、UserRepositoryDatabaseを継承しているのでDatabaseと同等の振る舞いをできるようになります。 が、そのことによっていくつか不自然な点が生じてしまいます。

継承によって生じた歪み

継承によって、UserRepositoryは「特定のユーザーを取得する」という本来持たせたかった役割の他に、Databaseの持つ「DB との接続状態の保持や、DB への SQL クエリ送信」といった役割も獲得してしまっています。これは単一責務の原則 に反することになります。 本来 UserRepositoryからすると、Databaseは DB 接続のための単なるツールとして使いたいだけで、Database自身になりたいわけではありません。

1 つのクラスが複数責務を持ってしまうとどうなるか

継承などによって 1 つのクラスが複数の責務を持ってしまうと、以下のようなデメリットが生じる場合があります。

  • とある責務を期待してそのクラスを使いたいときに、期待していない機能までくっついてくることになり、クラスの再利用性が低くなる。
  • 単体テストがしずらくなる。
    • 今回の例だと、DB との接続機能だけをモックしてテストするということが困難1なため、UserRepository のテストのためには実際の DB を用意しなくてはいけない。
    • 1 つの操作に対して複数の責務それぞれから起因する仕様が同時に絡んでくることになり、それらの責務を独立してテストできる場合に比べてテストケースが膨らむ。m + n ケースで良かったところが、m × nケースになったりする。
  • 親クラスがパブリックメソッドを持つような場合、それらのメソッドは子クラスのメソッドとしても呼び出せるようになってしまう。今回の例だと、「ユーザーの取得」とは直接は関係のない DB 操作のメソッドもUserRepositoryの利用側が使えるようになり、UserRepositoryが意図とは異なる使い方をされてしまう可能性が生じる。

UserRepositoryDatabaseのように 期待される役割が異なっている 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のオブジェクト生成時のコンストラクタなどで渡すようにします。

UserRepositoryDatabaseを継承していないのでDatabaseとしては振る舞えず、DB 接続ロジックなどの実装についてもよく知りません。DB のデータ ⇔User オブジェクトの変換ロジックにのみ責任を負っています。 このように、移譲を使うことで単一責務の原則を守ることができ、前述した継承を用いた際のデメリットについても解消できます。 「期待される役割が異なっている」クラスの機能を利用したい際は、継承ではなく委譲を用いるのが適切 と言えます。

継承の使いどき

逆に「子には親と同じ役割が期待される」場合には継承が適しています。

たとえば、Car(車)クラスとElectoricCar(電気自動車)クラスを考えます。 CarのほうがElectoricCarよりも意味が広く抽象度は高いですが、Carを利用するときもElectoricCar を利用するときも「加速する/減速する」「窓を開ける/閉める」といった基本的な動作は共通して可能であることが期待されます。 「電気自動車を運転する」ことを、「車を運転する」と言い換えても間違いではないように、CarElectoricCar に期待される役割は基本的な部分では一緒であると言えます。

このような場合に委譲を使うと以下のようになります。

継承を使うべき場面で委譲を使った例

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 つのクラスは継承関係に無いので、ダックタイピングを許さないような言語だとこのままではElectoricCarCarとして扱うこともできません。

継承を使って書き直した例

Carは先程と同じ実装のままで、ElectoricCarが変更になります。

class ElectoricCar extends Car {

  // 電気自動車特有のロジック
  void Charge() {
    // ...
  }
}

当然といえば当然ですが委譲を使った際と比べて圧倒的にスッキリします。Carに新たなメソッドが増えるなどの変更が入っても基本的にはElectoricCar に変更を入れる必要はありません。継承関係を持っているためElectoricCarCar型としても振る舞えます。 「子には親と同じ役割が期待される」場合は、委譲ではなく継承を用いるのが適切と言えます。

ここまでのまとめ

  • 子に親と同じ役割が期待される場合、親と同様に振る舞えるようになる継承を使うのが良い
  • 子に親と同じ役割が期待されない場合、子にとって親は単なるツールである可能性が高いため委譲を使うのが良い

インターフェースの使いどころ

UserRepositoryについて再び考えると、極端な話UserRepositoryの利用側は、user さえ取得できればそれで良くて、その裏側にあるのがデータベースだろうが、.txtファイルに保存されていようが、オンメモリだろうがなんだって良いはずです。 こういう「裏の実装は複雑な状態などを取りうるかもしれないが、使う側としては目的が達成できれば何でもいい」場合はインターフェースの出番であることが多いです。 そこで、IUserRepositoryという「ユーザーを取得・保存する」役割のインターフェースを作り、UserRepositoryIUserRepository を実装するようにしてみます。

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 を保存してくれる」という、キレイで抽象的な世界だけを見えるようにしてくれている と言えます。

インターフェースを挟むことで、SomeApplicationServiceUserRepositoryへの依存から開放されています。 このことによって、もしUserRepositoryの内部ロジックに何らかの大規模な変更が入ったとしても、仮に DB が MySQL から NoSQL に変わったとしても、インターフェースが変わらない限りは、SomeApplicationServiceまで連鎖的に変更が波及するようなことはありません。

この性質はテストの際にも大いに役立ちます。例えばSomeApplicationServiceのテスト用にIUserRepository を実装したインメモリのInMemoryUserRepository を用意しておいて、テストのときだけそれに差し替えるといったことも可能となるため、よりテストが容易になります。 同じ要領で、インターフェースを使うと、たとえば「ユーザーにメールを飛ばす」といったクラスを、テストのときだけは「送っているように見えて実際には何もしない」クラスに差し替えるようなことも可能です。

インターフェースの重要性

インターフェースは、大規模な開発においては非常に重要な役割を果たします。 インターフェースだけきちんとしてればその内部実装はまぁある程度雑でも案外困らないな、というのが最近の自分の考えです。 逆に、初期にインターフェースの設計をミスると、いざ改修が必要になった時には既に手遅れになっているような場合も多く、その場合は大いなる苦しみを伴いながら作りかえを行うことになります。 インターフェースの設計がその後のシステムの保守性・堅牢性の明暗を分けると言っても過言では無いため、インターフェースを設計するときは、抽象度は本当に適切なのか?余計な情報まで受け取ったり渡したりしてしまってないか?など、慎重に考えるとよいです。

インターフェースという概念

ここまで、インターフェースと言った際になんとなく Java の interface を意識して書いてきましたが、ダックタイピングが可能な言語ではわざわざ実装と別にインターフェースクラスを定義する必要性が薄い場合もあります。また、Java の interface のような言語機能がない言語も存在するかと思います。 ただ、そういった場合でもインターフェースの重要さは変わらないと考えます。インターフェースの本質は、「どういう情報を公開情報としてクラスやオブジェクト同士がコミュニケーションを取るか」という点であり、そのことについて思考の余地がある言語であればインターフェースについて扱っていることになります。 そこにある違いは、インターフェースという概念を具現化した言語機能が明示的に存在するか否かというだけであり、インターフェースの本質的な概念はプログラミング言語にかかわらず普遍的に存在し、とても大事な概念であるという点については変わりません。

おわりに

自分なりに継承/委譲/インターフェースについて解説してみました。 わからない点や質問などあればぜひコメントいただけると嬉しいです。

Footnotes

  1. 実態としてはこの表現はあまり正しくなく、例えば Java なんかはテスト用のモックライブラリを使うと好き勝手にメソッドをモックできるのであまり困らない。