Anti-OOP : ifを回避する方法

開発を進めていくと、if-else構文やswitch-case構文を本当に多く使用します。論理的思考に基づくと、分岐処理は必須領域であり有用です。しかし、分岐処理は手続き型プログラミングのレガシーとなり、オブジェクト指向の考え方を妨害する要因の1つになりました。

我々が開発でよく考えていることは「可能な限り、分岐処理をなくさなければならない。」というものです。単純な反復分岐を最小化しようと努力すると、その対価としてオブジェクト指向の考え方とコードをナレッジとして得ることができます。

さらに一般化すると、DRY(重複排除)を守る – boiler plateしたコードを減らす – 努力をすると、その答えの1つであるオブジェクト指向の考え方を得られます。

ショッピングモール割引ポリシー

多くの方が理解しやすいショッピングモールドメインでサンプルを作ってみました。
同じ種類の分岐処理がコードのあちこちに重複しているのに、処理するドメインロジックは若干異なることが多いです。
次のようなコードを実際によく見かけます。下記は、割引ポリシーを分岐で処理するコードです。

public class PaymentService {
    // リアルタイム割引内訳を確認
    public Discount getDiscount(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;

        // 割引金額
        long discountAmt = 0;
        if ("Amazon".equals(discountCode)) {   // アマゾン割引
            discountAmt = productAmt * 0.1;
        } else if ("Rakuten".equals(discountCode)) { // 楽天市場割引
            discountAmt = productAmt * 0.15;
        } else if ("Yahoo".equals(discountCode)) {  // ヤフーショッピング割引
            if (productAmt < 1000)  // 割引クーポンの金額より少ない場合
                discountAmt = productAmt;
            else
                discountAmt = 1000;
        }
        return Discount.of(discountAmt, ...);
    }

    // 決済処理
    public void payment(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;

        // 割引金額
        long paymentAmt = 0;
        if ("Amazon".equals(discountCode)) {   // アマゾン割引
            paymentAmt = productAmt * 0.9;
        } else if ("Rakuten".equals(discountCode)) { // 楽天市場割引
            paymentAmt = productAmt * 0.85;
        } else if ("Yahoo".equals(discountCode)) {  // ヤフーショッピング割引
            if (productAmt < 1000)  // 割引クーポンの金額より少ない場合
                paymentAmt = 0;
            else
                paymentAmt = productAmt - 1000;
        } else {
            paymentAmt = productAmt;
        }
        ...
    }
    ...

同じタイプの分岐処理は、このメソッドのあちこちに点在します。
このようなコードの場合、一般的に重複排除のためメソッド抽出からリファクタリングをします。

Step 1. メソッドの抽出

public class PaymentService {
    // リアルタイム割引内訳確認
    public Discount getDiscount(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;

        // 割引金額
        long discountAmt = getDiscountAmt(discountCode, productAmt);
        ...
    }

    // 決済処理
    public void payment(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;

        // 決済金額
        long paymentAmt = productAmt - getDiscountAmt(discountCode, productAmt);
        ...
    }

    private long getDiscountAmt(String discountCode, long productAmt) {
        long discountAmt = 0;
        if ("Amazon".equals(discountCode)) {   // アマゾン割引
            discountAmt = productAmt * 0.1;
        } else if ("Rakuten".equals(discountCode)) { // 楽天市場割引
            discountAmt = productAmt * 0.15;
        } else if ("Yahoo".equals(discountCode)) {  // ヤフーショッピング割引
            if (productAmt < 1000)  // 割引クーポンの金額より少ない場合
                discountAmt = productAmt;
            else
                discountAmt = 1000;
        }
        return discountAmt;
    }
    ...

getDiscountAmtメソッド抽出を行うだけで、コードの重複が削除され行数を少なくすることができます。
上記のようなメソッド抽出は、既存の手続き型プログラミングで可能なリファクタリング手法です。重要なのはgetDiscountAmtを「割引ポリシーの分岐を決済サービスオブジェクトの中に置くのが正しいか」ということです。割引と決済は分離した方がよいでしょう。

2つの関係をより具体的に見てみると、ショッピングモールのドメイン上の割引と決済が関連(Association)付けられています。

また、このオブジェクトだけでなく、他のオブジェクトで割引ロジックを使用する際、getDiscountAmtメソッドが重複して発生します。if-elseも同じく重複しています。

ここで抽象化思考が必要になります。現在のサンプルで分離するドメインロジックは割引であり、割引ロジックのコアは、割引金額を求めるものと言えるでしょう。つまりgetDiscountAmtは分離することが重要な行為(メソッド)になり、抽象化させる(Interface)のです。

割引(割引金額を求めること)の抽象化をもとに、インターフェースを抽出します。各割引ポリシーが1つのクラスに分離されるイメージです。これらのリファクタリング手法をインターフェース抽出と呼びます。

基本的にクラス抽出後、ポリモーフィズムが求められる場合は、インターフェース抽出を行います。クラス抽出は強結合であり、”SRP”(単一責任の原則)は満たしますが、より重要な”OCP”(開放閉鎖の原則)は満たされないためです。

オブジェクト指向5原則SOLID:https://en.wikipedia.org/wiki/SOLID

Step 2. 重要なインターフェースを抽出

public interface Discountable {
    /** 割引なし */
    Discountable NONE = new Discountable() {
        @Override
        public long getDiscountAmt(long originAmt) {
            return 0;
        }
    };

    long getDiscountAmt(long originAmt);
}

class AmazonDiscountPolicy implements Discountable {
    @Override
    public long getDiscountAmt(long originAmt) {
        return originAmt * 0.1;
    }
}

class RakutenDiscountPolicy implements Discountable {
    @Override
    public long getDiscountAmt(long originAmt) {
        return originAmt * 0.15;
    }
}

class YahooDiscountPolicy implements Discountable {
    private long discountAmt = 1000L;

    @Override
    public long getDiscountAmt(long originAmt) {
        if (originAmt < discountAmt)
            return originAmt;
        return discountAmt;
    }
}
public class PaymentService {
    // リアルタイムでの割引内訳を確認
    public Discount getDiscount(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;
        // 割引ポリシー
        Discountable discountPolicy = getDiscounter(discountCode);

        // 割引金額
        long discountAmt = discountPolicy.getDiscountAmt(productAmt);
        ...
    }

    // 決済処理
    public void payment(...) {
        // 商品金額
        long productAmt = ...;
        // 割引コード(Amazon:アマゾン-10%, Rakuten:楽天市場-15% Yahoo:ヤフーショッピング-1000円)
        String discountCode = ...;
        // 割引ポリシー
        Discountable discountPolicy = getDiscounter(discountCode);

        // 決済金額
        long paymentAmt = productAmt - discountPolicy.getDiscountAmt(productAmt);
        ...
    }

    private Discountable getDiscounter(String discountCode) {
        if ("Amazon".equals(discountCode)) {   // アマゾン割引
            return new AmazonDiscountPolicy();
        } else if ("Rakuten".equals(discountCode)) { // 楽天市場割引
            return new RakutenDiscountPolicy();
        } else if ("Yahoo".equals(discountCode)) {  // ヤフーショッピング割引
            return new YahooDiscountPolicy();
        } else {
            return Discountable.NONE;
        }
    }
    ...

インタフェースを適切に抽出しましたが、ファクトリーメソッドは、当該オブジェクト内にあるので、まだクライアント(PaymentService)オブジェクトと割引構想クラスが強結合状態になっています。現在の状態ではメソッド抽出時点と比較して改善された点はなく、リファクタリングがまだ不足しています。

getDiscounter(String)のファクトリーメソッド、つまり割引ポリシーを作成するメソッドも分離が必要です。以前と同様、ここで分離するのは、割引ポリシーの作成です。
ポリシー作成の役割を持つファクトリーに分離します。

Step 2-2. Factory

/** 割引生成ファクトリー */
public interface DiscounterFactory {
    Discountable getDiscounter(String discountName);
}

public class SimpleDiscounterFactory implements DiscountFactory {
    @Override
    Discountable getDiscounter(String discountName) {
        if ("Amazon".equals(discountCode)) {   // アマゾン割引
            return new AmazonDiscountPolicy();
        } else if ("Rakuten".equals(discountCode)) { // 楽天市場割引
            return new RakutenDiscountPolicy();
        } else if ("Yahoo".equals(discountCode)) {  // ヤフーショッピング割引
            return new YahooDiscountPolicy();
        } else {
            return Discountable.NONE;
        }
    }
}
    DiscounterFactory discounterFactory = new SimpleDiscounterFactory();

    // リアルタイムでの割引内訳を確認
    public Discount getDiscount(...) {
        ...
    }

    // 決済処理
    public void payment(...) {
        ...
    }

    private Discountable getDiscounter(String discountCode) {
        return discounterFactory.getDiscounter(discountCode);
    }

メソッドが分離され、抽象化に基づいて柔軟性のあるコードになりました。

生成メソッドが呼び出されるのが気になる場合は、初期化の遅延技法やClient(PaymentService)の生成と同時に初期化させる方法を利用することを推奨します。ここでは内容が膨大になるので、パフォーマンスの最適化については省略します

リファクタリング後のクリーンアップ

  • 支払方法: PaymentService
  • 割引: Discountable
  • 割引作成: DiscounterFactory

柔軟性の確保(従来と同じ)

DIPを通じた強結合の具象クラスではなく、柔軟なインタフェースを基盤に、オブジェクトの依存関係を確保

クラス図


上記パターンを使うと、コードの重複をなくすために、90%以上のテンプレートメソッドパターンを並行して使用することになります。上記のサンプルでは、テンプレートメソッドパターンを適用する必要がないので使用していません。
参考までに、テンプレートメソッドパターンは、継承(extends)を使用します。

Template methodパターン:https://en.wikipedia.org/wiki/Template_method_pattern

結論

この程度のリファクタリングでも「オブジェクト指向である」と言えます。新しい割引ポリシーが追加されると、PaymentServiceのようなクライアントは変更する必要がなく、Discountableを拡張して、新しい割引ポリシー構想クラスを追加し、SimpleDiscounterFactoryelse ifのみ追加する機能拡張が可能になります。

とはいえ、OCPは満たせませんでした。PaymentServiceに対しては変更がありませんが、すでに実装されたSimpleDiscounterFactoryから変更が発生するためです。

違う観点

違う観点でリファクタリングを試みることもあります。
扱う属性(データ)が静的であれば、ファクトリーの代わりにenumを使ってみましょう。

enum基盤のリファクタリング

@Getter
public enum DiscountPolicy implements Discountable {
    /** アマゾン割引 */
    Amazon(10, 0L) {
        @Override
        public long getDiscountAmt(long originAmt) {
            return originAmt * this.discountRate / 100;
        }
    },
    /** 楽天市場割引 */
    Rakuten(15, 0L) {
        @Override
        public long getDiscountAmt(long originAmt) {
            return originAmt * this.discountRate / 100;
        }
    },
    /** ヤフーショッピング割引 */
    Yahoo(0, 1000L) {
        @Override
        public long getDiscountAmt(long originAmt) {
            if (originAmt < this.discountAmt)
                return originAmt;
            return this.discountAmt;
        }
    }
    ;
    private final int discountRate;
    private final long discountAmt;

    DiscountPolicy(int discountRate, long discountAmt) {
        this.discountRate = discountRate;
        this.discountAmt = discountAmt;
    }
}
public class PaymentService {
    // リアルタイムでの割引内訳を確認
    public Discount getDiscount(...) {
        ...
    }

    // 決済処理
    public void payment(...) {
        ...
    }

    private Discountable getDiscounter(String discountCode) {
        if (discountCode == null)
            return Discountable.NONE;
        try {
            return DiscountPolicy.valueOf(discountCode);
        } catch (IllegalArgumentException iae) {
            throw new NotSupportedDiscount("Not found discountCode : " + discountCode,
            iae);
        }
    }
    ...

enumごとに割引インタフェースを実装すると、整然としたコードを作成できます。先ほどのファクトリーと違い、分岐処理自体が必要なく、拡張もenum定数を1つ実装すればよいでしょう。さらにenum定数を追加するだけで、他のコードを変更せずに拡張(新規割引ポリシーの追加)できるので、OCPも満たしています。
ただし、上述したように、データが静的で変化がないオブジェクトを扱う場合にのみ、使用を推奨します。

enumを使用する場合の欠点

  • enumのため、状態が変化できない。
  • 継承や拡張が一部制限され、柔軟性が少し落ちる。

例えばアマゾンの割引率が10%のところ、突然15%に変更された場合、アプリケーションを再配布する必要があります。
もちろん、ファクトリーを利用した場合も同様ですが、もう少し改善すると柔軟に対応できます。次のサンプルを見てみましょう。

Step 3. ドメインオブジェクト(Entity)基盤のリファクタリング

@Entity
@Inheritance
public abstract class AbstractDiscounter implements Discountable {
    @Id @GeneratedValue @Column
    private long id;
    @Column
    private String code;
    @Column
    private String name;
}

/** 割引率 */
@Entity
@DiscriminatorValue("RATE")
public class RateDiscounter extends AbstractDiscounter {
    @Column
    private int rate;

    @Override
    public long getDiscountAmt(long originAmt) {
        return originAmt * rate / 100;
    }
}

/** 金額割引 */
@Entity
@DiscriminatorValue("AMT")
public class AmtDiscounter extends AbstractDiscounter {
    @Column
    private long amt;

    @Override
    public long getDiscountAmt(long originAmt) {
        if (originAmt < amt)
            return originAmt;
        return amt;
    }
}

実際のテーブルに格納されたデータ

JPAの継承関係のマッピング戦略の基本となる単一テーブル戦略(SINGLE_TABLE)を利用しました。

Discounterテーブル

* id dtype * code name rate amt
1 RATE Amazon アマゾン 10 0
2 RATE Rakuten 楽天市場 15 0
3 AMT Yahoo ヤフーショッピング 0 1000

*表記カラムは、値がUnique

/** ポリモーフィズムリポジトリ */
@Repository
public interface DiscounterRepository<T extends AbstractDiscounter>
        extends JpaRepository<T, Long> {
    /** 割引コードで割引照会 */
    T findByCode(String code);
}
/**
 * 名前はFactoryだが有効性チェックと基本戦略の重複除去のためのヘルパークラス
 * 既存コードとの一貫性のため、そのまま維持したが、実際に使用するときは別の名称で使用
 */
@Component
public class SimpleDiscounterFactory implements DiscounterFactory {
    @Autowired
    private DiscounterRepository<AbstractDiscounter> discounterRepository;

    @Override
    public Discountable getDiscounter(String discountCode) {
        if (discountCode == null)
            return Discountable.NONE;
        AbstractDiscounter discounter = discounterRepository.findByCode(discountCode);
        return discounter == null ? Discountable.NONE : discounter;
    }
}

フロー上、ファクトリーを残しましたが、PaymentServiceから直接DiscounterRepositoryに挿入して使用する方が確実です。

@Service
public class PaymentService {
    @Autowired
    DiscounterFactory discounterFactory;
    // リアルタイムでの割引内訳を確認
    public Discount getDiscount(...) {
        ...
    }

    // 決済処理
    public void payment(...) {
        ...
    }

    private Discountable getDiscounter(String discountCode) {
        return discounterFactory.getDiscounter(discountCode);
    }
    ...

Java8、SpringフレームワークとJPAを使用すると仮定してサンプルコードを作りました。

with JPA

  • 変更する値を照会するためにRepositoryを使用
  • ポリモーフィズムのため継承関係のマッピングを使用

継承関係のクラス図

もしアマゾン割引が15%に変更された場合、以前の実装方法では、アプリケーションを再配布すると対応可能です。根本的な原因は、エンタープライズアプリケーションの大半のオブジェクトは、状態が常に簡単に変わる(mutable)オブジェクト(Entity-ドメインオブジェクト)であるためです。
ドメインロジックを含むオブジェクトの状態が静的な場合は、ほとんどありません。

このように構成すると、分岐処理する内容を、それぞれEntityオブジェクトとして分離して処理したり、分岐処理を最小化(または削除)したり、頻繁に変化する要件とオブジェクトの状態変化(割引ポリシーの変化)にも柔軟に対応できるようになります。

隠されたリファクタリング

コードを見るとRateDiscounter(割引率)、 AmtDiscounter(金額割引)の2つの具象クラスがあることが分かります。既存のAmazonDiscountPolicyRakutenDiscountPolicyを見ると、割引率を用いた割引という同じようなロジックを持つクラスでしたが、それぞれの実装を分類してRateDiscounterに抽象化したものです。

もし、Amazon 20%割引のようなポリシーが追加されると、Discounterテーブルの上に、そのポリシーを1つ追加します。全くコードを変更せずにポリシーを拡張できます。割引額を照会する行為(メソッド)もEntityにあるので、オブジェクト指向の基本的な関連状態と行為が持つオブジェクトとなり、さらに凝集性が増します。

Bonus Step 3-2. With Mybatis

現在はまだMybatisのようなO/Rマッピングツールを使ったり、JDBC基盤を使用するところが多いようです。戻り値の型にMapがないDTOを使った場合のサンプルも用意しました。

残念ながらMybatisを使うと、継承関係を表現するのにかなりの追加作業が求められますが、ほとんどの場合、そのようなプロセスを通らずにData基盤で実装されます。そこで、Step 3のサンプルとは異なる方法で分岐処理を解いてみましょう。

基本的なテーブル構造は同じにします。継承を表現するのは難しいので、単一テーブルを使用します。

Discounterテーブル

* id dtype * code name rate amt
1 RATE Amazon アマゾン 10 0
2 RATE Rakuten 楽天市場 15 0
3 AMT Yahoo ヤフーショッピング 0 1000

テーブルに関連付けてDTOを1つ生成し、Discounterインタフェースを実装しています。
Mybatis Dao実装コードは省略します。

@Data
public class DiscounterDto implements Discountable {
    public Long id;
    public String dtype;
    public String code;
    public String name;
    public int rate;
    public long amt;

    @Override
    public long getDiscountAmt(long originAmt) {
        if ("RATE".equals(dtype)) {
            return originAmt * rate / 100;
        } else if ("AMT".equals(dtype)) {
            if (originAmt < amt)
                return originAmt;
            return amt;
        } else {
            return 0;
        }
    }
}

Mybatisを使用する場合、オブジェクトの状態(属性)だけを管理して、関連する行為は他のコンポーネントやService層から直接ハンドリングすることが多いです。しかし考えを変えて、DTO(ただし、実質的にはEntityの意味合いが強い)内に置いた方がよいでしょう。

enumポリシーにリファクタリング

enum DiscountType {
    /** 割引率 */
    RATE {
        @Override
        public long getDiscountAmt(DiscounterDto dto, long originAmt) {
            return originAmt * dto.getRate() / 100;
        }
    },
    /** 金額割引 */
    AMT {
        @Override
        public long getDiscountAmt(DiscounterDto dto, long originAmt) {
            if (originAmt < dto.amt)
                return originAmt;
            return dto.amt;
        }
    },
    /** 割引金額の返却 */
    abstract long getDiscountAmt(DiscounterDto dto, long originAmt);
}
@Data
public class DiscounterDto implements Discountable {
    public Long id;
    // myabtisもカスタムコンバータを用いてString-enum間の変換ができる。
    public DiscountType dtype;
    public String code;
    public String name;
    public int rate;
    public long amt;

    @Override
    public long getDiscountAmt(long originAmt) {
        return dtype.getDiscountAmt(this, originAmt);
    }
}

改めて強調しますが、オブジェクトの状態は動的ですが、オブジェクトの行為は静的です。行為に対して引数が動的で、行為を通じてオブジェクトの状態、または行為の返却が変更されるだけで、行為自体は静的だと考えましょう。例えばA + B = Cのとき、A、B、Cのような値は変わりますが、+(加算演算)は変わりません。- JVMメモリ構造でMethod Areaがなぜ静的領域にあるかも、上の内容とリンクします。

このような静的領域である割引計算部分をenumポリシーで管理してDTOに委譲します。
重要なことは、ORMを使わなくても、ドメインロジックをEntityオブジェクト内に繋ぐことができるということです。
このように構成すると、今後、新たなポリシー追加/変更、モデル(テーブルスキーマ)変更があっても、ある程度の柔軟性は確保できます。

結論

最終的に、分岐処理をなくすことはできません。特に有効性チェックのような分岐処理は、残す必要があります。ドメインロジックの分岐処理を複数回繰り返すのではなく、1つの具象クラス内で複数を管理して、同じ種類の分岐処理がコードのあちこちに発生するのを防ぎましょう。

  • Domain modelにメソッドを追加しよう
  • オブジェクト指向プログラミングを用いて分岐処理を最小限にし、柔軟なコードにしよう

テスト可能なサンプルコード

https://github.com/redutan/anti-oop.git

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop