PEP 827 的核心难题:可操作的类型注解究竟该如何存储

2026-09-30 31 预计阅读时间: 1 分钟
来源: blog.python.org AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

PEP 827 讨论“类型操作”时,把一个看似底层、实际会影响整个生态的问题摆到了台面上:类型注解在运行时究竟应该以什么形式保存?是已经求值的 Python 对象、保留源码形态的字符串,还是某种可以延迟解析、继续变换的结构?这不仅决定反射 API 怎么设计,也会影响类型别名、框架启动成本、代码生成和静态分析工具。

来源摘要只指出了这项关键设计决策,并未给出完整语法或最终存储方案。下面不会假设 PEP 827 已经选择了某个答案,而是从现有 Python 行为出发,分析不同选择会带来什么工程后果。

同一条注解,至少有三种“身份”

考虑下面的函数:

UserId = int

class User:
    pass

def find_user(user_id: UserId) -> User | None:
    ...

UserId 可以被理解为三种不同的东西:

  1. 源码文本:名称就是 UserId,工具可以保留作者表达的领域含义。
  2. 求值后的对象:运行时得到 int,便于执行 isinstance 一类操作,但别名信息可能消失。
  3. 结构化类型表达式:它是一个可遍历、可替换的类型节点,既不只是字符串,也不必立刻变成普通 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__,并把注解处理拆成三层:

  1. 读取层:通过公开 API 获取注解,不立即求值。
  2. 解析层:在明确的模块和命名空间中解析。
  3. 变换层:操作自己的中间表示,而不是对字符串做替换。

下面是一个最小的结构化变换实验。它不是 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__ 的偶然形态,并把任何求值行为视为需要明确控制的执行边界。


相关推荐