JEPA4Japan · チュートリアル

コンポジション、継承、ポリモーフィズム

2,019文字 6分で読めます #Python

まずコンポジションを検討し、共通インターフェースで柔軟な設計を作ります。

コース進捗 コース目次 24レッスン中 24件を公開中

交換できる助っ人でロボットを組み立てる

学習ロボットが得点計算機を持っていると想像してください。ロボットは計算機を**持つ(has-a)のであり、計算機の一種ではありません。この関係がコンポジション(composition)**です。

  1. 中心の物 学習計画
  2. 助っ人 得点規則
  3. has-a 別の助っ人へ差し替える
コンポジションは、仕事を分けたままオブジェクトを組み合わせます。

助っ人を__init__()で受け取り、仕事を任せます。

class PlainHeading:
    def make(self, text):
        return text


class Report:
    def __init__(self, heading_maker):
        self.heading_maker = heading_maker

    def title(self, text):
        return self.heading_maker.make(text)


print(Report(PlainHeading()).title("学習計画"))
学習計画

依存する相手が見え、交換もできます。協力オブジェクトを外から渡すのは、簡単な依存性注入です。テストでは小さな偽物、本番では実際のサービスを渡せます。

同じ約束を守る助っ人へ交換する

別の助っ人が、同じ仕事を違う方法で行えます。

class PlainHeading:
    def make(self, text):
        return text


class LoudHeading:
    def make(self, text):
        return text.upper() + "!"


for helper in [PlainHeading(), LoudHeading()]:
    print(helper.make("Study plan"))
Study plan
STUDY PLAN!

一つの呼び出しが、互換性のある能力を持つ異なるオブジェクトで動きます。これが**多態性(polymorphism)**です。共通の基底クラスは必須ではありません。

  1. 契約 make(text)
  2. 受け取る 文字列を1つ
  3. 返す 見出し文字列を1つ
  4. 意味も同じ 驚く副作用はない
メソッド名だけでなく、振る舞いの約束まで一致して初めて交換できます。

make()という名前でも、Noneを返す、データを消す、文字列を返さず表示する実装は契約違反です。使う予定の実装を、同じ小さな例でテストします。

本当のis-a関係だけを継承にする

継承(inheritance)は、サブクラスが本当に基底オブジェクトの特殊な種類であり(is-a)、安全に置き換えられるという主張です。読書活動は活動の一種として表せます。

class Activity:
    def __init__(self, title, minutes):
        if minutes < 0:
            raise ValueError("minutes must not be negative")
        self.title = title
        self.minutes = minutes

    def summary(self):
        return f"{self.title}:{self.minutes}分"


class ReadingActivity(Activity):
    def __init__(self, title, minutes, pages):
        super().__init__(title, minutes)
        self.pages = pages

    def summary(self):
        return f"{self.title}:{self.pages}ページ"


item = ReadingActivity("Python文書", 25, 12)
print(item.summary())
print([cls.__name__ for cls in ReadingActivity.mro()])
Python文書:12ページ
['ReadingActivity', 'Activity', 'object']

サブクラスはsummary()をオーバーライドしています。super()は、メソッド解決順序、つまりMROに従い、現在のクラスの次から検索を続けます。特に多重継承では、単なる「親を呼ぶ」より正確な説明です。

  1. ReadingActivity 最初に探す
  2. Activity 次に基底を見る
  3. object 最後の基底
MROは、Pythonがメソッドを探す順番付きの道です。

置換テストをします。「Activityを約束した場所へ、追加の型検査や意味の弱体化なしでReadingActivityを渡せるか」。無理なら、そのis-a関係はおそらく誤りです。

高い家系図より小さな能力を選ぶ

継承は、コードを無料で再利用する券ではありません。ほぼ全部を上書きするサブクラス、何度も現れるisinstance()分岐、選択肢の組み合わせごとに増えるLoudJsonScoredReportのような名前は警告です。

整形、保存、得点は独立した助っ人として構成すれば、それぞれ単独で変えられます。能力に基づく関数は、必要な操作だけを頼みます。

def show(result):
    print(result.render())

互換性のあるrender()を持つオブジェクトなら参加できます。契約を小さくし、継承階層も浅く保ちます。多重継承のC3 MROは決定的ですが、協調の規則も増えます。本当にモデルが必要とするときだけ選びます。

ミニ制作:交換できる学習得点

study_scoring.pyを作ります。組み込み機能だけで動く完成プログラムです。得点規則とレポーターを外から注入し、計画は相手の正確なクラスで分岐しません。

class FixedScore:
    def __init__(self, points):
        if isinstance(points, bool) or not isinstance(points, int) or points < 0:
            raise ValueError("points must be a non-negative integer")
        self.points = points

    def calculate(self, minutes):
        return self.points


class MinuteScore:
    def __init__(self, minutes_per_point):
        if (
            isinstance(minutes_per_point, bool)
            or not isinstance(minutes_per_point, int)
            or minutes_per_point <= 0
        ):
            raise ValueError("minutes_per_point must be a positive integer")
        self.minutes_per_point = minutes_per_point

    def calculate(self, minutes):
        return minutes // self.minutes_per_point


class TextReport:
    def render(self, rows):
        lines = [
            f"{title}:{minutes}分 → {points}点"
            for title, minutes, points in rows
        ]
        lines.append(f"合計:{sum(points for _, _, points in rows)}点")
        return "\n".join(lines)


class CompactReport:
    def render(self, rows):
        total = sum(points for _, _, points in rows)
        return f"{len(rows)}活動 / {total}点"


class StudyPlan:
    def __init__(self, score_rule, reporter):
        self.score_rule = score_rule
        self.reporter = reporter

    def report(self, sessions):
        rows = [
            (title, minutes, self.score_rule.calculate(minutes))
            for title, minutes in sessions
        ]
        return self.reporter.render(rows)


def verify_rule(rule):
    result = rule.calculate(20)
    assert not isinstance(result, bool)
    assert isinstance(result, int) and result >= 0


def verify_reporter(reporter):
    assert isinstance(reporter.render([("テスト", 10, 2)]), str)


sessions = [("Python", 45), ("読書", 30)]

minute_plan = StudyPlan(MinuteScore(5), TextReport())
fixed_plan = StudyPlan(FixedScore(3), CompactReport())

print(minute_plan.report(sessions))
print(fixed_plan.report(sessions))

for rule in [MinuteScore(5), FixedScore(3)]:
    verify_rule(rule)
for reporter in [TextReport(), CompactReport()]:
    verify_reporter(reporter)
print("契約テスト成功。")

python3 study_scoring.pyで実行し、次を確認します。

Python:45分 → 9点
読書:30分 → 6点
合計:15点
2活動 / 6点
契約テスト成功。

境界の注意:MinuteScore(0)は拒否されます。新しい助っ人には、正しい名前のメソッドだけでなく、振る舞いの契約全体が必要です。

3つの小さなミッション

  1. 助っ人を交換。 calculate(minutes)を持つBonusScoreを、StudyPlanを編集せず追加します。
  2. is-aをテスト。 VideoActivity(Activity)を作り、Activityの全利用者が引き続き頼れることを書きます。
  3. 道順を読む。 サブクラスをもう1層加え、どのオーバーライドが動くか予想してからmro()を表示します。

第15章へ進む準備

  • has-aのコンポジションとis-aの継承を区別できる
  • 協力オブジェクトを注入し、交換できる
  • 振る舞いの契約を説明し、テストできる
  • 共通基底なしでも能力に基づく多態性を使える
  • 置換可能な本当のサブタイプだけに継承を使える
  • オーバーライド、super()、単純なMROを説明できる
  • 深い階層と正確な型による分岐を避けられる
  • 2つの得点計画を実行し、「契約テスト成功。」を確認できる

次章では、データクラスで短いデータ記録を作り、自由な選択文字列を列挙型へ置き換えます。