本页目录

Python I · 数据模型与惯用法

对标:Fluent Python(Ramalho)/ CPython 官方数据模型 | 前置:CS61A(抽象、高阶函数)、pl-02(动态类型、鸭子类型的理论) 你天天用 Python 写 Medusa——但"会用"和"懂它的设计"是两回事。这一页讲 Python 之所以是 Python 的那套核心机制:一切皆对象的数据模型、特殊方法(dunder)如何让你的类无缝接入语言、鸭子类型的哲学、以及迭代器/生成器这套让 Python 代码"Pythonic"的惯用法。理解它,你会从"照着写"升级到"按语言的意图写",Medusa 的数据处理代码会更干净。

学习层:同一个运算符,为什么会调用完全不同的代码?

具体谜题:+= 是改对象还是换绑定?

执行 a=[1,2]; b=a; b += [3] 后,a 与 b 是否仍指向同一个列表?把相同形状换成元组 t=(1,2); u=t; u += (3,),结果又是什么?随后对一个迭代器连续调用三次 next,它必须保持什么不变量?

先预测身份、协议和耗尽状态

预测:① 列表通常通过 __iadd__ 原地扩展,a is b 仍为真且两者看到 3;② 元组不可变,__iadd__ 回退为新元组绑定,t is u 为假;③ 迭代器每次成功 next 都推进内部游标,耗尽后稳定抛出 StopIteration。

最小心智模型:名字、对象、协议

Python 名字绑定对象,语言操作通过数据模型协议分派到特殊方法;“鸭子类型”检查的是所需行为而非名义继承。迭代器是带状态的对象,生成器把状态机写成可暂停的控制流;理解身份与可变性比背诵语法更能预测程序行为。

形式机制与不变量

对表达式 \(x+y\),可抽象为查找 \(type(x).__add__(x,y)\),若返回 NotImplemented 再尝试反向协议;len(x) 对应 \(x.__len__()\)。赋值只改变环境映射 \(\rho(name)=object\),不复制对象。迭代器满足 \(\mathrm{iter}(it)=it\),并维护游标 \(k\):成功调用返回 \(S[k]\) 后令 \(k\leftarrow k+1\),\(k=|S|\) 后所有后续调用都保持“已耗尽”。

协议实现的正确性不变量是返回值与异常符合调用方约定;可变对象的别名操作必须明确是否改变共享对象。

反例与失效边界

  • “看起来像鸭子”不是任意错误都能推迟到运行时;协议缺失、错误返回类型和副作用仍需测试或静态工具发现。
  • += 的具体行为取决于类型是否实现原地协议;不可变对象的回退会产生新对象,但容器中的别名可能让观察结果更复杂。
  • 生成器不是免费并行:它只交错执行一个线程中的暂停点,I/O、异常传播和资源关闭仍要设计。

迁移任务:把协议账本用于真实数据管线

为一个 Medusa 数据对象写出 __iter__、__next__、__len__ 和 __getitem__ 的契约,记录别名与耗尽状态。再把列表推导改成生成器,测量峰值内存,并说明这和 pl-02 的纯函数/惰性求值、L01 的数据布局有什么联系。

无 JavaScript 时的静态读法:a=[1,2]; b=a; b += [3] 通常调用列表的原地扩展,所以 a is b 为真,内容都是 [1,2,3];元组没有可变原地扩展,u += (3,) 绑定新元组,t is u 为假。一个三元素迭代器的游标依次为 0、1、2、3,再次 next 都抛 StopIteration。交互版切换协议实现,显示名字绑定、dunder 分派和迭代 trace。

\n+

操作协议状态变化可观察结果
len(x)len无整数长度
list += yiadd原对象扩展别名同时改变
next(it)next游标 +1耗尽后稳定异常

1. 一切皆对象:Python 的世界观

名字绑定对象:a=[1,2]; b=a 两名字指同一对象(对比 C 的值盒子)。

图 py-01.3名字绑定对象:a=[1,2]; b=a 两名字指同一对象(对比 C 的值盒子)。

Python 里一切都是对象——数字、字符串、函数、类、模块,全是对象,都有身份(id)、类型(type)、值。这不是口号,有实际后果:

2. 数据模型与 dunder:接入语言的钩子

dunder 协议:语法 len(x)/x[k]/for ↔ __len__/__getitem__/__iter__ 的钩子映射。

图 py-01.2dunder 协议:语法 len(x)/x[k]/for ↔ __len__/__getitem__/__iter__ 的钩子映射。

Python 最优雅的设计——特殊方法(dunder methods,__xxx__)让你的自定义类无缝接入语言的语法和内置函数。你不是"调用方法",而是"实现协议",然后语言的语法自动为你工作:

你写 Python 调用 你实现
len(x) x.__len__() 长度协议
x[k] x.__getitem__(k) 下标/切片
x + y x.__add__(y) 运算符重载
for i in x x.__iter__() 迭代协议
if x: x.__bool__() 真值测试
with x: x.__enter__/__exit__ 上下文管理
print(x) x.__repr__/__str__ 显示

核心洞察:Python 的语法是一层协议,dunder 是协议的实现点。实现了 __len__ 和 __getitem__,你的类就"是"一个序列——len()、下标、切片、迭代、in 全部自动可用。"实现协议而非继承基类"就是鸭子类型(下节)。这让 Python 的抽象极其灵活:写一个行为像列表的类,不需要继承 list,只要实现对的 dunder。

3. 鸭子类型:像鸭子叫就是鸭子

鸭子类型:"如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子"——Python 不检查对象的类型,只检查它有没有需要的方法/属性(🔗 pl-02 动态类型的实用哲学)。你要一个"能迭代的东西",任何实现了 __iter__ 的对象都行,不管它是不是某个基类的子类。

4. 迭代器与生成器:惰性的威力

生成器惰性:yield 逐条产出,内存恒定 vs list 全载。

图 py-01.1生成器惰性:yield 逐条产出,内存恒定 vs list 全载。

Python 惯用法的核心——迭代器协议 + 生成器:

def read_articles(cursor):          # 生成器:不把全部文章load进内存
    while (row := cursor.fetchone()):
        yield process(row)          # 逐条产出,内存恒定

对 Medusa 直接有用:处理上万篇文章时,用生成器逐条流式处理而非 list(所有文章)——内存从"全部数据"降到"一条"。生成器表达式 (f(x) for x in big)、itertools(惰性组合子)是 Python 处理大数据的正道。"能惰性就别急切"是 Pythonic 的重要一条。

5. Pythonic 惯用法速览

让代码"像 Python 而非翻译过来的 Java/C":

方法论:Pythonic 不是炫技,是"顺着语言的设计写"——推导式、生成器、上下文管理、鸭子类型都让代码更短、更清晰、更少 bug。写 Medusa 时多问一句"这段有没有更 Pythonic 的写法",代码质量会稳步提升。

6. 练习与要点

例 1(可变默认参数坑) 写 def append_to(x, lst=[]) 连续调两次,观察第二次 lst 里有上次的残留——理解"默认参数在定义时求值一次、可变对象被共享",Python 最著名的坑之一,Medusa 里要警惕。

例 2(实现一个序列) 写一个类只实现 __len__ 和 __getitem__,验证它自动支持 len()、下标、切片、for 迭代、in——亲手体会"实现协议就接入语言",dunder 的威力一次看懂。

例 3(生成器省内存) 把一个"读全部文章进 list 再处理"的函数改成生成器逐条 yield——用 sys.getsizeof 或内存监控对比。把"惰性流式"用到 Medusa 的真实数据处理。\(\blacksquare\)


下一页:Python II——运行时、GIL 与性能生态:CPython 底下是什么、为什么 Python"慢"、GIL 如何限制并发、以及 numpy/异步/打包这套生态怎么补救。