__prepare__ 方法是在 Python 3.0 通过 PEP 3115 引入的,以解决类创建协议中的基本限制。在此更改之前,在类体执行期间使用的命名空间总是一个标准字典,没有办法保持属性声明顺序或拦截分配。这对开发 ORM 和需要跟踪字段声明顺序的序列化库的开发者特别成问题,而这些开发者不希望依赖脆弱的源代码解析。
当 Python 执行类体时,它填充一个命名空间映射,最终将成为类的 __dict__。默认的 dict 类型在较旧的 Python 版本中不保证插入顺序,并且缺少在定义时验证或转换名称的钩子。要求声明时约束的开发人员 — 例如禁止某些命名模式或跟踪二进制协议的字段顺序 — 没有干净的机制来在类对象最终确定之前挂钩到类构建的特定阶段。
通过将 __prepare__ 实现为元类中的静态方法,可以返回一个自定义的可变映射(例如 collections.OrderedDict 或自定义验证字典)作为命名空间。这个映射在类体执行期间捕获所有类级别的赋值,允许在元类 __new__ 方法最终确定类之前进行预处理。然后将自定义命名空间传递给 __new__,在这里可以将其转换为标准的 dict 或保留以便有序访问。
from collections import OrderedDict class OrderPreservingMeta(type): @staticmethod def __prepare__(name, bases, **kwargs): return OrderedDict() def __new__(mcs, name, bases, namespace, **kwargs): ordered_attrs = list(namespace.keys()) cls = super().__new__(mcs, name, bases, dict(namespace)) cls._declaration_order = ordered_attrs return cls class Schema(metaclass=OrderPreservingMeta): id = 1 name = "test" value = 3.14 print(Schema._declaration_order) # ['id', 'name', 'value']
一个金融交易平台需要生成二进制消息格式,其中协议头中的字段顺序严格匹配 Python 消息类定义中的声明顺序。重新排序字段会破坏与旧版 C++ 解析器的兼容性,导致交易被拒绝或系统崩溃。
解决方案 A:手动索引。 开发人员会为每个字段注释一个序列号,如 field_order = 1。这种方法对初学者来说显而易见且易于理解。然而,它违反了 DRY 原则,在重构时会成为一个维护负担,因为在中间插入一个字段需要重新编号所有后续字段。
解决方案 B:源代码解析。 框架可以使用 AST 模块解析类定义源代码并提取赋值顺序。这在没有元类复杂性的情况下工作。不幸的是,当在运行时无法获得源文件时,比如在冻结的二进制发行版或优化的 CPython 部署中剥离源代码时,它完全失效。
解决方案 C:使用 __prepare__ 的元类。 通过从 __prepare__ 返回一个 OrderedDict,元类自动捕获自然声明顺序。这在所有部署场景中都是可靠的,对最终用户是透明的。唯一的缺点是理解 Python 元类协议的额外复杂性,这需要高级知识。
选择的解决方案: 团队选择了解决方案 C,因为它在不增加每个消息实例运行时开销的情况下提供定义时保证。在所有部署环境中都能可靠工作,包括那些没有源代码的环境,同时保持开发人员所期望的自然类语法,并在尽可能早的阶段强制执行约束。
结果: 消息库自动维护了线格式兼容性。开发人员编写了自然的类定义,系统生成了正确的二进制布局。继承结构在子字段之前正确保留了父字段顺序,解决了交易协议规范中的复杂问题而无需人工干预。
问题 1:为什么必须将 __prepare__ 定义为 @staticmethod(或 @classmethod),而不是常规实例方法,如果省略此修饰符会发生什么错误?
回答:__prepare__ 在元类实例创建之前被调用,这意味着没有可绑定的 cls 或 self。Python 调用 __prepare__ 以生成将传递给 __new__ 的命名空间。如果将其定义为常规实例方法并期望 self,Python 将引发 TypeError,指示该函数接受位置参数但没有提供,因为该机制试图仅用名称、基类和关键字参数调用它。它必须是一个静态方法,以便在没有隐式第一个参数绑定的情况下被调用,虽然如果需要访问元类本身,classmethod 也是可行的。
问题 2:__prepare__ 可以返回不是 dict 子类的映射吗?它要遵循什么特定协议才能在类体执行期间正常工作?
回答:是的,它可以返回任何实现 MutableMapping 抽象基类协议的可变映射,具体要求包括 __setitem__、__getitem__、__contains__,理想情况下 __iter__ 或 keys() 进行转换。然而,该映射不必继承自 dict。关键要求是它必须接受字符串键和任意值,在类体中的属性赋值过程中表现得像字典。在类执行后,元类的 __new__ 将接收此映射;如果不是 dict 子类,您必须在调用 super().__new__ 之前显式转换它(例如,dict(namespace)),因为生成的类对象的 __dict__ 必须是一个字典。
问题 3:__prepare__ 如何处理传递给类定义头的关键字参数(例如,class MyClass(metaclass=Meta, strict=True)),如果这些参数未正确转发,会发生什么?
回答:类头中的关键字参数(而超出 metaclass 的部分)作为 **kwds 传递给 __prepare__。如果 __prepare__ 不接受 **kwargs(或特定命名参数),Python 将引发 TypeError,说明 __prepare__ 收到了意外的关键字参数。这是向元类添加配置选项时的常见陷阱。方法签名必须是 __prepare__(name, bases, **kwargs) 以保持向后兼容。这些关键字也会随后传递给 __new__ 和 __init__,允许元类在准备时接收配置以自定义命名空间行为(例如,在严格和宽松验证模式之间选择)。