JEPA4Japan · 教程

组合、继承与多态

1,809字 6分钟阅读 #Python

优先选择组合,并使用共享接口保持对象系统的灵活性。

课程进度 课程大纲 已发布 24/24 课

用可替换的辅助对象组装机器人

想象一个拿着分数计算器的学习机器人。这个机器人拥有一个计算器;它并不是一种计算器。这种关系就是组合。

  1. 主要对象 一个学习计划
  2. 辅助对象 一种计分规则
  3. 拥有关系 接入另一个辅助对象
组合把对象连接起来,同时让它们各司其职。

在 __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("Study plan"))
Study plan

这个依赖清晰可见,而且可以替换。从外部传入协作对象是一种简单的依赖注入。测试可以注入一个很小的假对象;生产代码则可以注入真正的服务。

替换遵守同一承诺的辅助对象

另一个辅助对象可以用不同方式完成相同的工作:

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!

这就是多态:同一个调用可以用于不同的对象,因为这些对象提供了兼容的能力。它们不需要共享同一个基类。

  1. 契约 make(text)
  2. 接收 一个字符串
  3. 返回 一个标题字符串
  4. 含义相同 没有意外的副作用
只有行为遵守同一个承诺时,相同的方法名才真正有用。

如果一个名为 make() 的辅助对象返回 None、删除数据,或者打印文本而不是返回文本,它就破坏了契约。请用同一组小例子测试每个预期的实现。

只为真正的“是一种”关系使用继承

继承表示子类确实是一种特殊的基类对象,并且可以安全地代替基类对象。阅读活动可以是一种活动:

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} min"


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} pages"


item = ReadingActivity("Python docs", 25, 12)
print(item.summary())
print([cls.__name__ for cls in ReadingActivity.mro()])
Python docs: 12 pages
['ReadingActivity', 'Activity', 'object']

这个子类重写了 summary()。super() 会按照方法解析顺序,也就是 MRO,从当前类之后继续查找。尤其在多重继承中,这比“调用父类”的说法更加准确。

  1. ReadingActivity 先在这里查找
  2. Activity 然后查找基类
  3. object 最后的基类
MRO 是 Python 查找方法时遵循的有序路径。

可以用替换测试来判断:代码需要的是一个 Activity;它能否接收 ReadingActivity,而不需要新增类型检查,也不会让原有含义变弱?如果不能,那么“是一种”的说法很可能并不成立。

优先使用小型能力,而不是高耸的家族树

继承并不是免费复用代码的优惠券。危险信号包括:一个子类重写了几乎所有内容、代码中反复出现 isinstance() 分支,或者为每种选项组合不断增加 LoudJsonScoredReport 之类的名称。

格式化、存储和计分是彼此独立的辅助功能。把它们组合起来,让每一个都能单独改变。基于能力的函数只要求对象提供自己所需的操作:

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

任何具有兼容 render() 的对象都可以参与。让这种契约保持精简,也让继承链保持浅短。多重继承拥有确定的 C3 MRO,但它也会带来额外的协作规则;只有当模型确实需要它时才使用它。

小项目:可组合的学习计分系统

创建 study_scoring.py。这个完整程序只使用 Python 的内置功能。计分规则和报告器都是通过注入提供的辅助对象;学习计划永远不需要根据它们的具体类进行分支判断。

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} min -> {points} pts"
            for title, minutes, points in rows
        ]
        lines.append(f"Total: {sum(points for _, _, points in rows)} pts")
        return "\n".join(lines)


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


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([("Test", 10, 2)]), str)


sessions = [("Python", 45), ("Reading", 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("Contracts passed.")

运行 python3 study_scoring.py 并检查输出:

Python: 45 min -> 9 pts
Reading: 30 min -> 6 pts
Total: 15 pts
2 activities / 6 pts
Contracts passed.

边界提醒:MinuteScore(0) 会被拒绝。新的协作对象必须满足完整的行为契约,而不能只是拥有一个名称正确的方法。

三个小任务

  1. 替换辅助对象。 添加带有 calculate(minutes) 的 BonusScore,不要编辑 StudyPlan。
  2. 测试“是一种”关系。 创建 VideoActivity(Activity),并说明每个使用 Activity 的调用者仍然可以依赖哪些行为。
  3. 阅读查找路径。 再添加一个子类并打印它的 mro(),然后预测会运行哪个重写的方法。

当你做到以下几点时,就可以开始第 15 章了……

  • 你能区分“拥有一个”的组合关系和“是一种”的继承关系;
  • 你能注入和替换协作对象;
  • 你能描述并测试行为契约;
  • 你能在没有共享基类的情况下使用基于能力的多态;
  • 你只为可以相互替换的子类型使用继承;
  • 你能解释方法重写、super() 和简单的 MRO;
  • 你会避免过深的层次结构和根据具体类型进行切换的分支链;
  • 你能运行两种计分计划,并看到 Contracts passed.。

接下来,你将使用 dataclasses 创建紧凑的数据记录,并使用 enums 代替松散的选项字符串。