Skip to content

Python 类型标注

Python 是动态语言,不写类型也能运行。但商业项目一旦变大,函数调用链、接口返回值、配置对象、数据模型都会越来越多。如果没有类型标注,代码很容易变成“运行前谁也不知道会不会传错”的状态。

类型标注的核心价值不是让 Python 变成 Java,而是把“函数契约”写清楚,让人、IDE、静态检查工具都能提前发现问题。

学完本页后,你应该能做到:

  1. 明白类型标注不会自动改变运行时行为。
  2. 能看懂函数参数、返回值、容器、可空值、回调函数的类型写法。
  3. 知道 Any 为什么危险,什么时候才应该用。
  4. 能区分 dictTypedDictdataclass、Pydantic 模型的使用边界。
  5. 能用类型标注设计一个可维护的商业导入服务。
  6. 能回答面试中“Python 动态语言为什么还要类型标注”的问题。

为什么需要类型标注

没有类型标注时,你看到一个函数:

python
def get_user(id):
    ...

你不知道:

  1. id 应该是字符串还是数字。
  2. 返回值是字典、对象还是 None
  3. 调用方应该怎么处理查询不到的情况。
  4. 函数内部依赖哪些字段。

加上类型后:

python
def get_user(user_id: int) -> dict[str, str] | None:
    ...

信息就清楚很多:入参是整数,返回值可能是字典,也可能是 None。调用者必须处理空值。

类型标注解决的是“协作成本”问题:

mermaid
flowchart TD
    A["函数没有类型"] --> B["调用者靠猜"]
    B --> C["传错参数也可能运行到很深才报错"]
    C --> D["线上问题难定位"]
    A2["函数有类型"] --> B2["IDE 提示入参和返回值"]
    B2 --> C2["mypy/pyright 提前发现类型不匹配"]
    C2 --> D2["评审和维护成本降低"]

类型标注的工作原理

Python 的类型标注默认不会做运行时强校验。

python
def add(a: int, b: int) -> int:
    return a + b


print(add("1", "2"))  # 运行结果是 "12",不会因为标注 int 就自动报错

为什么?因为 Python 运行时仍然是动态类型,类型标注主要存放在函数对象的 __annotations__ 中,供 IDE、文档工具、静态检查工具使用。

python
def add(a: int, b: int) -> int:
    return a + b


print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

类型标注的检查链路:

mermaid
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
TypedDicttyping_extensions.TypedDicttyping.TypedDicttyping.TypedDicttyping.TypedDict

如果项目要求兼容 Python 3.8,建议这样写:

python
from typing import Dict, List, Optional


def find_name(user_id: int) -> Optional[str]:
    ...


def group_users() -> Dict[str, List[int]]:
    ...

如果项目是 Python 3.10+,可以写得更简洁:

python
def find_name(user_id: int) -> str | None:
    ...


def group_users() -> dict[str, list[int]]:
    ...

本知识库示例优先使用较新的写法;老项目迁移时要根据运行版本调整。

基本写法

python
def greeting(name: str) -> str:
    return f"hello {name}"


def add(a: int, b: int) -> int:
    return a + b

含义:

  1. name: str 表示参数 name 期望是字符串。
  2. -> str 表示函数返回字符串。
  3. a: int 表示参数 a 期望是整数。

变量也可以标注:

python
count: int = 0
username: str = "tom"
enabled: bool = True

局部变量一般不需要每个都写类型。优先给函数签名、复杂结构、公共变量写类型。

常见基础类型

类型写法示例含义
字符串str用户名、标题、文件路径
整数int年龄、数量、分页页码
浮点数float比例、坐标、非精确小数
精确小数Decimal金额、价格
布尔值bool是否启用、是否删除
字节bytes文件二进制、网络报文

金额不要优先用 float,因为浮点数有精度问题。订单、支付、资产金额建议用 Decimal

python
from decimal import Decimal


def calc_total(price: Decimal, quantity: int) -> Decimal:
    return price * quantity

容器类型

python
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)固定结构

复杂嵌套类型要克制:

python
def parse(data: dict[str, list[dict[str, str]]]) -> None:
    ...

这种签名虽然准确,但阅读困难。更好的方式是抽成 dataclassTypedDict

Optional 和可空值

str | None 表示返回值可能是字符串,也可能是空。

python
def find_name(user_id: int) -> str | None:
    if user_id == 1:
        return "张三"
    return None

调用方必须处理空值:

python
name = find_name(2)
if name is not None:
    print(name.upper())
else:
    print("用户不存在")

为什么这很重要?

python
name = find_name(2)
print(name.upper())  # name 可能是 None,运行时报 AttributeError

类型检查工具会提示你:None 没有 upper() 方法。

Union 和 Literal

Union 表示多种可能类型。Python 3.10+ 可以用 |

python
def normalize_id(value: int | str) -> str:
    return str(value).strip()

Literal 用来限制只能传固定值。

python
from typing import Literal


Env = Literal["dev", "test", "prod"]


def connect(env: Env) -> None:
    print(f"连接环境: {env}")

类型检查工具可以发现错误:

python
connect("local")  # 类型检查会提示不合法

商业项目中,Literal 常用于环境、订单状态、任务类型、导出格式等固定取值。

Callable:函数也是类型

Callable 表示函数类型。

python
from collections.abc import Callable


def run_task(task: Callable[[str], bool], name: str) -> bool:
    return task(name)

Callable[[str], bool] 表示这个函数接收一个字符串,返回一个布尔值。

业务例子:导入时把校验函数作为参数传入。

python
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:给复杂类型起名字

复杂类型可以用类型别名提升可读性。

python
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

python
from dataclasses import dataclass
from decimal import Decimal


@dataclass
class Asset:
    code: str
    name: str
    department: str
    price: Decimal

使用:

python
asset = Asset(code="A001", name="心电监护仪", department="ICU", price=Decimal("12000"))
print(asset.name)

优点:

  1. 字段清晰。
  2. IDE 有提示。
  3. 默认提供初始化方法。
  4. 比普通 dict 更不容易写错 key。
  5. 适合在业务内部传递结构化数据。

如果对象创建后不希望被修改,可以使用 frozen=True

python
@dataclass(frozen=True)
class ImportResult:
    success_count: int
    error_count: int

这不是绝对安全的权限控制,但能减少误修改。

TypedDict

如果接口返回的是 dict,但你又想描述字段结构,可以用 TypedDict

python
from typing import TypedDict


class UserDict(TypedDict):
    id: int
    name: str
    active: bool


def get_user() -> UserDict:
    return {"id": 1, "name": "张三", "active": True}

适合描述 JSON、接口返回值、配置项。

可选字段:

python
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 友好默认不做复杂校验
PydanticAPI 入参、配置、需要运行时校验可校验、可转换、错误信息清楚引入第三方依赖

如果只是内部传递资产对象,用 dataclass。如果描述接口返回 JSON,用 TypedDict。如果要校验用户请求体,Web 项目里通常用 Pydantic 或框架提供的模型能力。

Protocol:面向能力编程

Protocol 用来描述“只要你有这些方法,就符合这个类型”。它适合插件、存储适配器、通知渠道等场景。

python
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:泛型

泛型表示“类型可以由调用方决定”。

python
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

使用:

python
repo = Repository[str]()
repo.add("A001")
codes = repo.list_all()

泛型的意义:Repository 可以复用,但不会丢失元素类型。

Any:最危险的类型

Any 表示任意类型。

python
from typing import Any


def parse(data: Any) -> Any:
    return data

Any 会让类型检查失效。一个值只要是 Any,你对它调用任何方法,类型工具都很难提示。

python
from typing import Any


def handle(data: Any) -> None:
    data.not_exists().still_ok_for_type_checker()

什么时候可以用 Any

  1. 第三方库没有类型信息。
  2. 数据结构确实未知,且当前层只透传。
  3. 迁移老项目时,先用 Any 过渡,后续逐步收紧。

不要为了省事把所有函数都写成 Any,那等于放弃类型标注。

mypy 入门

安装:

shell
pip install mypy

运行:

shell
mypy app.py

示例:

python
def add(a: int, b: int) -> int:
    return a + b


add("1", 2)

类型检查会提示传入了错误类型。

项目可以增加 mypy.ini

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 一起使用,也可以命令行运行。

shell
npm install -g pyright
pyright

pyrightconfig.json 示例:

json
{
  "typeCheckingMode": "basic",
  "include": ["src"],
  "exclude": ["venv", ".venv"]
}

basic 适合新项目初期或老项目迁移;当类型质量变好后再切到 strict

商业 Demo:资产导入服务的类型设计

需求:读取外部系统导出的资产 CSV,校验后返回导入预览结果。这里重点不是文件读取细节,而是类型如何帮助代码更清晰。

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

这段代码为什么适合商业项目?

  1. RawAssetRow 描述外部 CSV 的原始结构。
  2. Asset 描述业务内部可信对象。
  3. RowError 描述单行错误,便于前端展示。
  4. ImportPreview 描述服务返回结果。
  5. AssetStatus 限制状态只能是 validinvalid
  6. 调用者不用猜返回值结构,IDE 可以直接提示字段。

类型流转过程:

mermaid
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

项目中优先给哪里加类型

优先级从高到低:

  1. 公共函数和工具函数。
  2. Service 层方法。
  3. API 入参和返回值。
  4. 配置对象。
  5. 数据库实体或领域对象。
  6. 复杂导入、清洗、计算逻辑。
  7. 回调函数、插件接口、策略接口。

临时脚本可以少写一点,但核心项目代码建议逐步补齐。

常见坑

问题后果建议
到处写 Any类型检查失效只在边界或迁移期临时使用
返回值没写 None调用方忘记判空查询不到时明确写 `T
复杂 dict 不建模key 写错难发现TypedDictdataclass
类型和真实返回不一致工具被误导类型标注必须跟代码一起维护
老版本使用新语法运行直接语法错误按 Python 版本选择写法
只做类型检查不写测试业务错了发现不了类型检查和单元测试都要有

生产排查流程

类型问题经常表现为运行时异常:AttributeErrorTypeErrorKeyError

mermaid
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
KeyErrordict 字段缺失TypedDict + 输入校验
TypeError参数类型不符合预期函数签名补类型,入口做转换
金额计算不准使用 float改用 Decimal
IDE 没提示类型全是 Any 或没写类型补 dataclass、TypedDict、Protocol

面试标准回答

Python 是动态语言,为什么还要类型标注?

标准回答:类型标注不会改变 Python 动态语言本质,它主要用于提升代码可读性、IDE 提示和静态检查能力。项目变大后,类型标注能把函数入参、返回值、可空情况、复杂数据结构写成明确契约,减少协作成本和低级类型错误。

类型标注会在运行时强制检查吗?

标准回答:默认不会。类型标注主要保存在 __annotations__ 中,给 IDE、mypy、pyright 等工具使用。运行时要做强校验,需要自己写校验逻辑,或者使用 Pydantic 等库。

Optional[str] 和 str | None 有什么区别?

标准回答:语义上都表示可能是字符串,也可能是 NoneOptional[str] 是老写法,兼容 Python 3.8 等版本;str | None 是 Python 3.10+ 的新写法,更简洁。

Any 有什么风险?

标准回答:Any 会绕过类型检查。一个值如果是 Any,类型检查工具很难发现错误方法调用、错误字段访问和错误赋值。项目里应该只在第三方边界、动态数据或迁移期临时使用,核心业务要逐步收紧类型。

TypedDict 和 dataclass 怎么选?

标准回答:TypedDict 适合描述 JSON、接口返回、配置这类本来就是字典的数据;dataclass 适合业务内部领域对象,字段清晰、IDE 友好,不容易写错 key。如果需要运行时校验和转换,可以考虑 Pydantic。

Protocol 有什么作用?

标准回答:Protocol 描述对象需要具备哪些方法,不要求显式继承。它适合插件、策略、通知渠道、仓储接口等场景,既保留 Python 鸭子类型的灵活性,又能让类型检查工具验证对象能力。

练习

  1. 给一个 add(a, b) 函数添加类型。
  2. 写一个返回 str | None 的查询函数,并在调用方正确判空。
  3. 使用 dataclass 定义 Article
  4. 使用 TypedDict 描述一个 JSON 响应。
  5. 使用 Literal 限制订单状态只能是 createdpaidcancelled
  6. 使用 Protocol 定义一个通知接口,并实现短信通知和邮件通知。
  7. 故意传错类型,运行 mypy 或 pyright 看提示。

关联知识点