当前位置:首页>python>Python 类型标注(type hints)进阶教程:从零到能独立写类型

Python 类型标注(type hints)进阶教程:从零到能独立写类型

  • 2026-08-24 23:27:06
Python 类型标注(type hints)进阶教程:从零到能独立写类型

如果你刚接触 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。标注在运行时基本只是"贴在那里的对象",默认不参与逻辑。

那标注给谁看?主要是两类工具:

  1. 静态类型检查器(mypy、Pyright 等):不运行代码,只分析"按你的标注,这里对不对"

  2. IDE(Cursor、VS Code + Pylance):靠标注做补全、跳转、inline 报错

可以把它理解成:你在代码里写了一份"说明书",工具按说明书帮你查错。说明书写错了或没写,工具就帮不上忙。

1.2 为什么需要

初学者常问:Python 不是动态语言吗,加类型不是多此一举?

动态语言灵活,但项目一大,这些问题会反复出现:

  • 函数返回 None 还是 dict,调用方没判断,线上 AttributeError

  • 传错参数类型,要跑到很深才爆

  • 重构改了一个字段名,全项目靠 grep 和运气

  • 读别人的代码,不知道 dict 里到底有什么 key

类型标注解决的是沟通问题:你写代码时把"我期望什么"说清楚,工具和队友都能提前发现不一致。

小脚本、一次性脚本可以不写。库、业务核心模块、多人协作的项目,写了标注通常更省心。不是"必须",是性价比在变大。

1.3 三个概念分清楚

后面全文会反复用到,先记这三个词:

概念
含义
例子
标注(annotation)
写在参数、返回值、变量上的类型说明
name: str
类型检查(type checking)
工具静态分析代码是否符合标注
跑 mypy src/
类型推断(type inference)
工具从上下文猜类型,你不写也能推一部分
x = 1
 推成 int

检查器不是万能的。你写 x: int = "hello",它能报;但 x: int = input() 它往往推不出 input 返回 str,可能漏报。标注 + 检查器是辅助,不能替代测试和逻辑思考。


2.最基础的写法

2.1 函数:从哪里开始写

最常见的入口是函数。完整形式:

defgreet(name: str, times: int = 1) -> str:
return name * times

逐项看:

  • name: str — 期望 name 是字符串

  • times: int = 1 — 期望整数,默认 1;默认值也要符合类型

  • -> str — 期望返回值是字符串

不写 -> ... 时,等价于 -> 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

最新文章

随机文章