如果你刚接触 type hints,大概率经历过这样的阶段:参数后面加个 : int,返回值写 -> str,代码能跑,IDE 偶尔也能补全。但一旦遇到 list 里装什么、None 怎么处理、装饰器包一层之后类型全丢,就开始怀疑——这玩意儿到底怎么用?
这篇按 Python 3.13 来写,文字会多讲一些"为什么"和"什么时候用",代码放在后面当例子。你可以按顺序读,也可以跳到你卡住的章节。
1.类型标注到底是什么?
1.1 它不会帮 Python 在运行时拦错
先把最容易误解的一点说清楚:类型标注不是 Java/C++ 那种编译期强制类型。
defadd(a: int, b: int) -> int:
return a + b
add("1", "2") # 运行时正常执行,结果是 '12'
Python 解释器看到 "1" 和 "2",照样传进函数,不会因为你写了 int 就抛 TypeError。标注在运行时基本只是"贴在那里的对象",默认不参与逻辑。
那标注给谁看?主要是两类工具:
静态类型检查器(mypy、Pyright 等):不运行代码,只分析"按你的标注,这里对不对"
IDE(Cursor、VS Code + Pylance):靠标注做补全、跳转、inline 报错
可以把它理解成:你在代码里写了一份"说明书",工具按说明书帮你查错。说明书写错了或没写,工具就帮不上忙。
1.2 为什么需要
初学者常问:Python 不是动态语言吗,加类型不是多此一举?
动态语言灵活,但项目一大,这些问题会反复出现:
类型标注解决的是沟通问题:你写代码时把"我期望什么"说清楚,工具和队友都能提前发现不一致。
小脚本、一次性脚本可以不写。库、业务核心模块、多人协作的项目,写了标注通常更省心。不是"必须",是性价比在变大。
1.3 三个概念分清楚
后面全文会反复用到,先记这三个词:
| | |
|---|
| 标注(annotation) | | name: str |
| 类型检查(type checking) | | |
| 类型推断(type inference) | | x = 1 |
检查器不是万能的。你写 x: int = "hello",它能报;但 x: int = input() 它往往推不出 input 返回 str,可能漏报。标注 + 检查器是辅助,不能替代测试和逻辑思考。
2.最基础的写法
2.1 函数:从哪里开始写
最常见的入口是函数。完整形式:
defgreet(name: str, times: int = 1) -> str:
return name * times
逐项看:
不写 -> ... 时,等价于 -> None,表示"这个函数主要做副作用,不返回有用值":
deflog(msg: str): # 等价于 -> None
print(msg)
2.2 变量:不是只能标在参数上
模块级、函数内的变量也可以标:
count: int = 0
names: list[str] = []
config: dict[str, str] = {"host": "localhost"}
什么时候值得标变量?
一行看不出类型:result = json.loads(text) → result: dict[str, object] = ...
先声明后赋值:items: list[str],后面再 items = []
容易写混:user_id: int,别和 order_id 搞混
简单能一眼看出的,x = 1 不写也行,检查器能推断。
2.3 None 与"可能没有"
很多 bug 来自"以为一定有值,其实是 None"。
deffind_user(uid: int) -> User | None:
if uid in cache:
return cache[uid]
returnNone
User | None 读作:要么是 User,要么是 None。调用方必须处理两种可能:
user = find_user(42)
if user isNone:
...
else:
user.name # 这里检查器知道 user 是 User
老写法是 Optional[User],和 User | None 一样。新项目用 |,3.10+ 都支持。
初学者易错点: 标注了 -> User 却可能 return None,检查器会报。要么改返回类型为 User | None,要么保证所有路径都返回 User。
3.容器类型怎么写
3.1 从 List[str] 到 list[str]
老教程写:
from typing importList, Dict, Set, Tuple
names: List[str] = []
Python 3.9 起(PEP 585),直接用内置类型:
names: list[str] = []
scores: dict[str, float] = {"math": 90.0}
tags: set[int] = {1, 2, 3}
意思一样,少 import,新项目优先这种。
3.2 list 里装什么,一定要写吗?
空列表可以推断:
items = [] # 检查器可能推成 list[Any] 或 list[Unknown]
items.append(1)
items.append("a") # strict 模式下可能后续使用时报错
若你知道只装字符串,写清楚更好:
items: list[str] = []
items.append("a") # OK
items.append(1) # 检查器报错
原则:容器里元素类型固定,就写 list[元素类型];真的什么都装,才用 list[object] 或(尽量少)list[Any]。
3.3 tuple 的两种用法
tuple 比 list 细,分两种:
固定长度、每个位置类型不同(像"坐标"):
point: tuple[float, float] = (1.0, 2.0)
x, y = point # x 是 float,y 是 float
长度不固定,但元素同一类型:
numbers: tuple[int, ...] = (1, 2, 3, 4)
# ... 表示"任意多个 int"
别把 tuple[int, int] 和 tuple[int, ...] 搞混:前者恰好两个 int,后者至少零个、可以有多个。
3.4 dict:key 和 value 分别标
# 用户 id -> 用户名
users: dict[int, str] = {1: "alice", 2: "bob"}
# 配置项:键字符串,值可能是 str/int/bool
settings: dict[str, str | int | bool] = {"debug": True, "port": 8080}
如果 dict 结构固定(比如一定有 title、year),后面会讲 TypedDict,比 dict[str, object] 精确得多。
4.联合类型与类型别名
4.1 A | B:多种类型之一
参数有时收 int,有时收 str:
defprint_id(value: int | str) -> None:
print(value)
检查器允许传 42 或 "abc",传 [] 会报。
联合类型在使用前往往要收窄(后面第八章细讲):检查器不知道你当前是 int 还是 str,直接 .upper() 可能报错,需要先 isinstance(value, str)。
4.2 类型别名:给复杂类型起短名
签名一长就难读:
defbroadcast(
message: str,
servers: list[tuple[tuple[str, int], dict[str, str]]],
) -> None:
...
抽成别名(Python 3.12+ 推荐 type 语句):
type Address = tuple[str, int]
type ConnectionOptions = dict[str, str]
type Server = tuple[Address, ConnectionOptions]
defbroadcast(message: str, servers: list[Server]) -> None:
...
type Server = ... 明确这是类型别名,不是运行时变量。老项目可能见到:
from typing import TypeAlias
Server: TypeAlias = tuple[Address, ConnectionOptions]
4.3 NewType:和别名不是一回事
UserId = int 只是别名,检查器认为 UserId 和 int 可互换。
若业务上"用户 ID"和"订单 ID"都是 int,但不能混传:
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
defget_user(uid: UserId) -> str:
...
get_user(UserId(42)) # OK
get_user(OrderId(42)) # 报错:类型不同
get_user(42) # 报错:plain int 不行
运行时 UserId(42) 就是个函数调用,几乎无额外开销。它防的是传参混用,不阻止你对 UserId 做 + 1(结果仍是 int)。
5.泛型——"类型也是参数"
5.1 问题从哪来
写一个"取列表第一个元素"的函数:
deffirst(items: list) -> ???:
return items[0]
返回类型随 items 变:传 list[int] 应返回 int,传 list[str] 应返回 str。写死一种不对,这里需要泛型。
5.2 PEP 695 新语法(3.12+,推荐)
deffirst[T](items: list[T]) -> T:
return items[0]
first([1, 2, 3]) # 返回类型推断为 int
first(["a", "b"]) # 返回类型推断为 str
读法:first 有一个类型参数 T;items 是 list[T];返回值也是 T。T 只在函数内有效,不用在文件顶部声明 TypeVar。
泛型类同理:
classStack[T]:
def__init__(self) -> None:
self._items: list[T] = []
defpush(self, item: T) -> None:
self._items.append(item)
defpop(self) -> T:
returnself._items.pop()
stack: Stack[int] = Stack()
stack.push(1) # OK
stack.push("x") # 报错
使用时 Stack[int] 把 T 具体化成 int,检查器按此检查。
5.3 老写法(读旧代码时会见到)
from typing import TypeVar, Generic
T = TypeVar("T")
classStackOld(Generic[T]):
...
逻辑相同,只是啰嗦。维护老项目时会看到,新代码优先 PEP 695。
5.4 边界与约束(知道即可)
有时要限制 T 的范围:
from typing import SupportsFloat
classAverage[T: SupportsFloat]:
"""T 必须是支持转成 float 的类型"""
...
或 S: (str, bytes) 表示 S 只能是 str 或 bytes 之一。进阶场景才用,初学先掌握普通 [T] 即可。
6.Protocol——"不用继承,只要长得像"
6.1 继承式思维 vs 鸭子类型
传统 OOP:要能用 close(),得继承某个基类或实现某接口。
Python 实际是:有 close 方法就能用,不管类名是什么。类型系统要描述这种"结构相同即可",用 Protocol。
from typing import Protocol
classSupportsClose(Protocol):
defclose(self) -> None: ...
defcleanup(resource: SupportsClose) -> None:
resource.close()
任何有 close(self) -> None 的类,无需继承 SupportsClose,传给 cleanup 都合法:
classMyFile:
defclose(self) -> None:
print("closed")
cleanup(MyFile()) # OK
这叫结构化子类型:看结构,不看名字。
6.2 和 Callable 的关系
简单函数类型:
from collections.abc importCallable
defapply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
Callable[[参数类型...], 返回类型]。参数复杂(kw-only、*args)时,Callable 写不下,用 Protocol 定义 __call__ 更合适。初学先把 Callable[[...], ...] 用熟就够。
7.TypedDict——有固定字段的 dict
7.1 为什么需要它
dict[str, object] 只表示"字符串键",不知道有哪些 key。下面检查器帮不了你:
defget_title(m: dict[str, object]) -> str:
return m["title"] # "title" 拼错?不存在?检查器不知道
若 dict 形状固定,用 TypedDict:
from typing import TypedDict, NotRequired
classMovie(TypedDict):
title: str
year: int
rating: NotRequired[float] # 可以没有
defget_title(m: Movie) -> str:
return m["title"]
m: Movie = {"title": "Inception", "year": 2010}
get_title(m) # OK
get_title({"year": 2010}) # 报错:缺 title
运行时它就是普通 dict,没有额外类实例,零开销。适合 JSON、API 响应、配置对象。
7.2 必填与可选
默认 TypedDict 里所有字段必填。若大部分可选:
classMoviePartial(TypedDict, total=False):
title: str
year: int
# total=False:默认都可缺省
或默认必填、个别标可选:NotRequired[float]。3.13 还有 ReadOnly[str] 表示不应原地修改该字段(给检查器看的约束)。
8.类型收窄——让检查器在 if 分支里变聪明
8.1 问题是什么
defprocess(value: int | str) -> None:
ifisinstance(value, str):
print(value.upper()) # 这里检查器知道 value 是 str
else:
print(value + 1) # 这里知道 value 是 int
isinstance 之后,检查器会收窄类型。没这层,联合类型几乎没法安全用。
8.2 自定义判断:TypeIs 与 TypeGuard
有时逻辑不是一个 isinstance 能说清:
from typing import TypeIs
defis_str_list(val: list[object]) -> TypeIs[list[str]]:
returnall(isinstance(x, str) for x in val)
defdemo(items: list[object]) -> None:
if is_str_list(items):
items.append("new") # 此处 items 是 list[str]
else:
items.append(1) # 仍是 list[object]
TypeIs(3.13+,优先):返回 True 时收窄为更具体类型;返回 False 时排除该类型。
老一点的 TypeGuard 也能收窄,但返回 False 时检查器往往不反向推断。涉及 list 这种"不变容器"的特殊收窄,TypeGuard 有时更灵活。初学:自定义类型判断优先试 TypeIs。
9.几个常用的小工具
9.1 Literal:只能是某几个值
from typing importLiteral
Mode = Literal["r", "w", "rb"]
defopen_file(path: str, mode: Mode) -> None:
...
open_file("a.txt", "r") # OK
open_file("a.txt", "x") # 报错
适合模式字符串、固定枚举值,和 3.10+ 的 match 搭配很好。
9.2 Final:不应再赋值
from typing import Final
MAX_RETRY: Final = 3
MAX_RETRY = 5# 检查器报错
类常量常用 ClassVar:
from typing import ClassVar
classConfig:
DEFAULT_TIMEOUT: ClassVar[int] = 30
表示这是类上的变量,不是每个实例各一份。
9.3 Self:返回子类类型
from typing import Self
classBuilder:
defset_name(self, name: str) -> Self:
self.name = name
returnself
classSubBuilder(Builder):
defset_flag(self) -> Self:
returnself
链式调用时,返回类型跟实际类走,不会丢成父类 Builder。
9.4 Never:不可能返回
from typing import Never
deffail(msg: str) -> Never:
raise RuntimeError(msg)
表示函数一定抛异常或死循环,后面代码"不可达",检查器据此分析。
9.5 @overload:同一函数多种签名
运行时只有一个实现,静态检查看到多个"面":
from typing import overload
@overload
defparse(raw: int) -> str: ...
@overload
defparse(raw: str) -> bytes: ...
defparse(raw: int | str) -> str | bytes:
ifisinstance(raw, int):
returnstr(raw)
return raw.encode()
标准库 open、re.match 等大量用到,读 stub 文件时会经常看到。
10.装饰器与 ParamSpec(知道痛点即可)
装饰器包一层,参数类型容易丢:
defbroken_decorator(func):
defwrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
检查器不知道 wrapper 和 func 参数关系。ParamSpec 用来声明"装饰器不改变 callable 的签名":
from collections.abc importCallable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
deflog_call(func: Callable[P, R]) -> Callable[P, R]:
defwrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
要知道"普通装饰器会破坏类型,有专门写法修复"即可。
11.怎么真的用起来——工具链
11.1 装一个检查器
pip install mypy
mypy your_package/
# 或
pip install pyright
pyright your_package/
两者都认 PEP,细节略有差别。团队选一个统一用。
11.2 项目里加配置(mypy 示例)
pyproject.toml:
[tool.mypy]
python_version = "3.13"
strict = false# 初学先 false,再逐步开
warn_return_any = true
disallow_untyped_defs = false# 成熟后可 true
strict = true 一次全开,老项目可能几千条报错。更稳的做法:新文件严格写,旧文件慢慢补。
11.3 第三方库没类型怎么办
很多库在 PyPI 有 stub 包:
pip install types-requests types-PyYAML
没有 stub 的,可在配置里对该模块 ignore_missing_imports = true,别全局关检查。
11.4 # type: ignore 怎么用
实在改不了时:
result = legacy() # type: ignore[arg-type]
尽量写具体错误码,并加注释说明原因。配合 warn_unused_ignores = true,ignore 过期了会提醒你删。
12.初学者最容易踩的坑
1. 以为写了标注运行时就会检查
不会。要跑 mypy/pyright,或在 CI 里加一步。
2. 到处用 Any
Any 等于告诉检查器"别管我",失去意义。拿不准用 object,或写具体的 Protocol / TypedDict。
3. 返回类型和实际不一致
标注 -> str 却 return None,或某些分支没 return。检查器会报,这是好事。
4. 可变默认参数
deff(items: list[str] = []) -> None: # 经典坑
...
应写成 items: list[str] | None = None,函数里再 if items is None: items = []。
5. 循环引用类名
类方法参数类型是自身时,文件顶加:
from __future__ import annotations
3.13 对此更友好,加一行不亏。
6. 空容器不标注
items = [] 后混装不同类型,strict 模式后续会痛。知道元素类型就写 list[str]。
参考
typing 官方文档 [1]
Python 类型系统规范 [2]
mypy 入门 [3]
引用链接
[1] typing 官方文档: https://docs.python.org/3/library/typing.html
[2] Python 类型系统规范: https://typing.python.org/en/latest/spec/index.html
[3] mypy 入门: https://mypy.readthedocs.io/en/stable/getting_started.html