本文目录
下面这段代码能正常运行,尽管标注和实际传参对不上:
def add(a: int, b: int) -> int:
return a + b
print(add("x", "y")) # xy解释器不会因为类型标注而拒绝调用。标注写在源码里,主要给人、给 mypy / pyright / PyCharm 这类类型检查工具读;它们在不运行程序的前提下,分析名字绑定与调用是否自洽。把标注当成「可机器读的文档 + 可选的静态门禁」,而不是 C/Java 那种编译期硬约束。
上一篇若涉及 ABC,讲的是运行时协议与继承;类型标注则在源码层描述「期望的类型形状」,两者互补但不等价。ABC 的 register 可以在运行时放宽匹配;mypy 不会因为你在 ABC 上 register 了某个类就自动相信它满足 Protocol——那是两套系统。
基本写法:参数、返回值、变量
常见形式是参数注解、-> 返回类型,以及模块级或局部变量的注解:
def greet(name: str, times: int = 1) -> str:
msg: str = name * times
return msg
scores: dict[str, float] = {"math": 92.5}
ids: list[int] = [1, 2, 3]容器用内置泛型 list[int]、dict[str, int]、set[str](Python 3.9+),不必再从 typing 里 import List、Dict。旧代码里可能还见 List[int],语义相同,新项目优先小写内置写法。
嵌套容器也按同样规则:list[dict[str, int]] 表示「字典列表,键 str、值 int」。元组若长度固定,可用 tuple[int, str, bool];长度不固定但类型一致时用 tuple[int, ...](省略号表示可变长)。
可选值:Optional 与 X | None
「可能没有」有两种等价写法:Optional[T] 或 T | None(3.10+ 推荐后者):
from typing import Optional
def find_user(uid: int) -> str | None:
return None
def parse_count(raw: str) -> Optional[int]:
if raw.isdigit():
return int(raw)
return None调用方若忽略 None,静态检查器会提示;运行时仍可能 AttributeError——标注不替你做判空。正确习惯是在使用前收窄:
def label(uid: int) -> str:
user = find_user(uid)
if user is None:
return "unknown"
return user.upper()Optional[int] 与 int | None 对检查器是同义;团队选一种风格并在代码库里统一即可。
联合类型与 Any
多个可能类型用 X | Y(3.10+)或 Union[X, Y]:
def normalize(value: str | int) -> str:
return str(value)
def dump(data: object) -> None:
print(repr(data))Any 表示「静态分析放弃检查」——与不写标注不同,它明确告诉工具别管这个值。遗留代码迁移时常见;新代码里应尽量少用,否则标注体系形同虚设。
Protocol:按行为描述类型
有时你并不关心具体类名,只关心「有没有某个方法」。Protocol 描述这种结构化契约:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
class FileLike(Protocol):
def read(self, n: int = -1) -> str: ...
def shutdown(resource: SupportsClose) -> None:
resource.close()
def first_line(src: FileLike) -> str:
return src.read().splitlines()[0]任何带对应方法的对象都能传进去,不必继承某个基类。检查器按形状匹配,运行时仍是普通 duck typing。... 在 Protocol 方法体里表示「只有签名,无实现」。
类型检查能抓什么、抓不到什么
| 层面 | 行为 |
|---|---|
| 解释器执行 | 默认忽略标注,不强制转换 |
| 静态检查 | 明显类型不一致、漏判空、错误属性访问 |
| 运行时验证 | 需 pydantic、beartype 或手写断言 |
下面这类错误,mypy 会在运行前报出:
def total(nums: list[int]) -> int:
return sum(nums)
total(["a", "b"]) # 检查器:list 元素应是 int,不是 str下面这类则很难靠标注完全堵住——动态字符串当属性名、过度宽泛的 Any、缺 stubs 的 C 扩展库:
def get_attr(obj: object, name: str) -> Any:
return getattr(obj, name)渐进式采用与工程习惯
不必一次给全仓库补全标注。常见路径:
- 新模块从公共 API 开始带完整签名;
- 对改动频繁的模块逐步补标注;
- CI 里对指定目录跑
mypy --strict或 pyright,legacy 目录暂设宽松; - 第三方库缺类型时,查是否提供
types-前缀的 stubs 包。
# type: ignore[error-code] 应附带原因,并尽量局部化——整文件 ignore 会让后续改动失去保护。
标注的价值在于把「这里应该是 list[int]」写进源码,让读代码的人和工具在同一套语义上对齐。它不会让 Python 变成静态语言,但能在重构、协作和 IDE 补全里省下大量「这参数到底该传什么」的来回确认。
与运行时类型信息的区别
type(x)、isinstance(x, cls) 读的是运行时对象的真实类别;类型标注描述的是程序员声明的期望。两者可以不一致——前面 add("x", "y") 就是反例。静态检查器在编译前(准确说是运行前)比对的是标注与数据流,不是替你调用 type()。
def pick_first(items: list[str]) -> str:
return items[0]
data: list[str | int] = ["a", 1]
pick_first(data) # 检查器可能报错:list 元素不全是 str若需要运行时校验,必须额外写代码或使用库;标注本身只是一层「文档 + 可选 lint」。
Callable、TypeVar 与泛型函数(点到为止)
函数类型常用 Callable[[ArgTypes], ReturnType]:
from collections.abc import Callable
def apply_twice(f: Callable[[int], int], x: int) -> int:
return f(f(x))
print(apply_twice(lambda n: n + 1, 3)) # 5简单泛型函数可引入 TypeVar,让输入输出类型保持同一未知数 T——读第三方库签名时会常见,日常业务代码可先掌握 list[int] 与 Callable 再深入。
常见误解
| 误解 | 实际 |
|---|---|
| 写了标注就会运行时校验 | 默认不会,除非额外工具 |
list 与 list[Any] 完全一样 | 对检查器,list 常被视为未参数化,严格模式下会提示 |
| Protocol 会强制继承 | 不会,只影响静态匹配 |
| 标注拖慢程序 | 运行时基本忽略,无 measurable 开销 |
小结
类型标注语法:参数: 类型、-> 返回类型、变量注解;容器用 list[int] 等内置泛型;可空用 T | None 或 Optional[T];行为契约用 Protocol。类型检查 是独立工具链(mypy、pyright、IDE),与解释器执行解耦。从公共 API 和数据结构开始写起,比追求全仓库一次性盖满更可持续。
在类与容器上的标注
类属性、实例方法同样写注解;self 通常不标注(约定俗成)。方法返回类型写在 -> 后:
class Buffer:
chunks: list[bytes]
def __init__(self) -> None:
self.chunks = []
def append(self, data: bytes) -> None:
self.chunks.append(data)
def size(self) -> int:
return sum(len(c) for c in self.chunks)类变量 chunks: list[bytes] 是注解,真正赋值在 __init__ 里完成——与 dataclass 字段声明不同,普通 class 里这两步分开写。静态方法、类方法同样可加参数与返回标注,检查器会一并分析。
嵌套函数若很短,也可标注,便于闭包内外类型一致:
def make_multiplier(factor: int) -> callable[[int], int]:
def mul(x: int) -> int:
return x * factor
return mulcallable[[int], int] 是内置小写写法(3.9+ 亦可用 collections.abc.Callable)。读库代码时见到这种签名,表示「接受 int、返回 int 的可调用对象」。
配置检查器:把标注变成门禁
本地可先装 mypy 或 pyright,对单文件试跑:
pip install mypy
mypy your_module.py项目级在 pyproject.toml 或 mypy.ini 里设 python_version、逐步开启 strict。CI 里同一条命令失败则阻断合并——这时标注从「建议」变成团队契约。新成员改函数签名时,检查器列出的调用点清单,往往比 code review 更完整地暴露遗漏。
不必追求第一天 --strict 全绿。常见路线:先开 check_untyped_defs 只查已标注函数体,再扩大目录;对暂时无法改的三方封装写 stub 或 # type: ignore 并注明 issue 号。标注与检查是长期减债,不是一次性作文。
读第三方库签名时的速查
打开类型存根的库,签名里会出现 TypedDict、Literal、Final 等进阶 constructs。日常写业务可先掌握本文的 list/Optional/Protocol;读到下列形式时知道「这是静态层概念」即可:
from typing import Literal, TypedDict
Mode = Literal["r", "w"]
# Mode 只能是两个字符串之一,检查器会拦别的值
class UserRow(TypedDict):
id: int
name: strTypedDict 描述「键固定、类型已知」的 dict,比 dict[str, Any] 更具体。Literal 收窄到常量集合。它们不改变运行时 dict 的行为,只帮助工具在访问 row["name"] 时发现拼写错误或类型不符。
若库未提供类型信息,可在 types-* 包或社区 stub 里找;实在没有就局部用 Any 并包一层自己的 typed 封装,避免 Any 传染整个代码库。
最后收束一条实践原则:标注服务于读代码的人与静态工具,不替代测试,也不改变 Python 动态本质。写函数时顺手补上参数与返回类型,比事后扫全库补标注省力;遇到 legacy 模块,用目录级检查策略逐步收紧,比一刀切 --strict 更易被团队接受。
IDE 依赖标注做自动补全与跳转:你写 user. 时,若 user 被推断或标注为具体 dataclass,成员列表才会准确。这也是「标注给谁看」里给自己看的一层——几个月后重读自己的代码,签名比长 docstring 更不易撒谎。
回顾本篇关键词:list 等内置泛型描述容器元素类型;Optional 与 | None 表达可空;Protocol 按方法形状做静态契约;类型检查 工具在运行前找不一致。四者组合起来,构成 Python 渐进式类型系统的主干,而不是要把语言改成另一种 Java。标注写进源码,检查交给工具,运行时仍靠测试与逻辑保证正确性——这三层分工别混为一谈。下一篇进入 import 与包结构,把模块边界与公开 API 画清楚,类型标注才有稳定的挂载点。这是本篇的收束。