🚀 ニフティ’s Notion

【オブジェクト指向2024 #7】継承でキャンペーンを整理する

継承を使ってキャンペーンをまとめよう

それでは実際にキャンペーンをまとめてみよう。キャンペーンとして共通化できそうな特徴はこんな感じだろうか。

  • キャンペーンというからには、値引き後の金額を計算できるはず。
  • キャンペーンの内容によらず、 compute_discounted_amount() 関数の処理は全部共通のはず。
# 親クラス (基底クラス)
class Campaign:
    def get_discounted_price(self, price: int):
        "割引後の価格を計算する"
        # このメソッドは子クラスでオーバーライドされることにより、
        # 様々な値引き方式に応じた金額を計算する
        return price

    def compute_discounted_amount(self, price: int):
        "割引された額を計算する"
        # ここで *ポリモーフィズム* を利用している。
        # 値引き後の金額は具体的な値引き方式に依存するけれど、
        # それは子クラスでオーバーライドされた get_discounted_price() が与えてくれるので、
        # この計算は全キャンペーンに対して共通化できる。
        return price - self.get_discounted_price(price)

すると、割引及び固定値値引きは次のようにかける。

# 割引 子クラス (派生クラス)
class RateDiscountCampaign(Campaign):
    def __init__(self, discount_rate: float):
        super().__init__()
        self.__discount_rate = discount_rate

    # ここで *オーバーライド* を利用している。
    # 割引の割合に応じて価格を計算する。
    @override
    def get_discounted_price(self, price: int):
        return int(price * (1 - self.__discount_rate))
        
    # compute_discounted_amount() は親から継承するので、書く必要はない
# 固定値値引き 子クラス (派生クラス)
class ValueDiscountCampaign(Campaign):
    def __init__(self, discount_value: float):
        super().__init__()
        self.__discount_value = discount_value

    # ここで *オーバーライド* を利用している。
    # 値引きの金額に応じて価格を計算する。
    @override
    def get_discounted_price(self, price: int):
        return price - self.__discount_value
        
    # compute_discounted_amount() は親から継承するので、書く必要はない

この関係は、よくこんな感じの図 (クラス図) で表現される。

サブスク管理クラスの campaign の制約

共通部分を Campaign として定義したので、もうこれで問題なく受け取るものを指定できる

class Subscription:
    # ...
    
    def __init__(self, plan_id: int, price: int, campaign: Campaign):
        #                                                ^^^^^^^^^^ 堂々と Campaign を受け取るぞと伝えられる

サブスク管理クラスの使い方

# 割引
sub = Subscription(
    plan_id=12345,
    price=1000,  # 1000円のプランを購読
    campaign=RateDiscountCampaign(0.2),  # 20% OFF
)
print(sub.monthly_price())  # => 800円
print(sub.compute_discounted_amount())  # => 200円

# 固定値値引き
sub = Subscription(
    plan_id=12345,
    price=1000,  # 1000円のプランを購読
    campaign=ValueDiscountCampaign(300),  # 300円引き
)
print(sub.monthly_price())  # => 700円
print(sub.compute_discounted_amount())  # => 300円

しっかりポリモーフィズムが働いて、個別のキャンペーンに応じた金額計算ができる!

そしてキャンペーンを形式的に共通化したので、適当に文字列を入れると… エラーになってくれる。

image block

継承により拡張性も高まる

継承にはコードの共通化以外に、 拡張性 の面でのメリットもある。新たなキャンペーンのアイデアが現れた場合にも、 Campaign の形式に沿ってさえいれば自動的に対応が完了するという点である。ロジックの実装時点では現時点では影も形もなかったアイデアが自動的に対応できるというのは面白くないだろうか? (ある種の前方互換性といえる)

例えば、開発完了して1年くらいたった折、初月無料になるキャンペーンを新規に追加したくなったとしよう。このキャンペーンは、例えば FreeFirstMonthCampaign のように名前をつけて、同じように Campaign クラスを継承して実装すればよい。サブスク管理クラスを始め、すでに既存の Campaign を受け付けるように作られている箇所であれば、修正することなくそのままこの新しいキャンペーンを処理することができる。

Icon
実は、これは後で説明するSOLID原則の「O: オープン・クローズドの原則」の体現です。

注意: 「意味上の共通部分」というところが大事

大切な注意点として、 Campaign という名前をつかたからにはキャンペーン以外のものが Campaign を継承してはいけない。

例えば、何かオプションサービスに加入することで月額料金が500円追加になるサービスがあるとしよう。安直に考えると、次のような Campaign の派生クラスを追加することで変更を最小限にできるような気がする。

class AdditionalOptionServiceBilling(Campaign):
    @override
    def get_discounted_price(self, price: int) -> int:
        return price + 500

実際これはPythonという言語の仕組み的には可能だし、動く。動いてしまう。ただ、意味的に間違っていることをやってしまうと後世の何も知らない人がコードを読み解く際に戸惑ってしまうし、その結果、保守性を著しく下げることになる。保守性を下げることは設計の本来の目的を真っ向からぶち壊していくので、決してしてはいけない。

is-a 関係

よく言われる一つの感覚的な指標として、「is-a関係」というものがある。継承は「子クラス is a 親クラス」の関係になっていなければならない。

  • RateDiscountCampaign is a Campaign.
    • 割引キャンペーンはキャンペーン (の一種) である。
    • これは正しい。
  • ValueDiscountCampaign is a Campaign.
    • 固定値値引きキャンペーンはキャンペーン (の一種) である。
    • これも正しい。
  • AdditionalOptionServiceBilling is a Campaign.
    • 追加オプションサービス課金はキャンペーン (の一種) である?
    • これは正しくない。
    Icon
    実は、これは後で説明するSOLID原則の「L: リスコフの置換原則」に対する違反です。

まとめ

  • 継承を使うと、複数の具体的な実装から共通部分を抽出することができる。
  • 共通部分として扱う限り、具体的な実装の違いを自動的に扱うことができる。特に、共通のコードの中で個別の実装に応じた動作をさせることをポリモーフィズムという。
  • 継承を使うときは、意味上の共通部分を抽出することが大事。具体的な実装の違いを無理やり共通部分に押し込むと、保守性が下がる。

次: 📄 【オブジェクト指向2024 #8】お題: 契約者にメールを送ろう