継承を使ってキャンペーンをまとめよう
それでは実際にキャンペーンをまとめてみよう。キャンペーンとして共通化できそうな特徴はこんな感じだろうか。
- キャンペーンというからには、値引き後の金額を計算できるはず。
-
キャンペーンの内容によらず、
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円
しっかりポリモーフィズムが働いて、個別のキャンペーンに応じた金額計算ができる!
そしてキャンペーンを形式的に共通化したので、適当に文字列を入れると… エラーになってくれる。
継承により拡張性も高まる
継承にはコードの共通化以外に、
拡張性
の面でのメリットもある。新たなキャンペーンのアイデアが現れた場合にも、
Campaign
の形式に沿ってさえいれば自動的に対応が完了するという点である。ロジックの実装時点では現時点では影も形もなかったアイデアが自動的に対応できるというのは面白くないだろうか? (ある種の前方互換性といえる)
例えば、開発完了して1年くらいたった折、初月無料になるキャンペーンを新規に追加したくなったとしよう。このキャンペーンは、例えば
FreeFirstMonthCampaign
のように名前をつけて、同じように
Campaign
クラスを継承して実装すればよい。サブスク管理クラスを始め、すでに既存の
Campaign
を受け付けるように作られている箇所であれば、修正することなくそのままこの新しいキャンペーンを処理することができる。
注意: 「意味上の共通部分」というところが大事
大切な注意点として、
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.
- 追加オプションサービス課金はキャンペーン (の一種) である?
- これは正しくない。
実は、これは後で説明するSOLID原則の「L: リスコフの置換原則」に対する違反です。
まとめ
- 継承を使うと、複数の具体的な実装から共通部分を抽出することができる。
- 共通部分として扱う限り、具体的な実装の違いを自動的に扱うことができる。特に、共通のコードの中で個別の実装に応じた動作をさせることをポリモーフィズムという。
- 継承を使うときは、意味上の共通部分を抽出することが大事。具体的な実装の違いを無理やり共通部分に押し込むと、保守性が下がる。
次: 📄 【オブジェクト指向2024 #8】お題: 契約者にメールを送ろう