ikenox.info

Naoto Ikeno

Naoto Ikeno

Backend Engineer, Software Architecture & Design, Perl, Golang, GCP

継承と委譲の使い分けと、インターフェースのメリット

August 28, 2018

継承、委譲、インターフェースは普段特に悩むことなく使い分けができていたのですが、いざ「継承と委譲はどういった基準で使い分けているか?」「インターフェースは何が嬉しいのか?」と聞かれると意外ときちんと言語化できなかったので、この記事に書き起こして言語化を試みました。

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との接続機能だけをモックしてテストするということが困難なため、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を意識して書いてきましたが、ダックタイピングが可能な言語ではわざわざ実装と別にインターフェースクラスを定義する必要性が薄い場合もあります。
ただその場合でも、「どういう情報を公開情報としてクラス同士が通信するか」が大事という点は変わりません。

おわりに

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

Naoto Ikeno

Naoto Ikeno

Backend Engineer, Software Architecture & Design, Perl, Golang, GCP


0 Comments


0 / 1000