跳转至

类型注解

学完前面的章节,相信各位已经能写出像模像样的 Python 代码了。函数、类、闭包、装饰器,一路下来都不是事儿。可是啊,写代码这件事,自己写得爽是一回事,给别人看,或者半年后自己再回来看,就是另一回事了。

来,我们看一段「水哥」上周刚交接给童鞋的代码:

def calc(x, y, op):
    if op == 'add':
        return x + y
    elif op == 'sub':
        return x - y

童鞋看着这个 calc 一脸懵:xy 到底是数字还是字符串?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'))

输出结果:

8
6

是不是发现,现在这个函数一眼就能看明白:xyintopstr,返回值也是 int。就算没有任何注释,光看签名就够了。

注解的语法很简单,记住两点:

  • 参数后面用 : 类型 标注
  • 返回值用 -> 类型 标注

记住这个比例:「参数加冒号,返回加箭头」。语法上跟原来的写法 100% 兼容,没注解的旧代码不需要改一行字符就能跟有注解的新代码混着跑。

那么有童鞋可能会问:「我能不能只标参数,不标返回值?或者只标返回值,不标参数?」

完全可以,类型注解是「想标哪里标哪里」的,不强制全标。比如:

def double(x: int):
    return x * 2


def greet(name) -> str:
    return f'你好,{name}'


print(double(3))
print(greet('两点水'))

输出结果:

6
你好,两点水

但是从「契约清晰」的角度,建议要么全标,要么干脆都不标。半标半不标看着特别割裂,读代码的人会怀疑「漏标的那个是忘了,还是故意的?」

这里有个非常重要的点,各位一定要记住:Python 解释器并不会强制检查这些类型。它只是个「说明」,给人看,给工具(IDE、静态检查器)看。

不信我们试试,故意传错类型:

def calc(x: int, y: int) -> int:
    return x + y


print(calc('两点水', '做鸭事业部'))

输出结果:

两点水做鸭事业部

看,明明标了 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='做鸭事业部'))

输出结果:

15
{'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))

输出结果:

两点水(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 == 1False == 0),所以一个标了 int 的参数,传 True 进去类型检查器是不报错的。但反过来,标 bool 的参数你传个 1,检查器就会拦下来。这点平时知道一下就行,写注解还是按「真实意图」来写:开关用 bool,数字用 int

再说一个跟 int 相关的小坑:Python 的 int 是「无限精度」的,没有 int32int64 之分,所以注解里也只有一个 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': '科技园'},
))

输出结果:

两点水 / 28 岁 / 1.75m / 实习:False / 技能:['Python', 'Django'] / 地址:{'city': '深圳', 'street': '科技园'}

现在这个签名读起来已经清晰多了,但是仔细看,skills: listaddress: dict 这两个还不够好——「列表里装的啥?字典的键值都是啥类型?」我们不知道。这就要靠下一节讲的「容器类型」来进一步细化。

如果一个函数没有返回值(也就是返回 None),返回类型写 None 就好:

def greet(name: str) -> None:
    print(f'你好,{name}')


greet('两点水')

输出结果:

你好,两点水

注意啊,这里的 -> 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('两点水', '做鸭事业部')

输出结果(日期会随当前时间变):

2026-04-28:昵称:两点水  部门:做鸭事业部 上班打卡成功

各位看,仅仅在签名里加了 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('四点水'))

输出结果:

001
None

看出来问题了吗?我们标的返回类型是 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('四点水'))

输出结果:

001
None

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 可能是 NoneNone 没有 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('员工不存在')

输出结果:

001

走过这个 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))

输出结果:

3
5
7

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 | Noneint | 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]))

输出结果:

30000

这里标了 list,但是「列表里到底装的是啥」没说清楚。是 int?是 float?是 str?读代码的人还是要猜。

老写法又来了,从 typing 导入 ListDictTuple 这些大写开头的版本:

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]))

输出结果:

30000

注意看,这里直接用小写的 list[float],不用 import 任何东西。其他容器也一样:

def group_by_dept(staff: dict[str, list[str]]) -> int:
    return len(staff)


data = {
    '做鸭事业部': ['两点水', '三点水'],
    '做鹅事业部': ['四点水'],
}
print(group_by_dept(data))

输出结果:

2

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)))

输出结果:

(1, 3)
(10, 30)

Sequence[int] 接受 list、tuple、字符串……只要是「有顺序的容器」就行。同样的还有 Iterable[X](只要能遍历就行,连生成器也接受)、Mapping[K, V](能像字典一样取值就行)。这些抽象类型在写库的时候特别有用,能让函数接受更广泛的输入。日常写业务代码倒不用太纠结,直接 list[int] 也完全 OK。

tuple 稍微特殊一点。如果是固定长度的元组,每个位置类型都要写出来:

def parse_point(p: tuple[int, int]) -> int:
    x, y = p
    return x + y


print(parse_point((3, 5)))

输出结果:

8

如果是不定长但元素同类型的元组(比较少见),用 ...

def join_names(names: tuple[str, ...]) -> str:
    return '、'.join(names)


print(join_names(('两点水', '三点水', '四点水')))

输出结果:

两点水、三点水、四点水

setlist 类似:

def unique_count(tags: set[str]) -> int:
    return len(tags)


print(unique_count({'Python', 'Java', 'Python'}))

输出结果:

2

记住一句话: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, '四点水'))

输出结果:

3
3
0

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))

输出结果:

{'做鸭事业部': 2, '做鹅事业部': 1}

整个函数的输入输出,光看签名 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)

输出结果:

两点水 28 12000.5 ['Python', '后端']

那么有童鞋就要问了:「变量赋值的时候,类型不是一眼就能看出来吗?我直接 name = '水哥' 不就完了?还加注解干啥?」

主要有两种场景特别需要:

第一种,初始值还推断不出最终类型。比如初始化一个空列表,你想告诉别人「这是一个装字符串的列表」:

employees: list[str] = []
employees.append('两点水')
employees.append('三点水')
print(employees)

输出结果:

['两点水', '三点水']

如果不写注解,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)

输出结果:

5 ['首页'] 2026-04-28

注意看 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)

输出结果:

{'两点水': 95, '三点水': 88}
{'Python', '后端'}

第二种,只声明不赋值。比如类的属性:

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)

输出结果:

两点水 28

类体里直接 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)

输出结果:

默认计数器 2

注意啊,这种「类体里给默认值」的写法,本质上是「类属性」(所有实例共享)。如果默认值是不可变的(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))

输出结果:

8
15

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))

输出结果:

调用 add(3, 5)
8

这个 with_log 装饰器的签名是不是一目了然——「我吃一个『接收两个 int、返回 int』的函数,然后吐出来一个一模一样形状的函数」。当然,要写出能装饰任意函数的通用装饰器,光靠 Callable 还不够,得用 ParamSpecTypeVar 这些更进阶的工具,那就属于另一个话题了,这里先不展开。

那么还有一个角色叫 Any,意思是「啥类型都行,别检查」。它在你真的不在乎类型的时候用:

from typing import Any


def log(value: Any) -> None:
    print(f'记录:{value}')


log('两点水')
log(123)
log([1, 2, 3])

输出结果:

记录:两点水
记录:123
记录:[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': '两点水'}))

输出结果:

123
[1, 2, 3]
{'name': '两点水'}

但是仔细想想,这个 x 真的是「啥都行」吗?不是。str(x) 内部其实只调用 x.__str__(),所以更准确的描述是「任何能 str() 的对象」。Python 中所有对象都有 __str__(继承自 object),所以这里其实标 objectAny 更准确:

def to_str(x: object) -> str:
    return str(x)


print(to_str(123))
print(to_str([1, 2, 3]))
print(to_str({'name': '两点水'}))

输出结果:

123
[1, 2, 3]
{'name': '两点水'}

效果一样,但是注解的「自带文档」效果好得多。看到 x: Any,读者会想「难道还能传函数、类、生成器?」;看到 x: object,读者立刻就明白「随便给个对象都能用」。这种细节的考究,会让你的代码读起来很专业。

顺便提一个跟 Any 经常被搞混的类型——objectobject 是所有类型的父类,注解写 object 意味着「随便传,但你拿到之后只能用所有对象都有的方法(比如 str())」。它跟 Any 长得像,行为很不一样:

def show(x: object) -> None:
    print(str(x))


show('两点水')
show(123)
show([1, 2, 3])

输出结果:

两点水
123
[1, 2, 3]

如果你给 xobject,然后函数体里写 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)

输出结果:

{'做鸭事业部': [('两点水', ['Python', '后端']), ('三点水', ['前端'])]}

是不是发现 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 = ... 这种语法。

这样一来,ProfileEmployeeDB 这两个名字一眼就能看懂含义,而且只要类型定义集中在一个地方,要改也方便。

实际跑一下(在 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)

输出结果:

{'做鸭事业部': [('两点水', ['Python', '后端']), ('三点水', ['前端'])]}

注意了,上面这段代码其实是「等号右边赋值的写法」——直接把类型当作普通变量赋值。这种写法在所有支持类型注解的版本都能跑,运行时表现就是定义一个普通变量。但严格来说,类型检查器并不一定把它视为「类型别名」。

如果项目跑在 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'])]}

简单总结一下:

  • 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))

输出结果:

用户 1001
商品 2002

UserIdProductId 底下都是 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)

输出结果:

从用户 1001 给用户 2002 转 5 个商品 3003

四个参数全是 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)))

输出结果:

用户 1001

NewType 在运行时其实就是个透传函数,但在类型检查器眼里 UserIdint 是两种不同的类型——传普通 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)

输出结果:

两点水 28 12000.5

参数注解上去之后,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)

输出结果:

两点水 28 12000.5

类体顶部三行是「类级注解」,这是给类型检查器和工具看的。它跟 __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))

输出结果:

150.5
True
False

签名读起来很顺畅吧?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())

输出结果:

嘎嘎嘎
汪汪汪(其实我不是鸭)

各位看,DuckDog 都没有继承 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})

输出结果:

尺寸是 3
尺寸是 4
尺寸是 2

strlistdict 都没继承我们定义的 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('小黄', '鸭子'))

输出结果:

你好,两点水!
你好,小黄!

EmployeePet 八竿子打不着,但是它们都有 name 属性,所以都能传给 shout_name

这种写法在写库、写中间层的时候特别好用——你不用强迫调用方继承你定义的基类,他们的现有类型只要「形状对得上」就能直接接进来,可拓展性极强。

这里只是浅尝辄止,Protocol 还有 runtime_checkable、属性协议等等更深的用法,留给善于思考的各位自己探索。

写完注解,怎么真的检查类型

聊到这里,各位应该会问一个问题:「我注解都写了,可是 Python 解释器又不强制,那要怎么真的发现类型错误啊?」

答案是:用静态类型检查器。常见的两个:

  • mypy:老牌,社区生态最成熟
  • pyright:微软出的,速度快,VSCode 里 Pylance 的核心就是它

随便挑一个就行。安装:

pip install mypy

假设我们写了这么一个文件 bad.py

def add(x: int, y: int) -> int:
    return x + y


print(add('两点水', 3))

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 在写完代码就提醒:

bad.py:7: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]

各位看,这种「忘了判 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:每一行都要写注解吗?

不必。优先级从高到低是:

  1. 公共函数/类(要给别人调用的):尽量都加,这是接口契约
  2. 复杂的业务函数(参数多、嵌套层级深的):加,方便后期维护
  3. 简单的一次性脚本:随意,不加也无所谓

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 | Nonelist[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:写工具库的标配

这些都是类型注解大家族里的成员,不用一次学完,遇到再学是最高效的。

类型注解这件事,最重要的不是「学全」,而是「用起来」。从今天开始,每写一个新函数都顺手把签名补上,一个月之后再回头看,你会发现自己写代码的「肌肉记忆」已经完全变了——这就够了。