加法运算揭示了教程简写背后的机制
查看原文 05:40Python 的 a + b 看起来很简单,但解释器要怎样才能让它适用于数字、字符串和用户定义的对象?在这场 2016 年的演讲中,Flask 创建者 Armin Ronacher 认为,CPython 的内部决定已经足够可见,以至于程序会依赖它们。这种可见性给了开发者很大的能力,也增加了修改解释器的成本。以下例子描述的是他当时讨论的 Python 实现,并非当前内部机制的规范。
常见解释会说 a + b 等价于 a.__add__(b)。Ronacher 沿着他讨论的 CPython 实现追踪这个运算符:字节码到达加法操作后,会先为精确的整数和字符串类型走快速路径,再进行通用数字处理,必要时还会处理序列拼接。PyNumber_Add 虽然名字里有 Number,也参与序列相加。操作数的类型及其关系会影响分派。直接查找特殊方法走的是另一条路径;他说,某些情况下后者甚至更快,但并非总是如此。观看加法路径讲解。
这里的类型槽位是 C 类型结构中保存操作函数指针的位置,例如数字加法槽位;它不同于 Python 用来管理实例属性的 __slots__。对象指向其类型,解释器可以利用类型的槽位执行操作。因此,熟悉的简写适合入门,却不能完整描述实现。
历史上的类型槽位与 Python 方法需要桥接
查看原文 12:04Ronacher 回顾说,内置 C 类型和 Python 类最初是两套机制,同时也承认自己不确定确切的历史顺序。C 类型提供操作槽位,Python 类提供 __add__ 等特殊方法。解释器在两者之间搭桥:包装器把 C 槽位暴露为 Python 可见的方法,又把 Python 定义的特殊方法连接回类型槽位。数字加法与序列拼接使用不同的槽位,使这种对应关系比单个方法名暗示的更复杂。
这不仅是实现图中的细节:扩展开发者使用 C API,Python 程序也能观察到槽位包装器。Python 定义的函数与 C 槽位包装器是外观不同的对象。Ronacher 还提到描述符绑定:Python 方法可能把实例作为 self 绑定,而 conversion = int 这样的类属性则依赖 int 不以这种方式绑定。代码可能依赖这些差异,因此其他实现也必须复现原本看似偶然的行为。观看类型槽位讲解。
旧对象与代理展示了跨边界泄露的行为
查看原文 19:11Unicode 索引在存储方式与熟悉的访问之间取舍
查看原文 25:16早期字节字符串让程序员习惯直接访问字符串中的位置。Ronacher 描述说,Unicode 支持继承了这种预期,而字符所需的数值范围变大了。在他的 Python 2 例子中,窄构建可能把一个 emoji 暴露为两个代理单元;宽构建则每个单元使用四字节。UTF-8 使用变长编码,对常见文本可以节省空间,但定位任意字符位置比按固定宽度跨越单元需要更多工作。
他说,他讨论的 Python 3 实现会根据字符串中最大的字符选择存储宽度。他对 Unicode 历史和效率的说法是用于教学的简化;按码点索引也不一定等于选取用户感知的一个字符。更普遍的问题是:当实现行为变成使用者的预期后,它会持续影响后续设计。观看 Unicode 讨论。
调用帧访问支持了开发者依赖的工具
查看原文 31:43模块缓存让可发现性成为兼容性约束
查看原文 37:27sys.modules 暴露了一个按名称保存已导入模块的缓存。这让已加载模块可以被发现:代码能按名称找到模块及其中的类。Ronacher 提到 pickle、复制机制和自定义导入代码都会使用这种共享状态。Pickle 用于序列化 Python 对象,而持久保存的数据可能让这些依赖延续很长时间。
在他描述的系统里,以同一个名称加载某个库的两个版本很困难,因为使用方期望该名称指向某个缓存模块。如果在查找中加入版本或作用域,就会改变代码和已保存数据中的既有假设。这是他对当时兼容性问题的分析,并非声称今天所有隔离或多版本加载方式都不可能。观看导入机制讨论。
共享类型与引用计数让解释器隔离更复杂
查看原文 39:57更窄的接口以牺牲内省能力换取演进空间
查看原文 46:19在观众问答中,一位听众建议使用较窄的公共 API。Ronacher 同意这样可能更容易演进,同时承认开发者会失去一些他所看重的内省能力。被问到特殊处理是否是性能所需时,他提出,更简单、更小的高频执行路径可能改善缓存局部性和分支预测,也可能让不常见的情况更快。这是他在讨论中提出的设计假设,演讲没有用基准测试证明它。
理解接口边界需要同时看两面:暴露内部细节能支持哪些有用工具?使用者又可能开始依赖哪些可观察细节?这是一项取舍,而不是否定内省或兼容性中任何一方的理由。观看问答。