类型注解
学完前面的章节,相信各位已经能写出像模像样的 Python 代码了。函数、类、闭包、装饰器,一路下来都不是事儿。可是啊,写代码这件事,自己写得爽是一回事,给别人看,或者半年后自己再回来看,就是另一回事了。
来,我们看一段「水哥」上周刚交接给童鞋的代码:
童鞋看着这个 calc 一脸懵:x 和 y 到底是数字还是字符串?op 还能传啥?返回的是啥类型?只能跑去翻调用方的代码,或者直接喊「水哥,这函数到底咋用啊」。
这种场景,是不是特别熟悉?Python 是动态语言,变量不需要声明类型,写起来确实痛快,可一旦项目大起来、人多起来,就特别容易翻车。
各位再想想,这种「类型不明」的代码会带来什么后果:
- 调用方传错类型,自己写代码时还没发觉,等部署上线、用户点了某个按钮才崩溃
- IDE 没法给你精准的代码补全——它都不知道你这个变量是啥,怎么提示有哪些方法可以用?
- 重构时心里没底——把这个函数的参数改了,到底会影响到哪些地方?只能全文搜索,搜完还要一行行肉眼看
- 团队协作里反复打断别人——每次你想用一个不熟的函数都要去问作者「这个参数是啥意思」
为了解决这个问题,从 Python 3.5 开始,官方就引入了「类型注解」(Type Hints)这套机制。再到 3.9、3.10、3.12,一路演变到现在,已经非常好用了。可惜的是,市面上很多老教程都没怎么讲,导致很多童鞋写了好几年 Python 还是 def f(x, y) 啥都不写。
各位想想,假如有这么一个场景:「水哥」周末临时请假,把手头的项目交接给童鞋。代码里有几十个函数,每个函数都长成 def process(data, config, cb) 这副样子。童鞋打开一个文件就懵了——data 是字典还是列表?config 是对象还是路径?cb 是函数还是字符串?每一处都得跑去问,要么就翻一遍调用栈。这一个周末,怕是整个人都得搭进去。
而如果一开始函数签名是 def process(data: dict[str, int], config: Path, cb: Callable[[str], None]),童鞋一眼就明白了,连问都不用问。这就是类型注解最直接的价值。
那今天这篇,就专门聊聊类型注解,让各位写出来的代码更清晰、更专业。
最简单的注解¶
我们先把上面那段 calc 改造一下。假设它就是处理两个整数的加减:
def calc(x: int, y: int, op: str) -> int:
if op == 'add':
return x + y
elif op == 'sub':
return x - y
return 0
print(calc(3, 5, 'add'))
print(calc(10, 4, 'sub'))
输出结果:
是不是发现,现在这个函数一眼就能看明白:x 和 y 是 int,op 是 str,返回值也是 int。就算没有任何注释,光看签名就够了。
注解的语法很简单,记住两点:
- 参数后面用
: 类型标注 - 返回值用
-> 类型标注
记住这个比例:「参数加冒号,返回加箭头」。语法上跟原来的写法 100% 兼容,没注解的旧代码不需要改一行字符就能跟有注解的新代码混着跑。
那么有童鞋可能会问:「我能不能只标参数,不标返回值?或者只标返回值,不标参数?」
完全可以,类型注解是「想标哪里标哪里」的,不强制全标。比如:
def double(x: int):
return x * 2
def greet(name) -> str:
return f'你好,{name}'
print(double(3))
print(greet('两点水'))
输出结果:
但是从「契约清晰」的角度,建议要么全标,要么干脆都不标。半标半不标看着特别割裂,读代码的人会怀疑「漏标的那个是忘了,还是故意的?」
这里有个非常重要的点,各位一定要记住:Python 解释器并不会强制检查这些类型。它只是个「说明」,给人看,给工具(IDE、静态检查器)看。
不信我们试试,故意传错类型:
输出结果:
看,明明标了 int,传字符串照样能跑,因为字符串本身也支持 +,Python 啥都没拦。所以类型注解不是「类型强制」,更像是写给同事看的「契约」。
那有人就要问了:「既然不强制,那加它干啥?」
好问题,答案是:
- IDE(PyCharm、VSCode)能根据注解给你精准的代码补全和报错提示
- 静态检查工具(比如 mypy、pyright)能在代码运行前就揪出类型错误
- 同事(包括未来的自己)读代码时省下大把猜测时间
就这三条,已经足够让我们认真对待它了。
还有一个很多童鞋会困惑的点:「带默认值的参数怎么注解?」很简单,注解写在前面,等号默认值跟在后面:
def punch(name: str, dept: str = '做鸭事业部') -> None:
print(f'{name}({dept})打卡成功')
punch('两点水')
punch('三点水', '做鹅事业部')
输出结果:
格式是 参数名: 类型 = 默认值,三个部分依次排好就行。
那 *args、**kwargs 这种可变参数呢?也能注解,写的是「单个元素」的类型,不是整个 tuple/dict 的类型:
def sum_all(*nums: int) -> int:
return sum(nums)
def make_user(**fields: str) -> dict[str, str]:
return fields
print(sum_all(1, 2, 3, 4, 5))
print(make_user(name='两点水', dept='做鸭事业部'))
输出结果:
*nums: int 的意思是「nums 这个 tuple 里的每个元素都是 int」,不是「nums 本身是 int」;**fields: str 同理,是说每个 value 都是 str。这点初学时很容易搞错,记一下。
常用的几个基础类型¶
类型注解里能写的类型,基本都是 Python 内置的那些:
| 类型 | 含义 |
|---|---|
int |
整数 |
float |
浮点数 |
str |
字符串 |
bool |
布尔值 |
list |
列表 |
dict |
字典 |
tuple |
元组 |
set |
集合 |
bytes |
字节串 |
None |
空值(用作返回类型) |
来个综合例子,给「做鸭事业部」写一个员工档案函数:
def make_profile(name: str, age: int, salary: float, is_active: bool) -> str:
return f'{name}({age} 岁,工资 {salary},在职:{is_active})'
print(make_profile('两点水', 28, 12000.5, True))
输出结果:
各位试一下,把上面这段代码贴到 PyCharm 或者 VSCode 里。当你调用 make_profile( 的时候,IDE 会立刻弹出参数提示——「第一个参数 name 是 str,第二个参数 age 是 int……」。要是没有注解,IDE 就只能干瘪地告诉你「这里要四个参数」,啥类型完全靠你脑子记。
是不是发现,注解写一下,开发体验立马上一个台阶?
是不是发现,记这一张表就够大部分场景用了?基础类型本身没什么花哨的,关键是用得「准」——是 int 就写 int,是 str 就写 str,别图省事写个 object 或者 Any 把一切都吃掉。
这里多说几句 bool 的注解。Python 里 bool 其实是 int 的子类(True == 1、False == 0),所以一个标了 int 的参数,传 True 进去类型检查器是不报错的。但反过来,标 bool 的参数你传个 1,检查器就会拦下来。这点平时知道一下就行,写注解还是按「真实意图」来写:开关用 bool,数字用 int。
再说一个跟 int 相关的小坑:Python 的 int 是「无限精度」的,没有 int32、int64 之分,所以注解里也只有一个 int。习惯了 C/Java 的童鞋一开始会找「int 32 在哪」,那是没有的——大胆放心地用 int 就好。
来看一个实际写「员工档案」时常见的混合签名:
def make_employee(
name: str,
age: int,
height: float,
is_intern: bool,
skills: list,
address: dict,
) -> str:
return f'{name} / {age} 岁 / {height}m / 实习:{is_intern} / 技能:{skills} / 地址:{address}'
print(make_employee(
'两点水', 28, 1.75, False,
['Python', 'Django'],
{'city': '深圳', 'street': '科技园'},
))
输出结果:
现在这个签名读起来已经清晰多了,但是仔细看,skills: list 和 address: dict 这两个还不够好——「列表里装的啥?字典的键值都是啥类型?」我们不知道。这就要靠下一节讲的「容器类型」来进一步细化。
如果一个函数没有返回值(也就是返回 None),返回类型写 None 就好:
输出结果:
注意啊,这里的 -> None 不是说「不写返回值」,而是「明确告诉别人这个函数没有有意义的返回值」。这俩在阅读体验上差很多。
那么各位再想一个场景。「做鸭事业部」的上班打卡函数(前面装饰器章节里见过的那个),加上注解之后是这样:
import time
def punch(name: str, dept: str) -> None:
today = time.strftime('%Y-%m-%d', time.localtime(time.time()))
print(f'{today}:昵称:{name} 部门:{dept} 上班打卡成功')
punch('两点水', '做鸭事业部')
输出结果(日期会随当前时间变):
各位看,仅仅在签名里加了 name: str, dept: str 和 -> None,整个函数的「使用契约」就完全交代清楚了——传字符串进去,不会得到任何返回值,行为是打印。换成读者视角,是不是省心多了?
函数可能返回 None 怎么办¶
来设想一个真实的场景。「做鸭事业部」的员工库是个字典,我们写一个根据昵称查工号的函数:
employees = {'两点水': '001', '三点水': '002'}
def find_id(name: str) -> str:
return employees.get(name)
print(find_id('两点水'))
print(find_id('四点水'))
输出结果:
看出来问题了吗?我们标的返回类型是 str,可实际上当这个员工不存在时,dict.get() 返回的是 None,不是 str。注解和实际行为对不上了。
这种「要么返回 X,要么返回 None」的情况非常常见,老一辈的写法是用 typing 模块里的 Optional:
from typing import Optional
employees = {'两点水': '001', '三点水': '002'}
def find_id(name: str) -> Optional[str]:
return employees.get(name)
print(find_id('两点水'))
print(find_id('四点水'))
Optional[str] 等价于「这个值要么是 str,要么是 None」。
可是啊,每次都要从 typing 导入这个 Optional,写多了真的烦。到了 Python 3.10,官方推出了一个更简洁的写法(PEP 604),直接用竖线 | 连起来:
employees = {'两点水': '001', '三点水': '002'}
def find_id(name: str) -> str | None:
return employees.get(name)
print(find_id('两点水'))
print(find_id('四点水'))
输出结果:
str | None 读起来是不是更直白?「字符串或者空值」,跟英语里的 or 一个意思。
这里要重点强调一句:返回类型标 str | None,不是说调用方就能直接当 str 用了——你必须在使用前判断一下到底是哪种情况。看这段:
def find_id(name: str) -> str | None:
db = {'两点水': '001'}
return db.get(name)
emp_id = find_id('四点水')
print(emp_id.upper())
上面这段 IDE 会立刻给 emp_id.upper() 飘红,因为 emp_id 可能是 None,None 没有 upper 方法。正确写法是先判断:
def find_id(name: str) -> str | None:
db = {'两点水': '001'}
return db.get(name)
emp_id = find_id('两点水')
if emp_id is not None:
print(emp_id.upper())
else:
print('员工不存在')
输出结果:
走过这个 if 分支之后,类型检查器会「自动收窄」——它知道 if emp_id is not None: 内部 emp_id 一定是 str,于是 .upper() 就不报错了。这种聪明劲儿叫「Type Narrowing(类型收窄)」,是现代类型检查器最有用的能力之一。
而且这个 | 不止能用来表示 None,可以连接任意多个类型:
def to_int(value: int | str | float) -> int:
return int(value)
print(to_int(3))
print(to_int('5'))
print(to_int(7.8))
输出结果:
int | str | float 表示「这个参数可以是 int、str、float 三者之一」,行话叫「联合类型」(Union Type)。
Python 3.10+ 才支持
X | Y这种写法。如果项目还在用 3.9 或更早版本,只能用from typing import Union; Union[X, Y]。但说实话,2026 年了,能升就升吧。
总结一下:
- 老写法:
Optional[str]、Union[int, str]—— 需要from typing import ... - 新写法:
str | None、int | str—— 不用 import,直接写
新代码统一推荐新写法。
顺便提一个小细节。函数有了 X | None 的返回类型之后,调用方就被「提醒」要处理 None 的情况。比如:
employees = {'两点水': '001', '三点水': '002'}
def find_id(name: str) -> str | None:
return employees.get(name)
emp_id = find_id('四点水')
if emp_id is None:
print('员工不存在')
else:
print(f'工号:{emp_id}')
输出结果:
如果不写 | None,调用方很容易忘记判空,直接拿返回值去做字符串拼接、查询数据库,运行到一半才崩。注解+静态检查器的组合,能在你忘记判空的瞬间就提醒你。
容器类型怎么注解¶
光说一个 list 还不够。看下面这个函数:
def total_salary(salaries: list) -> float:
return sum(salaries)
print(total_salary([10000, 12000, 8000]))
输出结果:
这里标了 list,但是「列表里到底装的是啥」没说清楚。是 int?是 float?是 str?读代码的人还是要猜。
老写法又来了,从 typing 导入 List、Dict、Tuple 这些大写开头的版本:
from typing import List
def total_salary(salaries: List[float]) -> float:
return sum(salaries)
print(total_salary([10000, 12000, 8000]))
List[float] 表示「装着 float 的列表」,方括号里写的是元素类型。
到了 Python 3.9(PEP 585),官方说:「这玩意儿大家天天用,还要 import 真的很烦,咱们直接让 list 自己支持下标行不行?」于是新写法就来了:
def total_salary(salaries: list[float]) -> float:
return sum(salaries)
print(total_salary([10000, 12000, 8000]))
输出结果:
注意看,这里直接用小写的 list[float],不用 import 任何东西。其他容器也一样:
def group_by_dept(staff: dict[str, list[str]]) -> int:
return len(staff)
data = {
'做鸭事业部': ['两点水', '三点水'],
'做鹅事业部': ['四点水'],
}
print(group_by_dept(data))
输出结果:
dict[str, list[str]] 表示「键是 str、值是 str 列表的字典」,这种嵌套写起来也很直观。
说到这里,多说一句关于「list 还是 Sequence」的选择。list[int] 写起来很自然,但其实有时候我们的函数并不真的需要一个 list,只要「能被遍历、能用下标访问」就行——这种情况下用 Sequence 更宽松:
from collections.abc import Sequence
def first_and_last(items: Sequence[int]) -> tuple[int, int]:
return items[0], items[-1]
print(first_and_last([1, 2, 3]))
print(first_and_last((10, 20, 30)))
输出结果:
Sequence[int] 接受 list、tuple、字符串……只要是「有顺序的容器」就行。同样的还有 Iterable[X](只要能遍历就行,连生成器也接受)、Mapping[K, V](能像字典一样取值就行)。这些抽象类型在写库的时候特别有用,能让函数接受更广泛的输入。日常写业务代码倒不用太纠结,直接 list[int] 也完全 OK。
tuple 稍微特殊一点。如果是固定长度的元组,每个位置类型都要写出来:
输出结果:
如果是不定长但元素同类型的元组(比较少见),用 ...:
def join_names(names: tuple[str, ...]) -> str:
return '、'.join(names)
print(join_names(('两点水', '三点水', '四点水')))
输出结果:
set 跟 list 类似:
def unique_count(tags: set[str]) -> int:
return len(tags)
print(unique_count({'Python', 'Java', 'Python'}))
输出结果:
记住一句话:3.9 以后,所有内置容器都能直接当类型用,不用从 typing 里 import 大写版了。
再多看几个稍微复杂的容器嵌套例子。比如有这么一个数据结构,记录每个员工每天打卡的次数:
punches: dict[str, dict[str, int]] = {
'两点水': {'2026-04-26': 2, '2026-04-27': 1},
'三点水': {'2026-04-26': 1, '2026-04-27': 2},
}
def total_punches(data: dict[str, dict[str, int]], name: str) -> int:
return sum(data.get(name, {}).values())
print(total_punches(punches, '两点水'))
print(total_punches(punches, '三点水'))
print(total_punches(punches, '四点水'))
输出结果:
dict[str, dict[str, int]] 这种嵌套,刚开始读会有点累,但是只要拆开来看——「外层的键是 str(员工昵称),值是一个 dict[str, int](日期到次数的映射)」,逻辑就很清楚了。后面我们会讲到,这种嵌套类型如果出现多次,可以用类型别名简化。
来个综合一点的例子,巩固一下。「做鸭事业部」要做一个员工统计函数,输入是一组员工记录,输出每个部门的人数:
def count_by_dept(records: list[dict[str, str]]) -> dict[str, int]:
result: dict[str, int] = {}
for r in records:
dept = r['dept']
result[dept] = result.get(dept, 0) + 1
return result
data = [
{'name': '两点水', 'dept': '做鸭事业部'},
{'name': '三点水', 'dept': '做鸭事业部'},
{'name': '四点水', 'dept': '做鹅事业部'},
]
print(count_by_dept(data))
输出结果:
整个函数的输入输出,光看签名 list[dict[str, str]] -> dict[str, int] 就一清二楚——「传一组『字段名到字段值』的字典进来,得到一个『部门到人数』的字典」。是不是很爽?
这里还藏了一个小细节,函数体里 result: dict[str, int] = {} 也加了注解,目的就是告诉 IDE「我后面 result 里要装的是 str → int」,否则 IDE 看到 result = {} 只能猜「这是个空字典,啥都能塞」,写错了也不会提示。
变量也能加注解¶
不光函数参数和返回值,普通的变量声明也能加注解。语法跟参数一样:
name: str = '两点水'
age: int = 28
salary: float = 12000.5
tags: list[str] = ['Python', '后端']
print(name, age, salary, tags)
输出结果:
那么有童鞋就要问了:「变量赋值的时候,类型不是一眼就能看出来吗?我直接 name = '水哥' 不就完了?还加注解干啥?」
主要有两种场景特别需要:
第一种,初始值还推断不出最终类型。比如初始化一个空列表,你想告诉别人「这是一个装字符串的列表」:
输出结果:
如果不写注解,IDE 看到 employees = [] 只能猜「这是一个 list,元素类型未知」,后面你 append 啥它都不报错。加了 list[str] 之后,要是不小心 employees.append(123),IDE 立马给你飘红。
来一个非常贴近实际的小场景。我们要写一个登录积分系统的初始化代码——「水哥」每天登录会累积一些积分,但是初始时还没积分:
points: int = 0
visited_pages: list[str] = []
last_login: str | None = None
points += 5
visited_pages.append('首页')
last_login = '2026-04-28'
print(points, visited_pages, last_login)
输出结果:
注意看 last_login: str | None = None 这行——初始是 None,但是变量类型是 str | None。这种「先声明可以为空,运行时再赋具体值」的写法在长流程里特别常见。要是不写注解,IDE 看到 last_login = None,可能就把它的类型推断成 None 了,后面你赋值字符串它倒不报错,但语义上就不那么明确了。
类似的还有空字典、空集合:
scores: dict[str, int] = {}
scores['两点水'] = 95
scores['三点水'] = 88
print(scores)
unique_tags: set[str] = set()
unique_tags.add('Python')
unique_tags.add('后端')
unique_tags.add('Python')
print(unique_tags)
输出结果:
第二种,只声明不赋值。比如类的属性:
class Employee:
name: str
age: int
def __init__(self, name: str, age: int):
self.name = name
self.age = age
emp = Employee('两点水', 28)
print(emp.name, emp.age)
输出结果:
类体里直接 name: str 就声明了一个类型为 str 的属性,连默认值都不用给。这种写法在 dataclass 里更常见,后面会讲。
也可以在类体里直接给默认值,效果跟变量声明差不多:
class Counter:
count: int = 0
name: str = '默认计数器'
def incr(self) -> None:
self.count += 1
c = Counter()
c.incr()
c.incr()
print(c.name, c.count)
输出结果:
注意啊,这种「类体里给默认值」的写法,本质上是「类属性」(所有实例共享)。如果默认值是不可变的(int、str、tuple 这些),用起来跟「实例属性默认值」差别不大;可如果默认值是 list、dict 这种可变对象,就特别容易踩坑——所有实例会共享同一个列表。所以一般 list、dict 这种字段,咱们还是放到 __init__ 里初始化,或者干脆用 dataclass 的 field(default_factory=list),这点后面再细聊。
Callable 和 Any——两个特殊角色¶
写了几个函数注解后,迟早会遇到一个问题:「我这个函数的参数是另一个函数,咋注解?」
学过装饰器的童鞋应该有印象,函数本身也是对象,是可以传来传去的。这种「可以被调用的东西」,类型叫 Callable,从 collections.abc 里来:
from collections.abc import Callable
def apply(func: Callable[[int, int], int], x: int, y: int) -> int:
return func(x, y)
print(apply(lambda a, b: a + b, 3, 5))
print(apply(lambda a, b: a * b, 3, 5))
输出结果:
Callable[[int, int], int] 怎么读?方括号里第一个列表是「参数类型列表」,第二个是「返回类型」。所以这个意思是:「一个接收两个 int、返回 int 的函数」。
在 Python 3.9 以前,
Callable也是从typing里 import 的。3.9+ 推荐从collections.abc导入。
学过装饰器的童鞋应该会想到,装饰器的本质就是「接收一个函数,返回一个函数」,那么装饰器的类型注解也能用 Callable 写出来:
from collections.abc import Callable
def with_log(func: Callable[[int, int], int]) -> Callable[[int, int], int]:
def wrapper(x: int, y: int) -> int:
print(f'调用 {func.__name__}({x}, {y})')
return func(x, y)
return wrapper
@with_log
def add(x: int, y: int) -> int:
return x + y
print(add(3, 5))
输出结果:
这个 with_log 装饰器的签名是不是一目了然——「我吃一个『接收两个 int、返回 int』的函数,然后吐出来一个一模一样形状的函数」。当然,要写出能装饰任意函数的通用装饰器,光靠 Callable 还不够,得用 ParamSpec、TypeVar 这些更进阶的工具,那就属于另一个话题了,这里先不展开。
那么还有一个角色叫 Any,意思是「啥类型都行,别检查」。它在你真的不在乎类型的时候用:
from typing import Any
def log(value: Any) -> None:
print(f'记录:{value}')
log('两点水')
log(123)
log([1, 2, 3])
输出结果:
Any 听起来很方便,但请慎用。一旦标了 Any,类型检查器就当没看见这个值,等于把注解的好处全扔了。能写具体类型,绝不用 Any。
那么各位可能会问:「啥时候用 Any 呢?」一般是这两种情况:
- 你在写一个真正通用的工具函数,确实不知道传进来是什么类型(比如
print这种) - 你在跟一个没注解的旧库交互,连作者都说不清类型,硬标会更乱
除此之外,优先选具体类型,再次选联合类型,再次选 object / Sequence 这种宽松抽象类型,最后才考虑 Any。这是写注解的优先级顺序,记一下。
举个例子,假设我们要写一个「把任意输入序列化成字符串」的函数,第一反应可能是:
from typing import Any
def to_str(x: Any) -> str:
return str(x)
print(to_str(123))
print(to_str([1, 2, 3]))
print(to_str({'name': '两点水'}))
输出结果:
但是仔细想想,这个 x 真的是「啥都行」吗?不是。str(x) 内部其实只调用 x.__str__(),所以更准确的描述是「任何能 str() 的对象」。Python 中所有对象都有 __str__(继承自 object),所以这里其实标 object 比 Any 更准确:
def to_str(x: object) -> str:
return str(x)
print(to_str(123))
print(to_str([1, 2, 3]))
print(to_str({'name': '两点水'}))
输出结果:
效果一样,但是注解的「自带文档」效果好得多。看到 x: Any,读者会想「难道还能传函数、类、生成器?」;看到 x: object,读者立刻就明白「随便给个对象都能用」。这种细节的考究,会让你的代码读起来很专业。
顺便提一个跟 Any 经常被搞混的类型——object。object 是所有类型的父类,注解写 object 意味着「随便传,但你拿到之后只能用所有对象都有的方法(比如 str())」。它跟 Any 长得像,行为很不一样:
输出结果:
如果你给 x 标 object,然后函数体里写 x.upper(),类型检查器会报错——因为「object 没有 upper 方法」。换成 Any 就不会报。所以如果想「让函数能接受任意类型,但又强制使用前要先判断/转换」,标 object 比标 Any 更安全。
类型起得太长?给它起个别名¶
来看一个略复杂的场景。「做鸭事业部」要给所有员工建一个档案库,每个员工的档案是「姓名 + 标签列表」,整个库是「部门名 → 员工档案列表」的映射。注解写出来是这样:
def add_employee(
db: dict[str, list[tuple[str, list[str]]]],
dept: str,
profile: tuple[str, list[str]],
) -> None:
db.setdefault(dept, []).append(profile)
db: dict[str, list[tuple[str, list[str]]]] = {}
add_employee(db, '做鸭事业部', ('两点水', ['Python', '后端']))
add_employee(db, '做鸭事业部', ('三点水', ['前端']))
print(db)
输出结果:
是不是发现 dict[str, list[tuple[str, list[str]]]] 这一长串读起来真的很累?而且每次写到这个类型都得复制一遍,万一哪里少打个方括号,bug 就埋下了。
这时候就该「类型别名」上场了。从 Python 3.12 开始,有了一个全新的 type 关键字(PEP 695),可以这么写:
type Profile = tuple[str, list[str]]
type EmployeeDB = dict[str, list[Profile]]
def add_employee(db: EmployeeDB, dept: str, profile: Profile) -> None:
db.setdefault(dept, []).append(profile)
db: EmployeeDB = {}
add_employee(db, '做鸭事业部', ('两点水', ['Python', '后端']))
add_employee(db, '做鸭事业部', ('三点水', ['前端']))
print(db)
Python 3.12+ 才支持
type Alias = ...这种语法。
这样一来,Profile 和 EmployeeDB 这两个名字一眼就能看懂含义,而且只要类型定义集中在一个地方,要改也方便。
实际跑一下(在 3.12+ 上):
Profile = tuple[str, list[str]]
EmployeeDB = dict[str, list[Profile]]
def add_employee(db: EmployeeDB, dept: str, profile: Profile) -> None:
db.setdefault(dept, []).append(profile)
db: EmployeeDB = {}
add_employee(db, '做鸭事业部', ('两点水', ['Python', '后端']))
add_employee(db, '做鸭事业部', ('三点水', ['前端']))
print(db)
输出结果:
注意了,上面这段代码其实是「等号右边赋值的写法」——直接把类型当作普通变量赋值。这种写法在所有支持类型注解的版本都能跑,运行时表现就是定义一个普通变量。但严格来说,类型检查器并不一定把它视为「类型别名」。
如果项目跑在 3.10、3.11 上,又想要明确告诉检查器「这是别名」,可以用 TypeAlias:
from typing import TypeAlias
Profile: TypeAlias = tuple[str, list[str]]
EmployeeDB: TypeAlias = dict[str, list[Profile]]
def add_employee(db: EmployeeDB, dept: str, profile: Profile) -> None:
db.setdefault(dept, []).append(profile)
db: EmployeeDB = {}
add_employee(db, '做鸭事业部', ('两点水', ['Python']))
print(db)
输出结果:
简单总结一下:
- Python 3.12+:直接
type Alias = ...,最干净 - Python 3.10/3.11:用
Alias: TypeAlias = ... - 普通赋值
Alias = ...:能跑,但语义上不如前两种明确
类型别名还有一个特别实用的场景:给业务概念起名字。比如下面这段:
UserId = int
ProductId = int
def get_user(uid: UserId) -> str:
return f'用户 {uid}'
def get_product(pid: ProductId) -> str:
return f'商品 {pid}'
print(get_user(1001))
print(get_product(2002))
输出结果:
UserId 和 ProductId 底下都是 int,但是名字不一样。代码读起来 def get_user(uid: UserId) 比 def get_user(uid: int) 信息量大得多——一眼就能看出「这个 int 不是随便一个数字,是用户 ID」。
来看一段反面教材,「做鸭事业部」原本的代码是这样的:
def transfer(from_id: int, to_id: int, product_id: int, amount: int) -> bool:
print(f'从用户 {from_id} 给用户 {to_id} 转 {amount} 个商品 {product_id}')
return True
transfer(1001, 2002, 3003, 5)
输出结果:
四个参数全是 int,调用的时候顺序写错一个,就完蛋了——可能商品 ID 当成用户 ID,金额当成商品 ID,连报错都不会有,因为类型对得上。改成有名字的别名后,调用时虽然语法上不强制,但是 IDE 提示会变成 from_id: UserId, to_id: UserId, product_id: ProductId, amount: int,写错的概率就低多了。
要进一步防止「把商品 ID 当用户 ID 传错」这种 bug,还可以用 NewType:
from typing import NewType
UserId = NewType('UserId', int)
ProductId = NewType('ProductId', int)
def get_user(uid: UserId) -> str:
return f'用户 {uid}'
print(get_user(UserId(1001)))
输出结果:
NewType 在运行时其实就是个透传函数,但在类型检查器眼里 UserId 和 int 是两种不同的类型——传普通 int 进去会被报错。这就把「业务概念」实实在在地变成了「类型」。这个特性在大型项目里特别有用,但小项目用 type Alias 就够了。
类的属性怎么注解¶
前面写了一个 Employee,类的属性注解我们顺便聊深一点。
最朴素的写法是这样:
class Employee:
def __init__(self, name, age, salary):
self.name = name
self.age = age
self.salary = salary
emp = Employee('两点水', 28, 12000.5)
print(emp.name)
输出结果:
读这个 Employee 类,单看 __init__ 我们不知道每个属性是啥类型。改造一下:
class Employee:
def __init__(self, name: str, age: int, salary: float) -> None:
self.name = name
self.age = age
self.salary = salary
emp = Employee('两点水', 28, 12000.5)
print(emp.name, emp.age, emp.salary)
输出结果:
参数注解上去之后,IDE 一般就能把 self.name 推断为 str 了。如果想再明确一些,把属性也单独声明在类体里:
class Employee:
name: str
age: int
salary: float
def __init__(self, name: str, age: int, salary: float) -> None:
self.name = name
self.age = age
self.salary = salary
emp = Employee('两点水', 28, 12000.5)
print(emp.name, emp.age, emp.salary)
输出结果:
类体顶部三行是「类级注解」,这是给类型检查器和工具看的。它跟 __init__ 里的 self.xxx = xxx 配合,让整个类的「属性蓝图」一目了然。
那么「在类体里直接 name: str」和「__init__ 里 self.name = name」是不是必须配对呢?严格说不是必须,但是配对最稳妥——光在类体里声明而不在 __init__ 里赋值,访问时会报 AttributeError;光在 __init__ 里赋值而不在类体里声明,类型检查器对类的「属性蓝图」就没那么清楚。
实例方法的注解也按同样规则来,self 不用注解,其他参数和返回值正常写:
class Wallet:
balance: float
def __init__(self, initial: float = 0) -> None:
self.balance = initial
def deposit(self, amount: float) -> float:
self.balance += amount
return self.balance
def can_afford(self, price: float) -> bool:
return self.balance >= price
w = Wallet(100.0)
print(w.deposit(50.5))
print(w.can_afford(120))
print(w.can_afford(160))
输出结果:
签名读起来很顺畅吧?deposit 「接收 float、返回 float」,can_afford 「接收 float、返回 bool」,意图都一目了然。
那么各位想想,写过几个这种「一堆属性 + 一个 __init__ 全是赋值」的类之后,是不是会觉得很烦?每个属性都要写两遍(类体里一次、__init__ 里又一次)。
是的,这就是 dataclass 想要解决的问题。它能让你只写类体那段类型声明,__init__ 自动帮你生成。这部分留到 python20 专门讲,这里先卖个关子。
Protocol——类型注解版的 duck typing¶
学 Python 久了的童鞋肯定听过一句话:「If it walks like a duck and quacks like a duck, it's a duck.」——鸭子类型。Python 的核心精神之一,就是「我不管你是不是 Duck 类的实例,只要你会 quack(),我就当你是鸭」。
可是问题来了:在类型注解里怎么表达这个意思?总不能写 def feed(d: Duck) 然后强制大家都继承 Duck 吧,那就违背鸭子类型的初衷了。
Python 3.8 引入了 Protocol(PEP 544),专门解决这个事。它的意思是:只要这个对象有我要的方法、属性,就算我没继承它,也算它符合这个协议。
来看个例子。「做鸭事业部」养了好多种动物,我们想写一个通用的「让它们叫一声」的函数:
from typing import Protocol
class Quacker(Protocol):
def quack(self) -> str:
...
class Duck:
def quack(self) -> str:
return '嘎嘎嘎'
class Dog:
def quack(self) -> str:
return '汪汪汪(其实我不是鸭)'
def let_it_quack(animal: Quacker) -> None:
print(animal.quack())
let_it_quack(Duck())
let_it_quack(Dog())
输出结果:
各位看,Duck 和 Dog 都没有继承 Quacker,但是它们都有 quack 方法,所以传给 let_it_quack 都没问题——这就是「结构化子类型」。如果传一个没有 quack 方法的对象进去,类型检查器会立刻报错。
Protocol 的好处是:你不用提前规划好继承关系,只要「形状」对得上就行。这是写库、写框架时非常好用的工具。
再看一个更贴近实际的场景。假如我们要写一个「打印任意东西的尺寸」的工具函数,按 duck typing 的思路,「只要这东西能 len(),我就当它有尺寸」:
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int:
...
def show_size(obj: Sized) -> None:
print(f'尺寸是 {len(obj)}')
show_size('两点水')
show_size([1, 2, 3, 4])
show_size({'a': 1, 'b': 2})
输出结果:
str、list、dict 都没继承我们定义的 Sized,但是它们都实现了 __len__,所以全都符合协议。这种「不靠继承、靠形状」的多态,写起来又灵活又类型安全。
Protocol 不止能描述「方法」,也能描述「属性」。比如「我需要一个有 name 属性的对象」:
from typing import Protocol
class HasName(Protocol):
name: str
class Employee:
def __init__(self, name: str, dept: str) -> None:
self.name = name
self.dept = dept
class Pet:
def __init__(self, name: str, kind: str) -> None:
self.name = name
self.kind = kind
def shout_name(obj: HasName) -> None:
print(f'你好,{obj.name}!')
shout_name(Employee('两点水', '做鸭事业部'))
shout_name(Pet('小黄', '鸭子'))
输出结果:
Employee 和 Pet 八竿子打不着,但是它们都有 name 属性,所以都能传给 shout_name。
这种写法在写库、写中间层的时候特别好用——你不用强迫调用方继承你定义的基类,他们的现有类型只要「形状对得上」就能直接接进来,可拓展性极强。
这里只是浅尝辄止,Protocol 还有 runtime_checkable、属性协议等等更深的用法,留给善于思考的各位自己探索。
写完注解,怎么真的检查类型¶
聊到这里,各位应该会问一个问题:「我注解都写了,可是 Python 解释器又不强制,那要怎么真的发现类型错误啊?」
答案是:用静态类型检查器。常见的两个:
mypy:老牌,社区生态最成熟pyright:微软出的,速度快,VSCode 里 Pylance 的核心就是它
随便挑一个就行。安装:
假设我们写了这么一个文件 bad.py:
跑 python bad.py,能跑出来(虽然结果是 水哥水哥水哥),但是跑 mypy bad.py:
bad.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
再换一种典型错误,比如忘记处理 None:
def find_id(name: str) -> str | None:
db = {'两点水': '001'}
return db.get(name)
emp_id = find_id('四点水')
print(emp_id.upper())
直接跑 python 会报 AttributeError: 'NoneType' object has no attribute 'upper',等于线上挂了;而 mypy 在写完代码就提醒:
各位看,这种「忘了判 None」的错,mypy 简直是降维打击——你脑子里没想到的边界情况,它机器扫一遍就给你揪出来。
看,运行时混过去的错误,mypy 在「写完代码、还没运行」时就给抓出来了。这就是类型注解最大的意义所在。
实际项目里,一般会把 mypy 或者 pyright 配进 CI,跟单元测试一起跑,就能在合并代码前拦住一大批低级错误。
那么各位可能又会问:「IDE(PyCharm、VSCode)已经能根据注解给我飘红了,我还需要单独跑 mypy 吗?」
答案是:最好还是跑。原因有几个:
- IDE 飘红只在你打开那个文件时才看得到。整个项目里没人打开过的旧文件,类型错误可能藏好几年
- 不同人电脑上的 IDE 配置可能不一样,有人飘红、有人不飘
- CI 里跑 mypy 是「客观的、强制的」,没过就拦下来,没法装作没看见
所以推荐的做法是:写代码时靠 IDE 即时反馈,提交代码前由 mypy/pyright 把关。两条防线一起上,类型错误才能真的被治住。
最后再交代一个常见疑问:「老项目里一堆没注解的代码,跑 mypy 会不会满屏报错?」会,所以对老项目,一般是「逐步引入」——
- 先在
mypy.ini或者pyproject.toml里把检查级别调宽松(--ignore-missing-imports、不强制返回类型注解等等) - 新写的代码全部带注解,老代码遇到改动时顺手补
- 几个迭代下来,覆盖率慢慢爬上来,再逐步收紧规则
千万别一上来就开 --strict,那基本等于「我要重写整个项目」,谁都顶不住。一步步来才是稳的。
一些常见疑问¶
写到最后再统一回答几个童鞋反复问的问题:
Q1:注解会不会让 Python 变慢?
几乎不会。注解在解释器层面只是「读取一下、存到 __annotations__ 里」,运行时不做任何检查。除非函数被调用得极其频繁、且参数注解极其复杂,否则你测都测不出来差别。安心写就好。
Q2:注解和 docstring 冲突吗?
不冲突,是互补关系。注解负责「类型」,docstring 负责「业务含义、行为约束、示例」。两者一起上才是完整的:
def punch(name: str, dept: str = '做鸭事业部') -> None:
"""记录员工上班打卡。
Args:
name: 员工昵称,比如 '两点水'
dept: 部门名称,默认是 '做鸭事业部'
打卡只记录到内存,不写数据库。
"""
print(f'{name}({dept})打卡成功')
punch('两点水')
输出结果:
Q3:每一行都要写注解吗?
不必。优先级从高到低是:
- 公共函数/类(要给别人调用的):尽量都加,这是接口契约
- 复杂的业务函数(参数多、嵌套层级深的):加,方便后期维护
- 简单的一次性脚本:随意,不加也无所谓
像 for i in range(10): print(i) 这种 i 显然是 int 的循环变量,没必要硬塞个 i: int。注解的价值是「消除歧义」,没歧义的地方加了反而显得啰嗦。
Q4:第三方库没注解怎么办?
很多老库可能没写注解,那就只能:
- 看库的
py.typed标记是否存在(存在表示作者承诺了类型支持) - 没有的话,用
typeshed社区维护的存根包(package 名一般是types-xxx) - 实在没有,就在调用处用变量注解「补救」一下:
result: list[str] = some_legacy_lib.get_data()
主流的现代库(FastAPI、httpx、Pydantic、SQLAlchemy 2.x 等等)都已经全面拥抱类型注解,新选库时多看一眼这点准没错。
小结¶
类型注解这玩意儿,乍一看是给 Python 加负担,写惯了之后会发现真香。最后再总结三点:
- 注解只是给人和工具看的,Python 解释器不强制;想真的检查类型,跑 mypy 或 pyright
- 拥抱新写法:
X | None代替Optional[X],list[int]代替List[int],少一份 import 多一份清爽 - 从签名开始写起:函数参数和返回值是性价比最高的注解位置;变量注解、类属性注解按需补;遇到长得离谱的类型,就用类型别名命名
慢慢把注解习惯养起来,你写出来的代码会一天比一天专业。
最后留一个小思考给善于思考的各位:本文里我们见到了 int | None、list[int]、Callable[[int, int], int]、Protocol、类型别名…… 那么如果想写一个「接收任意类型 T,返回同样类型 T 的列表」的函数(比如「把任意元素包成单元素列表」),该怎么写呢?
提示一下:这就要用到「泛型」(Generics)了,关键字是 TypeVar 或者 Python 3.12+ 的新泛型语法(def wrap[T](x: T) -> list[T]: ...)。这部分内容比较进阶,不过等你写过几个工具函数、几个小框架之后,你自然就会想去查它了。慢工出细活,咱们慢慢来。
再多举几个学完本章后可以继续深挖的方向:
Literal['get', 'post']:限定字符串只能是有限几个值,写 API 客户端时特别好用TypedDict:给字典里每个键都标具体类型,比dict[str, Any]强多了Final:声明「这个变量赋值后不准改」@override(Python 3.12+):明确标记「我是在覆盖父类方法」Self类型(Python 3.11+):在类里写「返回我自己」更优雅- 泛型类、泛型函数、
ParamSpec:写工具库的标配
这些都是类型注解大家族里的成员,不用一次学完,遇到再学是最高效的。
类型注解这件事,最重要的不是「学全」,而是「用起来」。从今天开始,每写一个新函数都顺手把签名补上,一个月之后再回头看,你会发现自己写代码的「肌肉记忆」已经完全变了——这就够了。