课程进度 课程大纲 已发布 24/24 课
Python 基础
数据与集合
构建可靠的程序
使用对象建模
专业 Python
高级 Python
用可替换的辅助对象组装机器人
想象一个拿着分数计算器的学习机器人。这个机器人拥有一个计算器;它并不是一种计算器。这种关系就是组合。
- 主要对象 一个学习计划
- 辅助对象 一种计分规则
- 拥有关系 接入另一个辅助对象
在 __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!
这就是多态:同一个调用可以用于不同的对象,因为这些对象提供了兼容的能力。它们不需要共享同一个基类。
-
契约
make(text) - 接收 一个字符串
- 返回 一个标题字符串
- 含义相同 没有意外的副作用
如果一个名为 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,从当前类之后继续查找。尤其在多重继承中,这比“调用父类”的说法更加准确。
- ReadingActivity 先在这里查找
- Activity 然后查找基类
- object 最后的基类
可以用替换测试来判断:代码需要的是一个 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) 会被拒绝。新的协作对象必须满足完整的行为契约,而不能只是拥有一个名称正确的方法。
三个小任务
- 替换辅助对象。 添加带有
calculate(minutes)的BonusScore,不要编辑StudyPlan。 - 测试“是一种”关系。 创建
VideoActivity(Activity),并说明每个使用Activity的调用者仍然可以依赖哪些行为。 - 阅读查找路径。 再添加一个子类并打印它的
mro(),然后预测会运行哪个重写的方法。
当你做到以下几点时,就可以开始第 15 章了……
- 你能区分“拥有一个”的组合关系和“是一种”的继承关系;
- 你能注入和替换协作对象;
- 你能描述并测试行为契约;
- 你能在没有共享基类的情况下使用基于能力的多态;
- 你只为可以相互替换的子类型使用继承;
- 你能解释方法重写、
super()和简单的 MRO; - 你会避免过深的层次结构和根据具体类型进行切换的分支链;
- 你能运行两种计分计划,并看到
Contracts passed.。
接下来,你将使用 dataclasses 创建紧凑的数据记录,并使用 enums 代替松散的选项字符串。