11-本章小结与工程实践
约 4713 字大约 16 分钟
2026-09-28
本章从一副纸牌和一个二维向量出发,讨论了 Python 对象如何接入语言本身。
最核心的结论可以压缩成一句话:
Python 数据模型是一组对象协议。类型实现协议,调用者使用统一的语法、内置函数和标准库工具。
例如:
len(obj) -> 长度协议
obj[index] -> 下标协议
for item in obj -> 迭代协议
repr(obj) -> 对象表示协议
bool(obj) -> 真假协议
left + right -> 数值运算协议真正的工程问题不是“还能实现哪些特殊方法”,而是:
- 这个对象应该表现出哪些自然行为;
- 哪些协议足以表达这些行为;
- 每项协议的返回值、异常和复杂度是否符合预期;
- 哪些能力不应该公开。
本节把前面的知识整理成一套可直接用于真实项目的设计方法。
1. 从对象职责出发,不从方法清单出发
设计自定义类型时,先用普通语言描述对象职责。
例如,一个只读搜索结果集合可能需要:
- 保存一组有顺序的搜索结果;
- 能获取当前加载结果的数量;
- 能按位置读取和切片;
- 能遍历;
- 能判断某个结果是否存在;
- 能在日志中显示查询条件和结果数量;
- 不能由调用者随意插入或删除结果。
然后再把职责映射到 Python 协议:
有顺序、可按位置读取 -> Sequence
获取数量 -> __len__
读取和切片 -> __getitem__
日志表示 -> __repr__
只读 -> 不实现 __setitem__、append、remove这比打开特殊方法列表逐项决定是否实现更可靠。协议是对象语义的结果,不是设计的起点。
2. 优先实现最小而完整的协议
“最小”意味着不增加没有明确用途的能力;“完整”意味着已经承诺的能力必须符合全部约定。
纸牌案例只实现:
__len__
__getitem__就获得了长度、索引、切片、基础迭代和标准库组合能力。这是一个小而有效的协议集合。
但如果一个类声明自己是 Sequence,承诺就更强:
- 整数索引应该具有稳定含义;
- 越界位置应该抛出
IndexError; - 切片行为应该可以解释;
- 迭代顺序应该与索引顺序一致;
- 长度应该与可访问元素数量一致;
- ABC 提供的
index()、count()等方法应该符合对象语义。
不要只为了让 isinstance(obj, Sequence) 返回 True 而继承 Sequence。继承 ABC 是公开接口承诺,不是标签装饰。
3. 使用公开操作,特殊方法留给类型实现
调用对象时,优先使用 Python 的公开入口:
len(obj)
repr(obj)
bool(obj)
obj[index]
left + right避免把正常业务代码写成:
obj.__len__()
obj.__repr__()
obj.__getitem__(index)
left.__add__(right)公开入口不仅更符合阅读习惯,还负责:
- 类型级特殊方法查找;
- 返回值协议检查;
- 反向运算和其他回退;
- 内置类型的优化路径;
- 统一的异常行为。
显式引用特殊方法主要出现在协议实现、继承协作和有明确目的的调试中,例如 super().__init__()。
4. 委托给成熟对象,减少重复规则
本章中的 FrenchDeck 把纸牌存入列表,再把长度和下标委托给它:
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]这项委托同时复用了:
- 负数索引;
slice处理;- 越界
IndexError; - 切片步长;
- 列表已有的性能特征。
如果内部对象已经正确实现某项协议,通常应复用它,而不是重新编写边界逻辑。
委托不等于把内部对象完整暴露出去。对象仍然可以只开放领域需要的能力:
内部使用 list
不代表外部必须获得 append、remove 和切片赋值组合内置容器通常比继承具体容器更容易控制公共接口。
5. 把返回值和异常视为协议的一部分
特殊方法的方法名正确,不代表实现就正确。
返回值必须符合约定
__len__返回非负整数;__bool__返回真正的bool;__repr__和__str__返回str;__bytes__返回bytes;__index__返回可作为精确索引的整数;__iter__返回迭代器;__next__在耗尽时抛出StopIteration。
Python 的公开入口通常会验证这些结果。直接调用特殊方法可能绕过部分检查,因此不能用一次直接调用成功证明协议实现正确。
使用协议规定的异常
- 序列越界使用
IndexError; - 映射缺失键使用
KeyError; - 缺失属性使用
AttributeError; - 迭代结束使用
StopIteration; - 异步迭代结束使用
StopAsyncIteration; - 不支持的最终运算通常表现为
TypeError。
这些异常不只是错误消息。解释器、内置函数和框架会根据异常类型决定是否回退、结束或继续传播。
6. 二元运算不支持某种类型时返回 NotImplemented
实现加法、乘法和比较等二元协议时,不支持右侧类型应返回 NotImplemented:
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)这让解释器有机会尝试右侧类型的反向方法。双方都不支持时,解释器再生成 TypeError。
不要混淆:
NotImplemented -> 特殊单例,表示当前运算实现不支持
NotImplementedError -> 异常类,表示某项普通实现尚未提供在二元特殊方法中直接抛出 NotImplementedError 会打断正常的运算协商过程。
7. 明确对象是可变值还是不可变值
可变性会影响运算、哈希、切片和共享引用的行为。
普通运算通常返回新对象
result = vector1 + vector2调用者通常预期 vector1 和 vector2 保持不变,result 是新值。
增量运算可能修改原对象
value += other如果实现 __iadd__,可变对象可以修改自身并返回 self。没有实现时,Python 通常回退到普通加法并重新绑定左侧变量。
可哈希对象的相等状态必须稳定
如果对象用于字典键或集合元素,参与 __eq__ 和 __hash__ 的字段不能在存入后变化。否则容器可能无法再次定位该对象。
因此,值对象常采用不可变设计;可变领域实体则通常保持不可哈希,除非它们明确以稳定身份参与相等性判断。
8. 检查回退规则是否符合语义和性能
Python 的回退机制可以减少代码,但不一定始终是最佳实现。
迭代回退
没有 __iter__ 时,Python 可以通过连续调用 __getitem__(0)、__getitem__(1) 进行基础序列迭代。
对于新类型,显式 __iter__ 通常更明确,也能被 Iterable ABC 正确识别。
成员判断回退
没有 __contains__ 时,in 可以逐项迭代,最坏为 O(n)。如果对象内部有哈希索引,应考虑专用实现。
反向遍历回退
没有 __reversed__ 时,reversed() 可以组合 __len__ 和整数 __getitem__。链式结构如果随机访问昂贵,应提供专用反向迭代。
真假回退
没有 __bool__ 时,Python 使用 __len__。如果长度计算很昂贵,而真假只需要检查一个简单状态,就应显式实现 __bool__。
能运行只是第一步。还要确认默认路径是否符合数据结构的复杂度。
9. 避免在基础协议中隐藏副作用
调用者对这些操作通常有稳定预期:
len(obj)
bool(obj)
repr(obj)
hash(obj)
item in obj它们最好是快速、可重复且没有明显副作用的。
不建议在其中:
- 发起网络请求;
- 消费一次性数据流;
- 写入数据库;
- 修改关键业务状态;
- 生成大量日志;
- 执行不可预测的长时间计算。
如果操作本身昂贵或会改变状态,应使用能够表达成本和动作的普通方法:
job.refresh_status()
stream.consume_and_count()
repository.count_remote_records()方法名让调用者知道这不是一次廉价的语言级查询。
10. 对象表示应服务调试,但不能泄露敏感信息
一个好的 __repr__ 应该:
- 显示类型和关键状态;
- 尽量明确字段的类型和边界;
- 不执行昂贵查询;
- 不改变对象;
- 不暴露密码、令牌和私钥;
- 在对象部分初始化时也尽量稳定。
例如:
def __repr__(self):
return f"Client(host={self.host!r}, token='***')"__str__ 只有在确实需要不同的用户友好文本时再实现。没有明显区别时,只写可靠的 __repr__ 可以减少两种表示发生分歧的机会。
11. ABC 是行为承诺,不只是代码复用
collections.abc 可以承担三类作用:
- 为一组能力提供名称,例如
Iterable、Sequence、Mapping; - 在实例化时检查抽象方法是否已实现;
- 提供
index()、count()、items()等混入实现。
继承 ABC 前需要确认:
- 对象是否真的符合完整语义;
- 混入方法是否符合性能要求;
- 调用者是否应该依赖这个分类;
- 更小的协议集合是否已经足够。
一个对象能被 for 遍历,不一定被 Iterable 的运行时检查识别;实现 __len__ 和 __getitem__,也不一定自动成为 Sequence。协议行为、ABC 识别和显式继承需要分别理解。
12. 用行为验证协议,而不是只测方法本身
测试特殊方法时,应通过调用者真正使用的公开入口验证。
如果实现 __len__,优先测试:
assert len(obj) == expected_length而不只测试:
assert obj.__len__() == expected_length前一种写法还能验证返回值是否满足长度协议。
容器对象通常需要验证以下行为:
- 空对象和非空对象的长度;
- 第一个、最后一个和负数位置;
- 切片及其返回类型;
- 越界异常;
- 正向和反向迭代顺序;
- 成员存在与不存在;
- 迭代、长度和索引之间的一致性。
数值对象通常需要验证:
- 运算结果;
- 原对象是否保持不变;
- 不支持类型的失败方式;
- 正向和反向操作;
- 相等性与哈希不变量;
- 零值、负值和边界值。
这些不是额外的“特殊方法测试”,而是对象公开行为的正常测试。
13. 综合案例的需求
现在设计一个真实一些的只读搜索结果对象。
单条结果 SearchHit 包含:
document_id:文档标识;score:相关性分数;title:标题。
结果集合 SearchResults 需要:
- 保存查询文本和有序结果;
- 支持
len(results); - 支持整数索引、负数索引和切片;
- 支持迭代、反向遍历、成员判断、
index()和count(); - 切片后仍返回
SearchResults,保留查询语义; - 提供安全、简洁的调试表示;
- 不允许调用者通过下标修改结果;
- 不自行实现排序比较,因为相关性顺序已经由搜索服务给出。
根据需求,可以选择:
- 使用冻结的数据类表达不可变的
SearchHit; - 让
SearchResults继承Sequence; - 内部使用元组保存结果;
- 实现
__len__、__getitem__和__repr__; - 复用
Sequence的其他混入方法。
14. 综合案例的完整实现
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class SearchHit:
document_id: str
score: float
title: str
class SearchResults(Sequence):
def __init__(self, query, hits):
self.query = query
self._hits = tuple(hits)
def __len__(self):
return len(self._hits)
def __getitem__(self, index):
selected = self._hits[index]
if isinstance(index, slice):
return type(self)(self.query, selected)
return selected
def __repr__(self):
return (
f"SearchResults(query={self.query!r}, "
f"hits={len(self._hits)})"
)
if __name__ == "__main__":
results = SearchResults(
query="python data model",
hits=[
SearchHit("doc-101", 0.98, "Special methods"),
SearchHit("doc-205", 0.91, "Container protocols"),
SearchHit("doc-309", 0.87, "Operator overloading"),
],
)
print(results)
print(len(results))
print(bool(results))
print(results[0])
print(results[-1])
top_two = results[:2]
print(top_two)
print(type(top_two).__name__)
print([hit.document_id for hit in results])
print([hit.document_id for hit in reversed(results)])
print(results[1] in results)
print(results.index(results[1]))输出为:
SearchResults(query='python data model', hits=3)
3
True
SearchHit(document_id='doc-101', score=0.98, title='Special methods')
SearchHit(document_id='doc-309', score=0.87, title='Operator overloading')
SearchResults(query='python data model', hits=2)
SearchResults
['doc-101', 'doc-205', 'doc-309']
['doc-309', 'doc-205', 'doc-101']
True
115. 综合案例的协议分析
SearchHit 为什么使用冻结的数据类
SearchHit 是一个值对象。frozen=True 阻止普通字段赋值,并让自动生成的相等性、表示和哈希行为建立在稳定字段上。
hit = SearchHit("doc-101", 0.98, "Special methods")自动生成的 repr 已经足够明确:
SearchHit(document_id='doc-101', score=0.98, title='Special methods')无需为它重复手写 __repr__、__eq__ 和 __hash__。
冻结的数据类并不保证字段引用的所有内部对象都深度不可变。本例字段都是字符串和浮点数,因此适合这种设计。
SearchResults 为什么继承 Sequence
搜索结果具有稳定顺序,并且按位置读取有明确意义。实现 __len__ 和 __getitem__ 后,Sequence 提供:
- 迭代;
- 反向遍历;
- 成员判断;
index();count()。
这些默认行为都符合当前数据量和元组存储结构。
为什么切片返回同类型对象
内部元组的切片结果仍是元组。如果直接返回它,查询文本会丢失。当前实现识别 slice:
selected = self._hits[index]
if isinstance(index, slice):
return type(self)(self.query, selected)整数索引返回单个 SearchHit,切片返回新的 SearchResults。这是一项明确的领域选择,不是所有序列都必须如此。
使用 type(self) 而不是写死 SearchResults,可以让行为在简单子类中保留具体类型。不过,如果子类构造函数签名不同,它仍可能需要覆盖 __getitem__。
为什么没有实现 __bool__
SearchResults 已经实现 __len__。真假语义正好是“当前是否有结果”,所以使用长度回退即可:
0 项 -> False
非 0 项 -> True额外实现相同逻辑的 __bool__ 只会增加重复。
为什么没有实现 __contains__
当前结果数量较小,Sequence 的线性成员判断已经足够。如果实际系统需要频繁按 document_id 查询,应明确增加索引并定义查询接口,例如:
results.get_by_id(document_id)hit in results 判断的是完整 SearchHit 值是否存在,而不是字符串 ID 是否存在。不要为了速度在 __contains__ 中悄悄改变成员语义。
为什么没有实现比较和加法
两个搜索结果集合如何比较大小并不明确:按结果数量、最高分还是查询文本?两个集合相加是否允许不同查询混合?
缺少自然且稳定的答案时,不实现运算符比提供任意规则更好。
16. 用公开行为验证综合案例
下面的断言可以直接接在类定义后运行:
hits = [
SearchHit("a", 0.9, "A"),
SearchHit("b", 0.8, "B"),
]
results = SearchResults("query", hits)
assert len(results) == 2
assert bool(results) is True
assert bool(SearchResults("query", [])) is False
assert results[0] == hits[0]
assert results[-1] == hits[-1]
sliced = results[:1]
assert isinstance(sliced, SearchResults)
assert sliced.query == "query"
assert list(sliced) == hits[:1]
assert list(results) == hits
assert list(reversed(results)) == list(reversed(hits))
assert hits[0] in results
assert results.index(hits[1]) == 1
try:
results[2]
except IndexError:
pass
else:
raise AssertionError("out-of-range access must raise IndexError")这段验证没有直接调用 __len__ 或 __getitem__,而是使用 len()、下标、切片、迭代和成员判断等公开行为。它同时检查了协议组合是否符合预期。
17. 交付自定义类型前的检查清单
在真实项目中,可以从以下方面复核设计。
语义
- 每项特殊方法是否对应自然、明确的对象行为;
- 调用者能否根据 Python 内置类型经验预测结果;
- 是否为了追求“功能完整”实现了没有必要的协议;
- 普通业务方法是否被错误包装成双下划线方法。
协议
- 返回值类型是否符合约定;
- 越界、缺失、耗尽和不支持操作是否使用正确异常或
NotImplemented; - 正向、反向和增量运算是否保持正确操作数顺序;
__eq__与__hash__是否一致;__repr__与__str__是否各自服务明确场景。
状态与可变性
- 普通运算是否意外修改输入对象;
- 对象作为字典键后,哈希相关状态是否可能变化;
- 是否无意间通过继承具体容器公开了修改接口;
- 切片返回类型是否符合领域预期。
性能
len()、bool()和repr()是否足够便宜;in的线性扫描是否可以接受;- ABC 的混入方法是否与底层数据结构复杂度匹配;
- 迭代和反向迭代是否使用了合理路径。
安全与可靠性
- 对象表示是否泄露敏感信息;
- 基础协议中是否隐藏网络或数据库操作;
- 资源是否依靠
with确定释放,而不是依赖__del__; - 真假判断和字符串表示是否可能抛出难以诊断的二次异常。
验证
- 是否通过公开入口测试协议;
- 是否覆盖空值、边界值和错误类型;
- 是否验证异常类型;
- 是否验证原对象在普通运算后保持预期状态;
- 示例输出是否与实际运行一致。
18. 本章最终结论
Pythonic 的自定义类型不是“实现了很多双下划线方法”的类型,而是能够自然融入 Python 使用方式的类型。
这种自然感来自几个方面:
- 对象实现的协议与自身职责一致;
- 调用者使用熟悉的语法和内置函数;
- 返回值、异常和回退遵守语言约定;
- 行为与内置类型形成的直觉一致;
- 实现复用成熟的数据结构和标准库;
- 不需要的能力没有被随意公开;
- 性能、副作用和可变性符合调用者预期。
纸牌案例说明了少量序列协议如何接入索引、切片、迭代和标准库;向量案例说明了自定义数值对象如何接入表示、绝对值、真假和运算符;容器 ABC 则说明了如何把小协议组合成更完整的接口。
掌握数据模型后,看到 len(obj)、obj[index]、for item in obj 或 left + right 时,不仅能使用这些语法,还能理解解释器在询问对象什么,以及自己的类型应该作出怎样的回答。
拓展阅读与查阅
以下资料用于进一步研究或日后查阅。理解本节内容不依赖这些链接。
