PEP 827 讨论“类型操作”时,把一个看似底层、实际会影响整个生态的问题摆到了台面上:类型注解在运行时究竟应该以什么形式保存?是已经求值的 Python 对象、保留源码形态的字符串,还是某种可以延迟解析、继续变换的结构?这不仅决定反射 API 怎么设计,也会影响类型别名、框架启动成本、代码生成和静态分析工具。
来源摘要只指出了这项关键设计决策,并未给出完整语法或最终存储方案。下面不会假设 PEP 827 已经选择了某个答案,而是从现有 Python 行为出发,分析不同选择会带来什么工程后果。
同一条注解,至少有三种“身份”
考虑下面的函数:
UserId = int
class User:
pass
def find_user(user_id: UserId) -> User | None:
...
UserId 可以被理解为三种不同的东西:
- 源码文本:名称就是
UserId,工具可以保留作者表达的领域含义。 - 求值后的对象:运行时得到
int,便于执行isinstance一类操作,但别名信息可能消失。 - 结构化类型表达式:它是一个可遍历、可替换的类型节点,既不只是字符串,也不必立刻变成普通 Python 对象。
这三种身份服务于不同消费者。
Web 框架通常希望解析参数并生成校验器;文档工具希望保留别名和原始名称;静态分析器需要理解泛型、联合类型和作用域;运行时库则关心解析成本与导入副作用。所谓“如何存储注解”,实质是在这些需求之间确定公共表示。
字符串、对象与延迟表示的取舍
直接保存求值后的对象
这种形式使用方便:库拿到注解后,可以马上检查它是否为某个类、联合类型或泛型实例。
代价也很明显:
- 求值可能要求提前导入其他模块;
- 前向引用需要特殊处理;
- 类型别名可能被折叠,丢失作者写下的名称;
- 注解中的表达式可能产生运行时副作用;
- 某些只供类型检查器理解的表达式未必适合直接执行。
保存为字符串
字符串能推迟求值,也更容易保留源码拼写。然而,任何需要理解类型结构的工具都必须再次解析字符串,并重新构造定义注解时的全局和局部命名空间。
字符串还把结构操作变成了文本操作。把 list[User] 中的 User 替换为 PublicUser,不能安全地依赖普通的 str.replace,因为相同单词可能出现在模块路径、字符串字面量或其他名称中。
保存为延迟计算或结构化表示
另一条设计路线,是保存能够稍后求值的描述,或者保存类似抽象语法树的类型结构。这会让“遍历类型”“替换类型参数”“保留别名”等操作更自然,但也会引入新的问题:
- 表示是否稳定,能否跨 Python 版本使用?
- 它捕获哪些命名空间,捕获多久?
- 求值结果是否缓存?
- 用户代码能否自行构造或修改这种表示?
- 第三方库应该依赖公开协议,还是依赖解释器内部对象?
这正是存储决策的重要性:它不是简单地选择一种容器,而是在定义运行时类型工具的边界。
用现有 Python 观察信息损失
在 PEP 827 的具体 API 尚未成为应用代码前,可以先用现有的 inspect.get_annotations 观察原始表示与求值结果之间的区别。
下面的程序可在 Python 3.10 及以上版本运行:
from __future__ import annotations
import inspect
UserId = int
class User:
pass
def find_user(user_id: UserId) -> User | None:
return None
raw = inspect.get_annotations(find_user, eval_str=False)
resolved = inspect.get_annotations(find_user, eval_str=True)
print("raw:", raw)
print("resolved:", resolved)
保存为 annotations_demo.py 后执行:
python annotations_demo.py
你会看到类似结果:
raw: {'user_id': 'UserId', 'return': 'User | None'}
resolved: {'user_id': <class 'int'>, 'return': __main__.User | None}
原始形式保留了 UserId 这个名称;求值之后,它变成了 int。对参数校验器来说,这可能正合适;对 API 文档生成器来说,领域名称 UserId 的消失却可能是损失。
这里还有一个安全边界:eval_str=True 需要对字符串注解进行求值。应用不应该对不可信代码或不可信注解盲目执行这一操作。框架可以把“读取原始注解”和“在受控命名空间中解析注解”设计成两个明确阶段。
可以这样实践:先把读取、解析与变换分层
在 PEP 827 的最终接口明确之前,库作者可以避免直接修改 __annotations__,并把注解处理拆成三层:
- 读取层:通过公开 API 获取注解,不立即求值。
- 解析层:在明确的模块和命名空间中解析。
- 变换层:操作自己的中间表示,而不是对字符串做替换。
下面是一个最小的结构化变换实验。它不是 PEP 827 的 API,只是演示为什么结构表示比字符串替换更安全:
import ast
class RenameType(ast.NodeTransformer):
def __init__(self, old_name: str, new_name: str) -> None:
self.old_name = old_name
self.new_name = new_name
def visit_Name(self, node: ast.Name) -> ast.AST:
if node.id == self.old_name:
return ast.copy_location(
ast.Name(id=self.new_name, ctx=node.ctx),
node,
)
return node
def rename_type(annotation: str, old_name: str, new_name: str) -> str:
tree = ast.parse(annotation, mode="eval")
transformed = RenameType(old_name, new_name).visit(tree)
ast.fix_missing_locations(transformed)
return ast.unparse(transformed)
print(rename_type("list[User] | None", "User", "PublicUser"))
print(rename_type("Mapping[str, list[User]]", "User", "PublicUser"))
运行结果应类似:
list[PublicUser] | None
Mapping[str, list[PublicUser]]
这段代码仍然只是实验:它没有处理名称绑定、导入、类型别名身份或求值环境,因此不能直接作为完整类型系统使用。但它揭示了一个关键区别——类型操作需要节点级语义,而不仅是字符级替换。
采用相关能力前应检查什么
如果 PEP 827 后续为类型操作提供公开能力,框架和库不必立即把内部实现全部替换掉。更稳妥的评估清单是:
- 是否能保留类型别名,而不是过早折叠为底层对象?
- 前向引用在哪个命名空间中解析?
- 读取注解是否会触发导入或执行用户代码?
- 延迟结果是否缓存,缓存会不会保留模块或局部对象?
- 变换后的类型能否被
inspect、文档工具和类型检查器共同理解? - API 是稳定的公共协议,还是解释器实现细节?
- 在不支持新能力的 Python 版本上,降级路径是什么?
PEP 827 所触及的关键并不是“让注解更动态”这么简单,而是如何同时保留类型表达式的含义、结构和运行时可用性。对应用开发者而言,当前最实际的策略是隔离注解读取逻辑、避免依赖 __annotations__ 的偶然形态,并把任何求值行为视为需要明确控制的执行边界。