Python 类型标注
Python 是动态语言,不写类型也能运行。但商业项目一旦变大,函数调用链、接口返回值、配置对象、数据模型都会越来越多。如果没有类型标注,代码很容易变成“运行前谁也不知道会不会传错”的状态。
类型标注的核心价值不是让 Python 变成 Java,而是把“函数契约”写清楚,让人、IDE、静态检查工具都能提前发现问题。
学完本页后,你应该能做到:
- 明白类型标注不会自动改变运行时行为。
- 能看懂函数参数、返回值、容器、可空值、回调函数的类型写法。
- 知道
Any为什么危险,什么时候才应该用。 - 能区分
dict、TypedDict、dataclass、Pydantic 模型的使用边界。 - 能用类型标注设计一个可维护的商业导入服务。
- 能回答面试中“Python 动态语言为什么还要类型标注”的问题。
为什么需要类型标注
没有类型标注时,你看到一个函数:
def get_user(id):
...你不知道:
id应该是字符串还是数字。- 返回值是字典、对象还是
None。 - 调用方应该怎么处理查询不到的情况。
- 函数内部依赖哪些字段。
加上类型后:
def get_user(user_id: int) -> dict[str, str] | None:
...信息就清楚很多:入参是整数,返回值可能是字典,也可能是 None。调用者必须处理空值。
类型标注解决的是“协作成本”问题:
flowchart TD
A["函数没有类型"] --> B["调用者靠猜"]
B --> C["传错参数也可能运行到很深才报错"]
C --> D["线上问题难定位"]
A2["函数有类型"] --> B2["IDE 提示入参和返回值"]
B2 --> C2["mypy/pyright 提前发现类型不匹配"]
C2 --> D2["评审和维护成本降低"]类型标注的工作原理
Python 的类型标注默认不会做运行时强校验。
def add(a: int, b: int) -> int:
return a + b
print(add("1", "2")) # 运行结果是 "12",不会因为标注 int 就自动报错为什么?因为 Python 运行时仍然是动态类型,类型标注主要存放在函数对象的 __annotations__ 中,供 IDE、文档工具、静态检查工具使用。
def add(a: int, b: int) -> int:
return a + b
print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}类型标注的检查链路:
flowchart TD
A["编写类型标注"] --> B["IDE 读取类型信息"]
B --> C["代码补全和错误提示"]
A --> D["mypy/pyright 静态分析"]
D --> E{"调用是否符合契约"}
E -->|否| F["开发阶段修复"]
E -->|是| G["继续运行单元测试"]
G --> H["提交代码"]结论:类型检查不能替代测试。类型检查发现“类型不匹配”,测试验证“业务行为是否正确”。
版本差异:Python 3.8、3.9、3.10+
项目里要注意 Python 版本。很多老项目还在 Python 3.8,新项目可能是 3.10 或 3.11。
| 能力 | Python 3.8 及以前常见写法 | Python 3.9+ | Python 3.10+ |
|---|---|---|---|
| 列表泛型 | List[str] | list[str] | list[str] |
| 字典泛型 | Dict[str, int] | dict[str, int] | dict[str, int] |
| 可空类型 | Optional[str] | Optional[str] | `str |
| 联合类型 | Union[int, str] | Union[int, str] | `int |
| TypedDict | typing_extensions.TypedDict 或 typing.TypedDict | typing.TypedDict | typing.TypedDict |
如果项目要求兼容 Python 3.8,建议这样写:
from typing import Dict, List, Optional
def find_name(user_id: int) -> Optional[str]:
...
def group_users() -> Dict[str, List[int]]:
...如果项目是 Python 3.10+,可以写得更简洁:
def find_name(user_id: int) -> str | None:
...
def group_users() -> dict[str, list[int]]:
...本知识库示例优先使用较新的写法;老项目迁移时要根据运行版本调整。
基本写法
def greeting(name: str) -> str:
return f"hello {name}"
def add(a: int, b: int) -> int:
return a + b含义:
name: str表示参数name期望是字符串。-> str表示函数返回字符串。a: int表示参数a期望是整数。
变量也可以标注:
count: int = 0
username: str = "tom"
enabled: bool = True局部变量一般不需要每个都写类型。优先给函数签名、复杂结构、公共变量写类型。
常见基础类型
| 类型 | 写法 | 示例含义 |
|---|---|---|
| 字符串 | str | 用户名、标题、文件路径 |
| 整数 | int | 年龄、数量、分页页码 |
| 浮点数 | float | 比例、坐标、非精确小数 |
| 精确小数 | Decimal | 金额、价格 |
| 布尔值 | bool | 是否启用、是否删除 |
| 字节 | bytes | 文件二进制、网络报文 |
金额不要优先用 float,因为浮点数有精度问题。订单、支付、资产金额建议用 Decimal。
from decimal import Decimal
def calc_total(price: Decimal, quantity: int) -> Decimal:
return price * quantity容器类型
def total(prices: list[float]) -> float:
return sum(prices)
def count_by_department(rows: list[dict[str, str]]) -> dict[str, int]:
result: dict[str, int] = {}
for row in rows:
department = row["department"]
result[department] = result.get(department, 0) + 1
return result常见容器:
| 类型 | 示例 | 说明 |
|---|---|---|
list[str] | ["a", "b"] | 有序、可重复 |
set[str] | {"A001", "A002"} | 去重、快速判断是否存在 |
dict[str, int] | {"ICU": 10} | key-value 映射 |
tuple[str, int] | ("A001", 1) | 固定结构 |
复杂嵌套类型要克制:
def parse(data: dict[str, list[dict[str, str]]]) -> None:
...这种签名虽然准确,但阅读困难。更好的方式是抽成 dataclass 或 TypedDict。
Optional 和可空值
str | None 表示返回值可能是字符串,也可能是空。
def find_name(user_id: int) -> str | None:
if user_id == 1:
return "张三"
return None调用方必须处理空值:
name = find_name(2)
if name is not None:
print(name.upper())
else:
print("用户不存在")为什么这很重要?
name = find_name(2)
print(name.upper()) # name 可能是 None,运行时报 AttributeError类型检查工具会提示你:None 没有 upper() 方法。
Union 和 Literal
Union 表示多种可能类型。Python 3.10+ 可以用 |。
def normalize_id(value: int | str) -> str:
return str(value).strip()Literal 用来限制只能传固定值。
from typing import Literal
Env = Literal["dev", "test", "prod"]
def connect(env: Env) -> None:
print(f"连接环境: {env}")类型检查工具可以发现错误:
connect("local") # 类型检查会提示不合法商业项目中,Literal 常用于环境、订单状态、任务类型、导出格式等固定取值。
Callable:函数也是类型
Callable 表示函数类型。
from collections.abc import Callable
def run_task(task: Callable[[str], bool], name: str) -> bool:
return task(name)Callable[[str], bool] 表示这个函数接收一个字符串,返回一个布尔值。
业务例子:导入时把校验函数作为参数传入。
from collections.abc import Callable
Validator = Callable[[dict[str, str]], list[str]]
def validate_rows(rows: list[dict[str, str]], validator: Validator) -> list[str]:
errors: list[str] = []
for row in rows:
errors.extend(validator(row))
return errors如果回调函数参数很多、返回值复杂,优先定义清晰的函数名、类型别名或协议,不要把签名写得像谜语。
TypeAlias:给复杂类型起名字
复杂类型可以用类型别名提升可读性。
from typing import TypeAlias
AssetCode: TypeAlias = str
DepartmentCode: TypeAlias = str
AssetCountByDepartment: TypeAlias = dict[DepartmentCode, int]
def count_assets(codes: list[AssetCode]) -> AssetCountByDepartment:
...类型别名不会创建新的运行时类型,它只是让签名更容易读。
dataclass
复杂数据结构不要总用普通 dict,可以使用 dataclass。
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class Asset:
code: str
name: str
department: str
price: Decimal使用:
asset = Asset(code="A001", name="心电监护仪", department="ICU", price=Decimal("12000"))
print(asset.name)优点:
- 字段清晰。
- IDE 有提示。
- 默认提供初始化方法。
- 比普通
dict更不容易写错 key。 - 适合在业务内部传递结构化数据。
如果对象创建后不希望被修改,可以使用 frozen=True:
@dataclass(frozen=True)
class ImportResult:
success_count: int
error_count: int这不是绝对安全的权限控制,但能减少误修改。
TypedDict
如果接口返回的是 dict,但你又想描述字段结构,可以用 TypedDict。
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
active: bool
def get_user() -> UserDict:
return {"id": 1, "name": "张三", "active": True}适合描述 JSON、接口返回值、配置项。
可选字段:
from typing import NotRequired, TypedDict
class ApiResponse(TypedDict):
code: int
message: str
data: NotRequired[dict[str, str]]TypedDict 仍然是普通字典,运行时不会阻止你写错 key。它主要给类型检查工具用。
dict、TypedDict、dataclass、Pydantic 怎么选
| 结构 | 适合场景 | 优点 | 注意点 |
|---|---|---|---|
dict | 临时数据、很简单的 JSON | 灵活 | key 容易写错 |
TypedDict | 描述接口 JSON 或配置字典 | 不改变原有 dict 结构 | 运行时不校验 |
dataclass | 业务内部领域对象 | 字段清晰、IDE 友好 | 默认不做复杂校验 |
| Pydantic | API 入参、配置、需要运行时校验 | 可校验、可转换、错误信息清楚 | 引入第三方依赖 |
如果只是内部传递资产对象,用 dataclass。如果描述接口返回 JSON,用 TypedDict。如果要校验用户请求体,Web 项目里通常用 Pydantic 或框架提供的模型能力。
Protocol:面向能力编程
Protocol 用来描述“只要你有这些方法,就符合这个类型”。它适合插件、存储适配器、通知渠道等场景。
from typing import Protocol
class Notifier(Protocol):
def send(self, user_id: int, message: str) -> None:
...
class SmsNotifier:
def send(self, user_id: int, message: str) -> None:
print(f"发送短信给 {user_id}: {message}")
def notify_user(notifier: Notifier, user_id: int) -> None:
notifier.send(user_id, "任务完成")SmsNotifier 没有显式继承 Notifier,但只要方法签名符合,就可以被当作 Notifier 使用。这很符合 Python 的鸭子类型思想。
Generic:泛型
泛型表示“类型可以由调用方决定”。
from typing import Generic, TypeVar
T = TypeVar("T")
class Repository(Generic[T]):
def __init__(self):
self.items: list[T] = []
def add(self, item: T) -> None:
self.items.append(item)
def list_all(self) -> list[T]:
return self.items使用:
repo = Repository[str]()
repo.add("A001")
codes = repo.list_all()泛型的意义:Repository 可以复用,但不会丢失元素类型。
Any:最危险的类型
Any 表示任意类型。
from typing import Any
def parse(data: Any) -> Any:
return dataAny 会让类型检查失效。一个值只要是 Any,你对它调用任何方法,类型工具都很难提示。
from typing import Any
def handle(data: Any) -> None:
data.not_exists().still_ok_for_type_checker()什么时候可以用 Any?
- 第三方库没有类型信息。
- 数据结构确实未知,且当前层只透传。
- 迁移老项目时,先用
Any过渡,后续逐步收紧。
不要为了省事把所有函数都写成 Any,那等于放弃类型标注。
mypy 入门
安装:
pip install mypy运行:
mypy app.py示例:
def add(a: int, b: int) -> int:
return a + b
add("1", 2)类型检查会提示传入了错误类型。
项目可以增加 mypy.ini:
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_ignores = True
disallow_untyped_defs = True含义:
| 配置 | 作用 |
|---|---|
python_version | 按指定 Python 版本理解语法 |
warn_return_any | 返回 Any 时给警告 |
warn_unused_ignores | # type: ignore 没必要时提醒 |
disallow_untyped_defs | 函数必须写类型 |
老项目不要一上来开最严格配置。可以先从核心目录、核心服务逐步收紧。
pyright 入门
Pyright 常和 VS Code / Pylance 一起使用,也可以命令行运行。
npm install -g pyright
pyrightpyrightconfig.json 示例:
{
"typeCheckingMode": "basic",
"include": ["src"],
"exclude": ["venv", ".venv"]
}basic 适合新项目初期或老项目迁移;当类型质量变好后再切到 strict。
商业 Demo:资产导入服务的类型设计
需求:读取外部系统导出的资产 CSV,校验后返回导入预览结果。这里重点不是文件读取细节,而是类型如何帮助代码更清晰。
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal, TypedDict
AssetStatus = Literal["valid", "invalid"]
class RawAssetRow(TypedDict):
asset_code: str
asset_name: str
department: str
price: str
@dataclass(frozen=True)
class Asset:
code: str
name: str
department: str
price: Decimal
@dataclass(frozen=True)
class RowError:
line_no: int
asset_code: str
reason: str
@dataclass(frozen=True)
class ImportPreview:
status: AssetStatus
valid_rows: list[Asset]
errors: list[RowError]
def parse_asset(line_no: int, row: RawAssetRow) -> Asset | RowError:
code = row["asset_code"].strip()
name = row["asset_name"].strip()
department = row["department"].strip()
if not code:
return RowError(line_no=line_no, asset_code="", reason="资产编号不能为空")
if not name:
return RowError(line_no=line_no, asset_code=code, reason="资产名称不能为空")
if not department:
return RowError(line_no=line_no, asset_code=code, reason="科室不能为空")
try:
price = Decimal(row["price"])
except Exception:
return RowError(line_no=line_no, asset_code=code, reason="价格格式错误")
return Asset(code=code, name=name, department=department, price=price)
def build_preview(rows: list[RawAssetRow]) -> ImportPreview:
assets: list[Asset] = []
errors: list[RowError] = []
seen_codes: set[str] = set()
for line_no, row in enumerate(rows, start=2):
result = parse_asset(line_no, row)
if isinstance(result, RowError):
errors.append(result)
continue
if result.code in seen_codes:
errors.append(
RowError(
line_no=line_no,
asset_code=result.code,
reason="资产编号重复",
)
)
continue
seen_codes.add(result.code)
assets.append(result)
status: AssetStatus = "valid" if not errors else "invalid"
return ImportPreview(status=status, valid_rows=assets, errors=errors)这段代码为什么适合商业项目?
RawAssetRow描述外部 CSV 的原始结构。Asset描述业务内部可信对象。RowError描述单行错误,便于前端展示。ImportPreview描述服务返回结果。AssetStatus限制状态只能是valid或invalid。- 调用者不用猜返回值结构,IDE 可以直接提示字段。
类型流转过程:
flowchart TD
A["CSV 原始行 dict"] --> B["RawAssetRow 描述字段"]
B --> C["parse_asset 校验和转换"]
C --> D{"是否合法"}
D -->|是| E["Asset 业务对象"]
D -->|否| F["RowError 错误对象"]
E --> G["ImportPreview.valid_rows"]
F --> H["ImportPreview.errors"]
G --> I["返回给 API 或任务入口"]
H --> I项目中优先给哪里加类型
优先级从高到低:
- 公共函数和工具函数。
- Service 层方法。
- API 入参和返回值。
- 配置对象。
- 数据库实体或领域对象。
- 复杂导入、清洗、计算逻辑。
- 回调函数、插件接口、策略接口。
临时脚本可以少写一点,但核心项目代码建议逐步补齐。
常见坑
| 问题 | 后果 | 建议 |
|---|---|---|
到处写 Any | 类型检查失效 | 只在边界或迁移期临时使用 |
返回值没写 None | 调用方忘记判空 | 查询不到时明确写 `T |
| 复杂 dict 不建模 | key 写错难发现 | 用 TypedDict 或 dataclass |
| 类型和真实返回不一致 | 工具被误导 | 类型标注必须跟代码一起维护 |
| 老版本使用新语法 | 运行直接语法错误 | 按 Python 版本选择写法 |
| 只做类型检查不写测试 | 业务错了发现不了 | 类型检查和单元测试都要有 |
生产排查流程
类型问题经常表现为运行时异常:AttributeError、TypeError、KeyError。
flowchart TD
A["线上出现类型相关异常"] --> B{"是否空值调用"}
B -->|是| C["返回类型补 T | None 并强制调用方判空"]
B -->|否| D{"是否 dict key 缺失"}
D -->|是| E["用 TypedDict/模型描述接口字段并校验输入"]
D -->|否| F{"是否第三方返回结构变化"}
F -->|是| G["在边界层转换成内部 dataclass"]
F -->|否| H{"是否 Any 扩散"}
H -->|是| I["收紧 Any,补类型别名和协议"]
H -->|否| J["补单元测试和类型检查规则"]排查重点:
| 异常 | 可能原因 | 改进方式 |
|---|---|---|
AttributeError: 'NoneType' | 查询结果没判空 | 返回类型写 `T |
KeyError | dict 字段缺失 | TypedDict + 输入校验 |
TypeError | 参数类型不符合预期 | 函数签名补类型,入口做转换 |
| 金额计算不准 | 使用 float | 改用 Decimal |
| IDE 没提示 | 类型全是 Any 或没写类型 | 补 dataclass、TypedDict、Protocol |
面试标准回答
Python 是动态语言,为什么还要类型标注?
标准回答:类型标注不会改变 Python 动态语言本质,它主要用于提升代码可读性、IDE 提示和静态检查能力。项目变大后,类型标注能把函数入参、返回值、可空情况、复杂数据结构写成明确契约,减少协作成本和低级类型错误。
类型标注会在运行时强制检查吗?
标准回答:默认不会。类型标注主要保存在 __annotations__ 中,给 IDE、mypy、pyright 等工具使用。运行时要做强校验,需要自己写校验逻辑,或者使用 Pydantic 等库。
Optional[str] 和 str | None 有什么区别?
标准回答:语义上都表示可能是字符串,也可能是 None。Optional[str] 是老写法,兼容 Python 3.8 等版本;str | None 是 Python 3.10+ 的新写法,更简洁。
Any 有什么风险?
标准回答:Any 会绕过类型检查。一个值如果是 Any,类型检查工具很难发现错误方法调用、错误字段访问和错误赋值。项目里应该只在第三方边界、动态数据或迁移期临时使用,核心业务要逐步收紧类型。
TypedDict 和 dataclass 怎么选?
标准回答:TypedDict 适合描述 JSON、接口返回、配置这类本来就是字典的数据;dataclass 适合业务内部领域对象,字段清晰、IDE 友好,不容易写错 key。如果需要运行时校验和转换,可以考虑 Pydantic。
Protocol 有什么作用?
标准回答:Protocol 描述对象需要具备哪些方法,不要求显式继承。它适合插件、策略、通知渠道、仓储接口等场景,既保留 Python 鸭子类型的灵活性,又能让类型检查工具验证对象能力。
练习
- 给一个
add(a, b)函数添加类型。 - 写一个返回
str | None的查询函数,并在调用方正确判空。 - 使用
dataclass定义Article。 - 使用
TypedDict描述一个 JSON 响应。 - 使用
Literal限制订单状态只能是created、paid、cancelled。 - 使用
Protocol定义一个通知接口,并实现短信通知和邮件通知。 - 故意传错类型,运行 mypy 或 pyright 看提示。
关联知识点
- Python 数据结构:理解
list、dict、set的底层特点和使用场景。 - Python 函数与模块:理解函数签名、模块拆分和公共 API 设计。
- Python 面向对象:理解
dataclass、接口、组合和依赖注入。 - Python 异常与文件:理解导入文件 Demo 中类型与异常如何配合。
- Python 测试:类型检查不能替代测试,二者要一起使用。
