类型标注与静态类型基础
本节目标
查询类型标注语法、常用类型构造和静态检查边界。
类型标注为接口附加信息,但不会自动验证运行时数据。本章可以从零独立阅读,不要求使用过类型检查器;它接续上一章的函数、参数与作用域,分别说明标注语法、静态工具可利用的关系,以及运行时反射的求值边界。语法和反射行为以 Python 3.14 为基线,完整构造可对照官方 typing 文档。
类型标注的目的与运行时边界
类型标注是附着在名称和接口上的元数据。编辑器、文档工具和静态类型检查器可在程序运行前读取它并报告不一致,Python 解释器却通常不会因实参或赋值不符合标注而拒绝执行。它能表达设计意图,但不能充当输入验证器、转换器或访问控制机制。
def double(value: int) -> int:
return value * 2
result = double("py")
assert result == "pypy"
静态检查器可以把 double("py") 诊断为错误,普通运行仍会遵循字符串乘法规则。网络请求、配置或用户输入进入系统时,必须另行解析和检查,并在失败时抛出合适异常。一次运行成功也不能证明类型合同无误:不匹配的值可能只是碰巧支持当前路径使用的操作。
变量、参数与返回值标注
变量写作 name: Type 或 name: Type = value;函数参数在名称后写标注,返回值在参数列表后写 -> Type。类体中的属性标注描述预期的实例属性,self.attribute: Type = value 也能在初始化代码中记录实例属性的意图。
attempts: int = 0
class Profile:
name: str
def __init__(self, name: str, nickname: str | None = None) -> None:
self.name = name
self.nickname: str | None = nickname
def display(profile: Profile) -> str:
return profile.nickname or profile.name
仅写 name: str 不会创建字符串,也不会让属性自动出现,初始化仍要依靠普通赋值。函数的 __annotations__ 以参数名为键,并用 'return' 保存返回标注;模块和类也可拥有各自的 __annotations__。在 Python 3.14 中,这个属性采用延迟求值,读取动作可能触发计算,而非无副作用地查看一份预先生成的字典。
内置泛型、联合与可选值
现代标注可直接在内置类型上表达容器元素:list[int] 表示整数列表,dict[str, int] 表示字符串键到整数值的映射。联合类型写作 A | B,因此“字符串或没有值”可表示为 str | None。这些形式描述静态允许的类型集合,不会在每次列表追加时自动检查元素。
def total(scores: list[int]) -> int:
return sum(scores)
def increment(counts: dict[str, int], key: str) -> None:
counts[key] = counts.get(key, 0) + 1
def normalized_name(name: str | None) -> str:
if name is None:
return "anonymous"
return name.casefold()
在 normalized_name 的 if 分支排除 None 后,类型检查器可以把余下路径中的 name 收窄为 str。收窄来自控制流事实;如果用断言或自定义判断欺骗工具,运行时仍可能收到不符合预期的对象。容器是否可变、键是否可哈希等运行时规则也不会因泛型参数而改变。
type 语句与类型别名
Python 3.12 起,PEP 695 引入 type 语句。它明确声明一个类型别名;运行时名称绑定到 typing.TypeAliasType 对象,别名右侧按需求值。普通赋值 UserId = int 只是把 int 对象绑定给另一个名称,本身没有“这是类型别名”的明确语义。
type UserId = int
type Row = dict[str, int | str]
type Tree[T] = T | list[Tree[T]]
def load(user_id: UserId) -> Row:
return {"id": user_id, "name": "Ada"}
为兼容较早 Python 或既有工具,旧代码可能导入 TypeAlias,再声明 UserId: TypeAlias = int。TypeAlias 如今已由新的 type 语句取代并标记为弃用。无论采用哪种形式,别名只是为复杂形状命名,不会创建新的运行时值类型,也不会把 UserId 对应的整数与其他 int 分隔开。
泛型函数、类与类型参数
PEP 695 的方括号语法把类型参数直接声明在函数或类名之后。一个类型参数表示调用之间可以变化、但同一次类型关系中需要保持一致的未知类型。
def first[T](items: list[T]) -> T:
return items[0]
class Box[T]:
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
name = first(["Ada", "Guido"])
number_box = Box(314)
工具可由 list[str] 推断 first 返回 str,也可将 Box(314) 视为保存 int 的 Box。类型参数只在相应泛型声明的词法范围内有效,泛型函数、类和别名在运行时还公开 __type_params__。这里关注输入与输出共享同一参数的关系,协变、逆变等高级规则不在本章范围内。
Callable、Literal、Protocol 与 TypedDict
typing 还提供面向不同任务的构造:
Callable[[bytes], str]描述接收一个bytes并返回str的可调用对象。Literal["draft", "published"]把允许值缩小到列出的字面量。Protocol按所需属性和方法描述结构接口,让不显式继承该协议的类型也能被静态接受。TypedDict描述字典需要哪些键以及各键的值类型;实际值仍是普通dict,运行时不会自动验证键和值。
from typing import Callable, Literal, Protocol, TypedDict
type Decoder = Callable[[bytes], str]
type Status = Literal["draft", "published"]
class Named(Protocol):
name: str
class Record(TypedDict):
name: str
status: Status
def decode_and_record(data: bytes, decode: Decoder) -> Record:
return {"name": decode(data), "status": "draft"}
构造应与任务对应:函数签名使用 Callable,少量固定取值使用 Literal,对象能力边界使用 Protocol,已有字典记录的形状使用 TypedDict。这些工具主要服务静态分析;运行时验证仍需单独实现。
静态检查器工作流与渐进类型
渐进类型允许已标注代码与未标注代码共存,无须一次改完整个项目。可以先覆盖稳定的公共接口和高风险数据边界,在编辑器或 CI 中运行团队选定的检查器,根据诊断修正实现或合同,再通过程序和测试验证运行时行为。本章不指定、安装或运行第三方检查器。
Any 是渐进边界中的显式逃生口。工具通常允许 Any 与其他类型双向流动,也允许在其上执行任意操作,因此它会传播并削弱后续检查。对暂时未知的数据可以先用 Any 隔离,但应在边界尽快解析成更具体的类型;若只知道“某个对象”而不允许任意操作,object 往往能保留更多检查价值。
静态检查器分析声明和可见控制流,测试则执行具体路径;前者不能替代测试,后者也难以穷举所有类型组合。两者与显式运行时验证分别负责不同层次,都有保留的必要。
Python 3.14 延迟求值标注
Python 3.14 默认采用 PEP 649 的延迟求值模型:函数、类和模块的标注通常到访问时才求值,而非定义语句执行时立即求值。所以下面的 normalize 可以先引用尚未出现的 User;等脚本调用 get_type_hints(normalize) 时,该类已经存在。这个结论有明确版本边界:Python 3.13 及更早版本默认立即求值,显式启用 from __future__ import annotations 后的字符串化模型也不同于 3.14 默认行为。
带标注的对象在 3.14 中可提供 __annotate__(format);无标注对象的 __annotate__ 通常是 None。编译器为函数、类和模块生成的 annotate function 在直接调用时只支持 annotationlib.Format.VALUE;自定义 annotate function 可以自行支持其他格式,因此这不是所有实现的统一限制。普通调用方应使用 annotationlib.get_annotations();需要直接处理 annotate function 时,则使用更底层的 annotationlib.call_annotate_function(),由标准库按请求格式调用、转换或求值。两条路径仍可能执行标注相关代码并抛出异常。__annotations__ 则是面向调用者的延迟属性:成功求值后得到名称到标注值的字典,求值期间同样可能抛出异常或执行代码。
代表脚本同时使用 type 别名和泛型函数。其中 identity("not-an-int") 故意把 str 传给标为 UserId(即 int 别名)的参数,运行时仍返回字符串,由此直接展示标注信息与强制验证的区别。
from typing import get_type_hints
def normalize(user: User) -> User:
return user
class User:
pass
type UserId = int
def identity(value: UserId) -> UserId:
return value
def first[T](items: list[T]) -> T:
return items[0]
resolved = get_type_hints(normalize)
runtime_value = identity("not-an-int")
print(f"deferred={normalize.__annotate__ is not None}")
print(f"resolved={resolved['user'].__name__}")
print(f"runtime-value={type(runtime_value).__name__}")
print(f"generic-first={first([3, 14])}")
deferred=True
resolved=User
runtime-value=str
generic-first=3
这里的 deferred=True 只证明该带标注函数具有非空 __annotate__,不是说所有访问都永远不会触发求值。get_type_hints() 随后解析标注,才得到实际的 User 类。输出来自固定对象名称、固定字符串和固定列表,不依赖地址或环境状态。
运行时反射与类型提示边界
需要取得已解析的类型提示时,可使用 typing.get_type_hints(),不必自行对字符串调用 eval()。它会在合适的全局、局部和类型参数命名空间中解析字符串或 forward reference,处理类时还会合并基类标注。若引用名称仍未定义,解析会抛出 NameError;标注表达式本身出错时还可能出现其他异常。
Python 3.14 新增的 annotationlib 提供更底层且可选择格式的 get_annotations():Format.VALUE 请求实际值;Format.FORWARDREF 尽量解析已有名称,并把无法解析的名称保留为 ForwardRef 代理;Format.STRING 适合需要近似源码文本的展示工具。forward reference 可以来自定义时尚不存在的名称,也可能来自显式字符串;得到代理并不等于它已安全解析,更不等于对应对象一定会在稍后出现。
这些反射路径都可能执行代码:访问 __annotations__,调用 get_type_hints() 或 annotationlib.get_annotations(),都可能运行标注相关表达式;FORWARDREF 与 STRING 格式也不是处理不受信输入的安全沙箱。不可信字符串不能先塞进标注字典或 ForwardRef 再求值。仅用于展示时,应选择满足需求且求值最少的格式,处理可能出现的异常,并让反射与业务输入验证保持分离。
排查类型标注问题时,依次确认 Python 版本与是否启用 future import、问题属于静态诊断还是运行时异常、读取方要求原始映射还是解析后的提示,以及命名空间中是否确实存在 forward reference 的目标。这样可以避免把 NameError 误判为检查器问题,也避免把一次运行成功误当成标注已被强制执行。