LoginSignup
118
141

誰に向けての記事か

この記事は主にオブジェクト指向プログラミングに慣れてきた頃くらいの方を対象にしています。

PythonやJava、Rubyといったプログラミング言語を勉強しはじめると、しばらくしてオブジェクト指向という概念を学習することになります。そしてオブジェクト指向プログラミングの重要な特徴として以下の3つがよく挙げられます。

  • 継承
  • カプセル化
  • ポリモーフィズム

上記のうち、特に使い方に注意を払わなければならないのは継承です。継承はスーパクラスとサブクラスの間に密結合を生み出します。継承を誤って使ってしまうと1つの変更が他のクラスにも影響を及ぼしかねません。そうすると変更するのにも認知的なコストがかかるようになります。継承自体禁止するように言う人もいるくらいです。

筆者自身は継承を全面的に禁止しようとは思いません。ただし、継承にも良い使い方、良くない使い方というのはあります。それでは良くない継承とは何でしょうか。

結論

最初に結論を述べます。

共通処理の置き場として継承を使ってはいけない

このような継承をしてしまうと変更が容易にできなくなります。実際にどのようなことが起こるか、具体例を通じて見ていきましょう。言語はPythonを使います。なお、ここでは説明の都合上、基本的な文法事項の解説は行いません。そこまで難しいことはやっていないので調べればすぐわかると思います(今ならChatGPTもありますしね!)。

ホテル宿泊予約サービス

ここではホテルの宿泊予約サービスを題材にして、誤った継承が引き起こすバグを体験しましょう。やることはシンプルで宿泊プランからかかる費用を計算するだけです。

プランの種類

ここでは簡単のため、2種類のプランのみ考えます。

  • スタンダードプラン - 通常のプラン。費用合計は「利用人数 × 一人当たりの費用」で計算。

  • ファミリープラン - 家族プラン。費用合計は「大人の人数 × 大人一人当たりの費用 + 子供の人数 × 子供一人当たりの費用」で計算。

また、ホテルを予約する時はオプションがつけられます。朝食付きのオプションなどはよくある例でしょう。追加可能オプションは以下2つを考えます。

  • 朝食をつける - 朝食をつけます。利用人数分の朝食料金がプラスでかかります。

  • スイートルームに変更 - 通常の部屋からスイートルームに変更します。アップグレード分の料金がプラスでかかります。

まずはオプションクラスを実装します。以下のように実装しました。

sample.py
import abc


class Option(metaclass=abc.ABCMeta):
    @property
    @abc.abstractmethod
    def fee(self):
        pass


class Breakfast(Option):
    def __init__(self, num_people, fee_per_person) -> None:
        self.num_people = num_people
        self.fee_per_person = fee_per_person

    @property
    def fee(self):
        return self.fee_per_person * self.num_people


class SuitRoom(Option):
    def __init__(self, amount) -> None:
        self.amount = amount

    @property
    def fee(self):
        return self.amount

抽象基底としてOptionを作成し、feeメソッドでそれぞれのオプションの料金を算出します。
Breakfastクラスでは人数分の朝食料金を計算するのでインスタンス変数として「人数」と「1人あたりの朝食料金」を持っています。
SuitRoomクラスはアップグレード分の料金をインスタンス変数で持ちます。

次に各プランクラスを以下のように実装しました。

sample.py
# (中略)

class StandardPlan:
    def __init__(self, num_people, cost_per_person, options=None):
        self.num_people = num_people
        self.cost_per_person = cost_per_person
        self.options = options or []
    
    @property
    def cost(self):
        return self.num_people * self.cost_per_person + self._option_fees()
    
    def _option_fees(self):
        return sum(option.fee for option in self.options)


class FamilyPlan:
    def __init__(self, num_adult, num_child, cost_per_adult, cost_per_child, options=None):
        self.num_adult = num_adult
        self.num_child = num_child
        self.cost_per_adult = cost_per_adult
        self.cost_per_child = cost_per_child
        self.options = options or []

    @property
    def cost(self):
        return self.num_adult * self.cost_per_adult + self.num_child * self.cost_per_child + self._option_fees()

    def _option_fees(self):
        return sum(option.fee for option in self.options)

各プランクラスはそれぞれの費用計算に必要となるインスタンス変数の他にオプションのリストもコンストラクタの引数で受け取っています。_option_feesメソッドでは内包表記を使って各オプションのfeeメソッドを呼び出してオプション料金の合計を求めています。costメソッド内で_option_feesメソッドを呼び出せばプランの費用にオプション料金の合計が加算されます。
これらをインスタンス化して使うmain関数を作成しましょう。

sample.py
# (中略)

def process(plans):
    for plan in plans:
        print("--------------------------")
        print(f"予算合計:{plan.cost}")
        print("--------------------------")


def main():
  plan1 = StandardPlan(
    num_people=10, 
    cost_per_person=12000
  )

  plan2 = FamilyPlan(
    num_adult=5,
    num_child=3,
    cost_per_adult=13000,
    cost_per_child=5000
  )

  plan3 = StandardPlan(
    num_people=10, 
    cost_per_person=12000,
    options=[Breakfast(1000, 10)]
  )

  plan4 = FamilyPlan(
    num_adult=5,
    num_child=3,
    cost_per_adult=13000,
    cost_per_child=5000,
    options=[Breakfast(1500, 8), SuitRoom(8000)]
  )

  process([plan1, plan2, plan3, plan4])


main()

main関数の中で各プランクラスのインスタンスを作成し、process関数内でプラン費用を出力しています。sample.pyを実行すると以下のように出力されます。

--------------------------
予算合計:120000円
--------------------------
--------------------------
予算合計:80000円
--------------------------
--------------------------
予算合計:130000円
--------------------------
--------------------------
予算合計:100000円
--------------------------

継承を使う

現状、StandardPlanクラスとFamilyPlanクラスの_option_feesメソッドの実装は全く同じです。DRY原則に従って共通の処理は1箇所にまとめたいものです。そこで継承を利用しましょう。新しくPlanクラスを作成し、そのクラスに_option_feesメソッドを移動します。

sample.py
+ class Plan:
+     def _option_fees(self):
+        return sum(option.fee for option in self.options)


- class StandardPlan:
+ class StandardPlan(Plan):
     def __init__(self, num_people, cost_per_person, options=None):
         self.num_people = num_people
         self.cost_per_person = cost_per_person
         self.options = options or []
    
     @property
     def cost(self):
         return self.num_people * self.cost_per_person + self._option_fees()

-    def _option_fees(self):
-        return sum(option.fee for option in self.options)


- class FamilyPlan:
+ class FamilyPlan(Plan):
      def __init__(self, num_adult, num_child, cost_per_adult, cost_per_child, options=None):
          self.num_adult = num_adult
          self.num_child = num_child
          self.cost_per_adult = cost_per_adult
          self.cost_per_child = cost_per_child
          self.options = options or []

      @property
      def cost(self):
          return self.num_adult * self.cost_per_adult + self.num_child * self.cost_per_child + self._option_fees()

-     def _option_fees(self):
-         return sum(option.fee for option in self.options)

実行すると先ほどと同じ出力結果が得られます。

さて、これで処理を共通化できました。一件落着でしょうか?
残念ながら上記のコードは変更に弱く、脆い構造になっています。スーパークラスであるPlanクラスに変更を加えてみましょう。オプションが2つ以上つけられた場合、オプション合計から2割引きになるように変更します。実際にこういった値引き処理というのは頻繁に行われます。

sample.py
class Plan:
+   def _apply_discount(self):
+       base = sum(option.fee for option in self.options)
+       if len(self.options) >= 2:
+           return base * 0.8
+       return base

    def _option_fees(self):
-       return sum(option.fee for option in self.options)
+       return self._apply_discount()

上記ではPlanクラスに新たにapply_discountメソッドを追加し、オプションの数が2つ以上なら2割引きになるようにしています。_option_feesメソッドは内部ではapply_discountメソッドの結果をそのまま返すようにしています。こうすることで_option_feesメソッドを使う側は以前と同じようにこのメソッドを使うことができます。実行するとplan4だけ割引が適用されて出力されます(小数になってるのはここでは気にしないでください)。

--------------------------
予算合計:120000円
--------------------------
--------------------------
予算合計:80000円
--------------------------
--------------------------
予算合計:130000円
--------------------------
--------------------------
予算合計:96000.0円
--------------------------

しばらくしてStandardPlanクラスも人数に応じて割引を適用することになりました。こちらは5人以上で予約すると2割引きされます。以下のようにStandardPlanクラスを変更します。

sample.py
class StandardPlan(Plan):
    def __init__(self, num_people, cost_per_person, options=None):
        self.num_people = num_people
        self.cost_per_person = cost_per_person
        self.options = options or []
    
    @property
    def cost(self):
-       return self.num_people * self.cost_per_person + self._option_fees()
+       return self._apply_discount() + self._option_fees()

+   def _apply_discount(self):
+       base = self.num_people * self.cost_per_person
+       if self.num_people >= 5:
+           return base * 0.8
+       return base

StandardPlanでは人数が5人以上であれば割引を適用するようにコードを修正しました。costメソッドではapply_discountの結果と_option_feesメソッドの結果を加算して合計額を算出しています。
これで修正は完了です。コードを実行してみましょう。

--------------------------
予算合計:192000.0円
--------------------------
--------------------------
予算合計:80000円
--------------------------
--------------------------
予算合計:192000.0円
--------------------------
--------------------------
予算合計:96000.0円
--------------------------

何やら出力結果がおかしいです。plan1とplan3がStandardPlanクラスのインスタンスでしたが、割り引きされるどころか以前よりも費用が高くなってしまっています。おまけにどちらも結果が同じです。まるでオプション料金が全く考慮されてないみたいです。これはどういうことでしょうか?

実はこれはcostメソッド内部で呼び出している_option_feesメソッドが原因です。_option_feesメソッドではself._apply_discount()のようにメソッド呼び出しをしていますが、この時のselfはどのクラスのオブジェクトになるでしょうか?

正解は呼び出し元のインスタンスのクラスです。なのでplan1とplan3がcostメソッドを呼び出すとselfにはStandardPlanクラスのインスタンスが代入されます。結果的にPlanクラスの_apply_discountメソッドが呼ばれることはなく、StandardPlanクラスの_apply_discountメソッドが2回呼ばれることになります。

qiita.png

結果として、オプション料金の計算は行われず、プラン料金が二回計算されてしまいます。

上記は誤った継承の例です。StandardPlanはPlanと明確なis-a関係が成り立っているんだからこの継承は間違ってない!と言いたくなるかもしれません。果たして本当にそうでしょうか?
Planクラスに定義されているのはオプション計算のメソッドだけです。これはPlanクラスの責務と言えるでしょうか?本来の責務を無視してそれっぽい名前だけつけてis-a関係が成り立っている!と言っても何の意味もありません。それは単なる命名の失敗です。この場合はPlanではなく、Optionsとでも命名するのが適切でしょう。そうなるとis-a関係が成り立っているとは到底言えません。

委譲に置き換える

上記では共通の処理の置き場として継承を使用してしまったために問題が発生しました。継承はスーパークラスとサブクラスの間に密結合を生み出します。

『Effective Java』では「継承はカプセル化を破る」という記述があります。本来カプセル化することのメリットは、カプセル化することで複雑さをオブジェクト内に閉じ込めることができるようになることです。これにより、あるクラスでの変更の影響はそのクラス内に閉じ込められ、内部の詳細を気にしなくても使えるようになるため、より単純にプログラミングすることができるようになります。上記のように共通処理のためだけに継承を利用してしまうと、子クラスは親クラスの内部を常に気にかけていないと迂闊に変更ができなくなってしまいます。これでは本来のカプセル化の意味が薄れてしまいます(Pythonでは完全なカプセル化は実現できないよという指摘はここでは置いておきます)。メソッド名を親のクラスと別の名前にすれば良いだけでは?と思われるかもしれませんが、スーパークラスにどんなメソッドがあるか把握していないといけない時点でクラスの内部構造を強く意識した状態になっています。こういった問題を解決するには委譲を使います。

新たにOptionsと言うクラスを作成し、変数としてオプションのリスト、メソッドで合計値を算出するtotalメソッドを定義します。そして全体をこのOptionsクラスを使うように変更します。

sample.py
import abc


+ class Options:
+     def __init__(self, options=None):
+         self.options = options or []
+    
+     def total(self):
+         return sum(option.fee for option in self.options)


class Option(metaclass=abc.ABCMeta):
    @property
    @abc.abstractmethod
    def fee(self):
        pass


class Breakfast(Option):
    def __init__(self, num_people, fee_per_person) -> None:
        self.num_people = num_people
        self.fee_per_person = fee_per_person

    @property
    def fee(self):
        return self.fee_per_person * self.num_people


class SuitRoom(Option):
    def __init__(self, amount) -> None:
        self.amount = amount

    @property
    def fee(self):
        return self.amount


- class Plan:
-     def _option_fees(self):
-         return sum(option.fee for option in self.options)


- class StandardPlan(Plan):
+ class StandardPlan:
      def __init__(self, num_people, cost_per_person, options=None):
          self.num_people = num_people
          self.cost_per_person = cost_per_person
+         self.options = Options(options)
-         self.options = options or []
    
    @property
    def cost(self):
-       return self.num_people * self.cost_per_person + self._option_fees()
+       return self.num_people * self.cost_per_person + self.options.total()


- class FamilyPlan(Plan):
+ class FamilyPlan:
      def __init__(self, num_adult, num_child, cost_per_adult, cost_per_child, options=None):
          self.num_adult = num_adult
          self.num_child = num_child
          self.cost_per_adult = cost_per_adult
          self.cost_per_child = cost_per_child
+         self.options = Options(options)
-         self.options = options or []

      @property
      def cost(self):
-         return self.num_adult * self.cost_per_adult + self.num_child * self.cost_per_child + self._option_fees()
+         return self.num_adult * self.cost_per_adult + self.num_child * self.cost_per_child + self.options.total()

main関数は修正する必要はありません。これで実行すると以前と同様の結果が得られます。

上記のようにオプション計算のロジックを専用のクラスにカプセル化したことで変更の影響をクラス内に閉じ込めることができます。先ほどと同様、オプションと通常のプランの割引計算ロジックを各クラスに書いてみましょう。

sample.py
class Options:
    def __init__(self, options=None) -> None:
        self.options = options or []
    
    def total(self):
-       return sum(option.fee for option in self.options)
+       return self._apply_discount()

+   def _apply_discount(self):
+     base = sum(option.fee for option in self.options)
+     if len(self.options) >= 2:
+         return base * 0.8
+     return base


# (中略)


class StandardPlan:
    def __init__(self, num_people, cost_per_person, options=None):
        self.num_people = num_people
        self.cost_per_person = cost_per_person
        self.options = Options(options)
    
    @property
    def cost(self):
-       return self.num_people * self.cost_per_person + self.options.total()
+       return self._apply_discount() + self.options.total()

+   def _apply_discount(self):
+       base = self.num_people * self.cost_per_person
+       if self.num_people >= 5:
+           return base * 0.8
+       return base

実行してみましょう。

--------------------------
予算合計:96000.0円
--------------------------
--------------------------
予算合計:80000円
--------------------------
--------------------------
予算合計:106000.0円
--------------------------
--------------------------
予算合計:96000.0円
--------------------------

1番目と3番目の出力結果を見ると、

plan1:120000円 -> 96000円
plan3:130000円 -> 106000円

のように人数によってちゃんと割引されているのがわかります。plan3にオプションを追加して人数とオプション割引が両方適用されるかも試してみてください。

上記ではオプションの計算処理をプランクラス内で直接行うのではなく、専用のクラスを作成し、そのクラスに実際の計算処理を任せました。このように一部の振る舞いを他のクラスのオブジェクトに代替させることを委譲と言います。継承の代わりに委譲を使うことでクラス間の結合が疎になりコードが変更しやすくなります。多くの場合、実装を直接継承するよりも別のオブジェクトに処理を委譲する方が良いとされています。

なお、先ほど作成したOptionsクラスはファーストクラスコレクションと呼ばれるものです。ファーストクラスコレクションではリストのようなデータをクラスでラップして関連する処理を記述します。このようにすることで凝集度が高まり、安定したクラスを作ることができます。

継承は悪か

継承が引き起こす問題を見てきました。この他にも安易な継承がクラス爆発を招いたり、神クラスを生み出したりと様々な問題を引き起こします。それでは継承は悪なのでしょうか?この記事の最初の方に良い継承と良くない継承があるというお話をしました。それでは良い継承とは何でしょうか。これまで触れてきませんでしたがコードの中には依然として継承を利用している箇所があります。Optionクラスです。このクラスは抽象クラスとして定義されており、それぞれの具象クラスをまとめ上げる共通のインターフェースとして機能しています。こうすることで実装とインターフェースが分離され、使う側のクラスはインターフェースに依存するようになります(実際にOptionsクラスではOption抽象クラスに定義されたメソッドしか使用していません)。このような継承は積極的に使っても問題ありません。なので以下のようなクラスを作成して、各プランクラスが継承するようなやり方もOKです。

class Plan(metaclass=abc.ABCMeta):
    @property
    @abc.abstractmethod
    def cost(self):
        pass

一般に滅多に変更されない部分を抽象クラスとして定義します。「プランには費用がかかる」「オプションには料金がかかる」、これらはほとんど変更の余地がない部分です(少なくとも今回考えてるアプリケーションにおいては)。なので抽象クラスにするのは理にかなっています。先ほどのように変更が起こりにくい部分に依存するとプログラムは全体的に安定していきます。
また、実装があったとしてもいわゆるTemplate Methodパターンのようにサブクラス前提のメソッドが定義されたクラスを継承するのも問題ありません。やってはいけないのはコードの再利用のためだけに無理な継承関係を作ることです。このような継承をしてしまうとクラス同士が密結合になり、変更に弱いシステムが出来上がってしまいます。このような場合は、継承はせず、委譲が使えないか考えてみましょう。

118
141
2

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
118
141