8
10

継承よりインターフェース・委譲を使ったほうがいい話

Last updated at Posted at 2024-08-16

はじめに

この記事は僕が技術本や記事で得た知見の理解を深める目的で作成しました。
間違っている箇所や、感想等ありましたらコメントいただけると嬉しいです。

対象読者層

  • 抽象クラス・インターフェースは知っているけど、使い分けがわからない
  • 委譲の仕組みは何となく知っているけど、具体的にどう使えばいいかわからない

結論

可能な限り、抽象クラスの継承は避ける。
インターフェース・委譲を積極的に使おう。
どうしても継承を使うなら、慎重に設計しよう。

継承のNGパターン

はじめに、継承を用いたNGパターンを見てみましょう。

実装例

以下のサンプルコードは決済処理を表現したコードになります。

クラス図

各クラスの役割は以下の通りです。

PaymentProcessorクラス
決済処理を行う抽象クラスです。
CreditCardPaymentクラス
クレジットカード決済を行う具象クラスです。
BarCodePaymentクラス
バーコード決済を行う具象クラスです。
Appクラス
メインクラスに相当します。決済処理を呼び出す側のクラスです。

サンプルコード

PaymentProcessor.java
/**
 * 決済処理の抽象クラス
 */
public abstract class PaymentProcessor {

    /**
     * 決済処理とポイント付与を行う
     *
     * @param amount 金額
     */
    public void payment(int amount) {
        // 決済を行う
        processPayment(amount);

        // ポイント付与
        double point = amount * (0.5 / 100.0);
        addPoints((int)point);
    }

    /**
     * ポイント付与処理
     *
     * @param points 付与するポイント
     */
    protected void addPoints(int points) {
        System.out.println(points + "ポイントが付与されました。");
        // 具体的な処理は省略
    }

    /**
     * 実際の決済処理 具体的な実装は具象クラスに任せる
     *
     * @param amount 金額
     */
    public abstract void processPayment(int amount);

}
CreditCardPayment.java
/**
 * クレジットカード決済を行うクラス
 */
public class CreditCardPayment extends PaymentProcessor {

    /**
     * クレジットカード決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をクレジットカードで決済するよ.");
        // 具体的な処理は省略
    }

}
BarCodePayment.java
/**
 * バーコード決済を行うクラス
 */
public class BarCodePayment extends PaymentProcessor {

    /**
     * バーコード決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をバーコードで決済するよ.");
        // 具体的な処理は省略
    }

}
App.java
import Payment.BarCodePayment;
import Payment.CreditCardPayment;
import Payment.PaymentProcessor;

/**
 * メインクラスです
 */
public class App {
    public static void main(String[] args) throws Exception {
        // 各決済処理を行うインスタンスを生成
        PaymentProcessor creditPay = new CreditCardPayment();
        PaymentProcessor barCodePay = new BarCodePayment();

        // 決済処理実行
        creditPay.payment(1000);
        barCodePay.payment(1000);
    }

}
実行結果
1000円をクレジットカードで決済するよ.
5ポイントが付与されました。
1000円をバーコードで決済するよ.
5ポイントが付与されました。

サンプルコードの説明

Mainクラスは、PaymentProcessorクラスを継承した具象クラスのインスタンスを生成しています。
決済処理を行うときは、親クラスであるPaymentProcessorクラスのpayment()メソッドを呼び出して決済処理を行います。

決済処理を担っているPaymentProcessor.payment()の流れは以下です。

  1. 決済処理の実施
  2. ポイントの付与

決済処理の実施は決済方法によって異なるため、具象クラスに処理を任せます。
ポイントの付与は、どの決済方法でも同じポイント付与率(ここでは0.5%)と仮定します。
ポイント付与処理は、親クラスであるPaymentProcessorクラスで処理を行っています。

継承を用いて、親クラスにロジックを共通化したことによる弊害

ここでポイント付与の仕様が「バーコード決済の場合のみポイント付与率を1%」に変更された場合、どのようにコードを変更するべきでしょうか?

以下の部分の処理を0.5 → 1 に変更したいですが、
そうすると、クレジットカード決済でも1%付与が適用されてしまいます。
※親クラスのロジック修正が、具象クラス全てに影響してしまう

PaymentProcessor.java
// ポイント付与
double point = amount * (0.5 / 100.0);

具象クラス側でメソッドをオーバーライドする

では、BarCodePaymentクラス側で、payment()メソッドをオーバーライドするのはどうでしょうか?

CreditCardPayment.java
/**
 * バーコード決済を行うクラス
 */
public class BarCodePayment extends PaymentProcessor {

    /**
     * バーコード決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をバーコードで決済するよ.");
        // 具体的な処理は省略
    }

    /**
     * バーコード決済のみポイント付与率1%を適用したいため、オーバーライド
     */
    @Override
    public void payment(int amount) {
        // 決済を行う
        processPayment(amount);

        // ポイント付与
        double point = amount * (1.0 / 100.0);
        super.addPoints((int)point);
    }

}
実行結果
1000円をクレジットカードで決済するよ.
5ポイントが付与されました。
1000円をバーコードで決済するよ.
10ポイントが付与されました。

一応、バーコード決済のみ1%ポイント付与を適用できました。
しかし、ポイント付与の計算処理以外は同じロジックになっています。

さらに今後、以下のように仕様が変更になった場合どうなるでしょうか?

  • 決済方法に、ネットバンク決済が追加される
  • ネットバンク決済でも、1%のポイント付与を行う

ネットバンク決済を行う具象クラスを作成してPaymentProcessorクラスを継承させますが、
ポイント付与率が親クラスの処理とは異なるため、payment()クラスをオーバーライドしてあげる必要があります。

例 InternetBankPayment.java
/**
 * ネットバンク決済を行うクラス
 */
public class InternetBankPayment extends PaymentProcessor {

    /**
     * インターネットバンク決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をネットバンクで決済するよ.");
        // 具体的な処理は省略
    }

    @Override
    public void payment(int amount) {
        // 処理内容は、バーコード決済と全く同じ
        // 決済を行う
        processPayment(amount);

        // ポイント付与
        double point = amount * (1.0 / 100.0);
        super.addPoints((int)point);
    }

}

つまり、親クラスとほぼ同じコードが、具象クラス側で増殖していく可能性があります。

最悪のケース 親クラス側に条件分岐を追加する

一番悪手となるケースです。

PaymentProcessor.java

/**
 * 決済処理の抽象クラス
 */
public abstract class PaymentProcessor {

    /**
     * 決済処理とポイント付与を行う
     *
     * @param amount 金額
     */
    public void payment(int amount) {
        // 決済を行う
        processPayment(amount);

        // ポイント付与
        double point = 0;
        if (this instanceof BarCodePayment) {
            point = amount * (1.0 / 100.0);
        } else {
            point = amount * (0.5 / 100.0);
        }

        addPoints((int) point);
    }
    // 以下省略
}

バーコード決済であるか? の判定を 親クラス側でinstanceof を使って判定しています。

そもそも、継承の特徴は親クラスとは異なる振る舞いを具象クラス側で行うことです。
上記のコードでは親が子供の状態に関心を待たなければならず、密結合の状態を作り出してしまっています。

また、他の決済手段を実装する時(新しい具象クラスの追加)に instanceof の条件分岐を追加しなければなりません。

つまり、具象クラスの追加・変更が発生する度に親クラスも修正が必要な状態になってしまっているのです。

継承は非常に慎重な設計が求められる

一度定義した親クラスの修正は、慎重に行う必要があります。
例えば、親クラスに新しい機能(メソッド)を追加するとします。
親クラスに追加した機能は、具象クラスでも使用できます。

もしかすると、一部の具象クラスでは必要ない機能になる可能性も出てきます。
そうなると、わざわざ具象クラス側でオーバーライドして何も処理を書かない といった煩雑な実装をしなければなりません。

一部の具象クラス
    /**
     * hogehogeを行う
     */
    public void newMethod() {
        // このクラスでは動作させたくないため何も処理を書かない
    }

上記のコードのような設計をしてしまうと、密結合やロジックの混乱といった多くの問題を抱えることになってしまいます。

継承は変更に強くするための手法ですが、設計が非常に難しい手法でもあります。

インターフェース・委譲を使って解決する

ここからは、継承で親クラス依存による密結合を解消するために、インターフェース・委譲を使ってリファクタリングを行います。

リファクタリング

以下は、継承による親子関係をリファクタリングしたクラス図・サンプルコードです。

クラス図

各クラスの役割は以下の通りです。

PaymentStrategyインターフェース(New)
決済処理機能とポイント付与率を取得する機能を提供するインターフェースです。
PaymentProcessorクラス(New)
決済処理を行う委譲クラスです。具体的な処理内容はコンストラクターの引数で受け取ったインスタンスに任せます。
CreditCardPaymentStrategyクラス(変更)
クレジットカード決済を行うクラスです。PaymentStrategyインターフェースを実装しています。
BarCodePaymentStrategyクラス(変更)
バーコード決済を行うクラスです。PaymentStrategyインターフェースを実装しています。

サンプルコード

PaymentStrategy.java
/**
 * 決済機能とポイント付与率算出を提供するインタフェース
 */
public interface PaymentStrategy {
    /**
     * 決済処理を行う
     *
     * @param amount 金額
     */
    void processPayment(int amount);

    /**
     * ポイント付与率を取得する
     *
     * @return ポイント付与率
     */
    double getPointRate();
}
PaymentProcessor.java
/**
 * 決済処理の委譲クラス
 */
public class PaymentProcessor {
    private final PaymentStrategy paymentStrategy;

    /**
     * コンストラクター
     *
     * @param paymentStrategy paymentStrategyを実装したクラスのインスタンス
     */
    public PaymentProcessor(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    /**
     * 決済処理とポイント付与を行う
     *
     * @param amount 金額
     */
    public void payment(int amount) {
        // 決済を行う
        paymentStrategy.processPayment(amount);

        // ポイント付与
        double point = amount * paymentStrategy.getPointRate();
        addPoints((int) point);
    }

    /**
     * ポイント付与処理
     *
     * @param points 付与するポイント
     */
    private void addPoints(int points) {
        System.out.println(points + "ポイントが付与されました。");
        // 具体的な処理は省略
    }
CreditCardPaymentStrategy.java
/**
 * クレジットカード決済を行うクラス
 */
public class CreditCardPaymentStrategy implements PaymentStrategy {

    /**
     * バーコード決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をクレジットカードで決済するよ.");
        // 具体的な処理は省略
    }

     /**
     * ポイント付与率を返します
     */
    @Override
    public double getPointRate() {
        return 0.5 / 100.0;
    }
}
BarCodePaymentStrategy.java
/**
 * バーコード決済を行うクラス
 */
public class BarCodePaymentStrategy implements PaymentStrategy {

    /**
     * バーコード決済処理
     */
    @Override
    public void processPayment(int amount) {
        System.out.println(amount + "円をバーコードで決済するよ.");
        // 具体的な処理は省略
    }

    /**
     * ポイント付与率を返します
     */
    @Override
    public double getPointRate() {
        return 1.0 / 100.0;
    }
}
App.java
import payment.BarCodePaymentStrategy;
import payment.CreditCardPaymentStrategy;
import payment.PaymentProcessor;

public class App {
    public static void main(String[] args) throws Exception {
        // 各決済処理を行うインスタンスを生成
        PaymentProcessor creditCard = new PaymentProcessor(new CreditCardPaymentStrategy());
        PaymentProcessor barCode = new PaymentProcessor(new BarCodePaymentStrategy());

        // 決済処理実行
        creditCard.payment(1000);
        barCode.payment(1000);
    }
}
実行結果
1000円をクレジットカードで決済するよ.
5ポイントが付与されました。
1000円をバーコードで決済するよ.
10ポイントが付与されました。

変更点①-親クラスに定義していたメソッド郡をインターフェースとして抽出

まず初めに、親クラスで定義していた決済機能(processPayment()メソッド)をインターフェースとして抽出しました。
合わせてポイント付与率算出部分(getPointRate()メソッド)も、抽出しました。
これにより、PaymentStrategyインターフェースを実装したクラスは決済機能、ポイント付与率算出 処理を自前で実装する必要があります。

インターフェースを採用することで、継承と同じく異なる振る舞いをすることができます。

変更点②-決済処理+ポイント付与を行う委譲クラスを追加

PaymentProcessorクラスは、決済処理とポイント付与の責任を持つクラスです。
ここでポイントとなるのは、決済処理・ポイント付与率の算出を、
引数で受け取ったprocessPaymentインスタンスに任せているところになります。

ざっくり説明すると、
PaymentProcessorクラスは「決済の手順は知っているけど、具体的な決済処理・ポイント付与率算出については関心を持たない」状態になります。

仮にPaymentProcessorクラスに修正が入ったとしても、PaymentStrategyインターフェースを実装したクラスには何の影響もありません。

継承では、親クラスのロジックを修正すると具象クラスに影響が出ていました。
委譲を採用することで、各クラス間の結合度がゆるくなっています。

PaymentProcessor.java
 /**
     * 決済処理とポイント付与を行う
     *
     * @param amount 金額
     */
    public void payment(int amount) {
        // 決済を行う(具体的に何をやっているか、PaymentProcessorクラスは知らない)
        paymentStrategy.processPayment(amount);

        // ポイント付与(具体的に何をやっているか、PaymentProcessorクラスは知らない)
        double point = amount * paymentStrategy.getPointRate();
        addPoints((int) point);
    }

デザインパターンである「Strategyパターン」を採用しています。
この記事では、詳しい説明は省略します。

バーコード決済のポイント付与率を0.5%に戻すにはどうすればいいか?

ここで、「バーコード決済のポイント付与率を0.5%に戻したい」という変更を入れるにはどうすればよいでしょうか?
安直に修正するのであれば、BarCodePaymentStrategyでオーバーライドしているgetPointRate()メソッドの処理を以下のように変更すれば要求を満たすことはできます。

BarCodePaymentStrategy.java
    /**
     * ポイント付与率を返します
     */
    @Override
    public double getPointRate() {
        // 1.0 → 0.5に戻す
        return 0.5 / 100.0;
    }

しかし、0.5 / 100.0; という計算処理は、CreditCardPaymentStrategyクラスにも存在しています。
これでは、同じコードが重複してしまいます。

インターフェース・移譲を使って処理の共通化を行う

この場合も、インターフェース・委譲を用いてコードの重複を排除することができます。

クラス図

ポイント付与率取得部分処理を、新しく切り出してインターフェースとして定義します。

PointRateStrategyインターフェース
ポイント付与率を取得する機能を提供するインターフェースです。
NomalPointRateクラス
通常のポイント取得率を持つクラスです。PointRateStrategyインターフェースを実装しています。

サンプルコード

PointRateStrategy.java
/**
 * ポイント付与率算出を提供するインターフェース
 */
public interface PointRateStrategy {
    /**
     * ポイント付与率を取得する
     *
     * @return ポイント付与率
     */
    double getPointRate();
}
PointRateStrategy.java
/**
 * 通常のポイント付与率クラス
 */
public class NomalPointRate implements PointRateStrategy {
    /**
     * ポイント付与率を返します
     *
     * @return ポイント付与率
     */
    @Override
    public double getPointRate() {
        return 0.5 / 100.0;
    }
}

これにより、ポイント付与率の算出処理を委譲するための準備ができました。
今度は、PaymentProcessorクラス側でポイント付与率の算出を委譲するように修正します。

PaymentProcessor.java
/**
 * 決済処理の委譲クラス
 */
public class PaymentProcessor {
    private final PaymentStrategy paymentStrategy;
    private final PointRateStrategy pointRateStrategy;

    /**
     * コンストラクター
     *
     * @param paymentStrategy   PaymentStrategyを実装したクラス
     * @param pointRateStrategy PointRateStrategyを実装したクラス
     */
    public PaymentProcessor(PaymentStrategy paymentStrategy,
            PointRateStrategy pointRateStrategy) {
        this.paymentStrategy = paymentStrategy;
        this.pointRateStrategy = pointRateStrategy;
    }

    /**
     * 決済処理とポイント付与を行う
     *
     * @param amount 金額
     */
    public void payment(int amount) {
        // 決済を行う
        paymentStrategy.processPayment(amount);

        // ポイント付与(具体的なポイント付与率はPointRateStrategyに任せる)
        double point = amount * pointRateStrategy.getPointRate();
        addPoints((int) point);
    }

  // 以下省略
}

PaymentProcessorクラスは以下のように変更しました。

  • コンスタクターの引数にPointRateStrategyインターフェースを実装したクラスのインスタンスを追加
  • ポイント付与率の取得処理は、コンストラクターで受け取ったPointRateStrategyインターフェースに任せるように修正

最後に、PaymentStrategyインターフェースで定義していたgetPointRate()メソッドは参照されなくなったため削除します。

PointRateStrategy.java
/**
 * 決済機能を提供するインターフェース
 */
public interface PaymentStrategy {
    /**
     * 決済処理を行う
     *
     * @param amount 金額
     */
    void processPayment(int amount);

    // getPointRate()メソッドを削除。PaymentStrategyインターフェースは決済処理のみ提供する
}

最終的に、呼び出し元のAppクラスでは以下のようにすることでポイント付与率を0.5%適用することができます。

App.java
import payment.BarCodePaymentStrategy;
import payment.CreditCardPaymentStrategy;
import payment.PaymentProcessor;
import payment.PointRate.NomalPointRate;

public class App {
    public static void main(String[] args) throws Exception {
        // 各決済処理を行うインスタンスを生成
        PaymentProcessor creditCard = new PaymentProcessor(
                new CreditCardPaymentStrategy(),
                new NomalPointRate());
        PaymentProcessor barCode = new PaymentProcessor(
                new BarCodePaymentStrategy(),
                new NomalPointRate());

        // 決済処理実行
        creditCard.payment(1000);
        barCode.payment(1000);
    }
}
実行結果
1000円をクレジットカードで決済するよ.
5ポイントが付与されました。
1000円をバーコードで決済するよ.
5ポイントが付与されました。

インターフェース・委譲は、仕様変更にも柔軟に対応できる

仮に、新しい決済方法・新しいポイント付与率 が追加になった場合どうなるでしょうか?

例:以下の仕様を追加する場合で考えてみます。

  • ネットバンキング決済
  • ポイント付与率が2.5%(夏季限定)

サンプルコードは省略しますが、以下の手順で実現できます。

  1. PaymentStrategyインターフェースを実装した、新しい決済クラスを作成する
  2. PointRateStrategyインターフェースを実装した、新しいポイント付与率を返すクラスを作成する
  3. 呼び出し側(Appクラス)で1.、2.のクラスインスタンスを生成してPaymentProcessorクラスのコンストラクターに渡す
  4. PaymentProcessor.payment()メソッドを呼び出す
App.java
import payment.BarCodePaymentStrategy;
import payment.CreditCardPaymentStrategy;
import payment.PaymentProcessor;
import payment.PointRate.NomalPointRate;

public class App {
    public static void main(String[] args) throws Exception {
        // インターネットバンク決済処理を行うインスタンスを生成
        // SummerPointRateクラスは2.5%のポイント付与率を返す作りになっている
        PaymentProcessor internetBank = new PaymentProcessor(
                new InternetBankPaymentStrategy(),
                new SummerPointRate());

        // 決済処理実行
        internetBank.payment(1000);
    }
}

どうでしょうか?
継承と比較して作成するクラスは増えましたが、かなり柔軟に対応できそうです。
今後、決済手段 ✕ ポイント付与率 のパターンが増えたとしても、PaymentProcessorクラスのコンストラクターに渡す引数を変えるだけで簡単に実現できます。

既存のクラスを修正するのではなく、新しいクラスを追加する事で既存機能への影響を抑えることができます。

更に、将来決済手順が変わってPaymentProcessor.payment()メソッド内の処理が変更になっても、他のクラスには影響ありません。
継承のような依存関係ではないため、コード修正による影響範囲が抑えられるのです。

結論 - 継承より、インターフェース・委譲を使おう

  • 継承を利用すると、クラス間が密結合になり安易にコード修正ができなくなります
  • 継承でできそうなところは、よほどのことがない限りインターフェース・委譲を使用するように検討しましょう
  • インターフェース・継承を使用することで、仕様変更に対し安全かつ柔軟に対応できるようになります
8
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
10