K 的一隅

Python Python 语言核心

标注给谁看:Python 类型标注语法与边界

类型标注默认不改变运行时;list[] 与 X|None 的写法、Protocol 的结构化类型,以及静态检查器能做什么、不能做什么。

8 分钟阅读 更新于 2026-07-28
本文目录

下面这段代码能正常运行,尽管标注和实际传参对不上:

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


print(add("x", "y"))  # xy

解释器不会因为类型标注而拒绝调用。标注写在源码里,主要给、给 mypy / pyright / PyCharm 这类类型检查工具读;它们在不运行程序的前提下,分析名字绑定与调用是否自洽。把标注当成「可机器读的文档 + 可选的静态门禁」,而不是 C/Java 那种编译期硬约束。

上一篇若涉及 ABC,讲的是运行时协议与继承;类型标注则在源码层描述「期望的类型形状」,两者互补但不等价。ABC 的 register 可以在运行时放宽匹配;mypy 不会因为你在 ABC 上 register 了某个类就自动相信它满足 Protocol——那是两套系统。

基本写法:参数、返回值、变量

常见形式是参数注解、-> 返回类型,以及模块级或局部变量的注解:

python
def greet(name: str, times: int = 1) -> str:
    msg: str = name * times
    return msg


scores: dict[str, float] = {"math": 92.5}
ids: list[int] = [1, 2, 3]

容器用内置泛型 list[int]dict[str, int]set[str](Python 3.9+),不必再从 typing 里 import ListDict。旧代码里可能还见 List[int],语义相同,新项目优先小写内置写法。

嵌套容器也按同样规则:list[dict[str, int]] 表示「字典列表,键 str、值 int」。元组若长度固定,可用 tuple[int, str, bool];长度不固定但类型一致时用 tuple[int, ...](省略号表示可变长)。

可选值:Optional 与 X | None

「可能没有」有两种等价写法:Optional[T]T | None(3.10+ 推荐后者):

python
from typing import Optional


def find_user(uid: int) -> str | None:
    return None


def parse_count(raw: str) -> Optional[int]:
    if raw.isdigit():
        return int(raw)
    return None

调用方若忽略 None,静态检查器会提示;运行时仍可能 AttributeError——标注不替你做判空。正确习惯是在使用前收窄:

python
def label(uid: int) -> str:
    user = find_user(uid)
    if user is None:
        return "unknown"
    return user.upper()

Optional[int]int | None 对检查器是同义;团队选一种风格并在代码库里统一即可。

联合类型与 Any

多个可能类型用 X | Y(3.10+)或 Union[X, Y]

python
def normalize(value: str | int) -> str:
    return str(value)


def dump(data: object) -> None:
    print(repr(data))

Any 表示「静态分析放弃检查」——与不写标注不同,它明确告诉工具别管这个值。遗留代码迁移时常见;新代码里应尽量少用,否则标注体系形同虚设。

Protocol:按行为描述类型

有时你并不关心具体类名,只关心「有没有某个方法」。Protocol 描述这种结构化契约:

python
from typing import Protocol


class SupportsClose(Protocol):
    def close(self) -> None: ...


class FileLike(Protocol):
    def read(self, n: int = -1) -> str: ...


def shutdown(resource: SupportsClose) -> None:
    resource.close()


def first_line(src: FileLike) -> str:
    return src.read().splitlines()[0]

任何带对应方法的对象都能传进去,不必继承某个基类。检查器按形状匹配,运行时仍是普通 duck typing。... 在 Protocol 方法体里表示「只有签名,无实现」。

类型检查能抓什么、抓不到什么

层面行为
解释器执行默认忽略标注,不强制转换
静态检查明显类型不一致、漏判空、错误属性访问
运行时验证需 pydantic、beartype 或手写断言

下面这类错误,mypy 会在运行前报出:

python
def total(nums: list[int]) -> int:
    return sum(nums)


total(["a", "b"])  # 检查器:list 元素应是 int,不是 str

下面这类则很难靠标注完全堵住——动态字符串当属性名、过度宽泛的 Any、缺 stubs 的 C 扩展库:

python
def get_attr(obj: object, name: str) -> Any:
    return getattr(obj, name)

渐进式采用与工程习惯

不必一次给全仓库补全标注。常见路径:

  1. 新模块从公共 API 开始带完整签名;
  2. 对改动频繁的模块逐步补标注;
  3. CI 里对指定目录跑 mypy --strict 或 pyright,legacy 目录暂设宽松;
  4. 第三方库缺类型时,查是否提供 types- 前缀的 stubs 包。

# type: ignore[error-code] 应附带原因,并尽量局部化——整文件 ignore 会让后续改动失去保护。

标注的价值在于把「这里应该是 list[int]」写进源码,让读代码的人和工具在同一套语义上对齐。它不会让 Python 变成静态语言,但能在重构、协作和 IDE 补全里省下大量「这参数到底该传什么」的来回确认。

与运行时类型信息的区别

type(x)isinstance(x, cls) 读的是运行时对象的真实类别;类型标注描述的是程序员声明的期望。两者可以不一致——前面 add("x", "y") 就是反例。静态检查器在编译前(准确说是运行前)比对的是标注与数据流,不是替你调用 type()

python
def pick_first(items: list[str]) -> str:
    return items[0]


data: list[str | int] = ["a", 1]
pick_first(data)  # 检查器可能报错:list 元素不全是 str

若需要运行时校验,必须额外写代码或使用库;标注本身只是一层「文档 + 可选 lint」。

Callable、TypeVar 与泛型函数(点到为止)

函数类型常用 Callable[[ArgTypes], ReturnType]

python
from collections.abc import Callable


def apply_twice(f: Callable[[int], int], x: int) -> int:
    return f(f(x))


print(apply_twice(lambda n: n + 1, 3))  # 5

简单泛型函数可引入 TypeVar,让输入输出类型保持同一未知数 T——读第三方库签名时会常见,日常业务代码可先掌握 list[int]Callable 再深入。

常见误解

误解实际
写了标注就会运行时校验默认不会,除非额外工具
listlist[Any] 完全一样对检查器,list 常被视为未参数化,严格模式下会提示
Protocol 会强制继承不会,只影响静态匹配
标注拖慢程序运行时基本忽略,无 measurable 开销

小结

类型标注语法:参数: 类型-> 返回类型、变量注解;容器用 list[int] 等内置泛型;可空用 T | NoneOptional[T];行为契约用 Protocol类型检查 是独立工具链(mypy、pyright、IDE),与解释器执行解耦。从公共 API 和数据结构开始写起,比追求全仓库一次性盖满更可持续。

在类与容器上的标注

类属性、实例方法同样写注解;self 通常标注(约定俗成)。方法返回类型写在 -> 后:

python
class Buffer:
    chunks: list[bytes]

    def __init__(self) -> None:
        self.chunks = []

    def append(self, data: bytes) -> None:
        self.chunks.append(data)

    def size(self) -> int:
        return sum(len(c) for c in self.chunks)

类变量 chunks: list[bytes] 是注解,真正赋值在 __init__ 里完成——与 dataclass 字段声明不同,普通 class 里这两步分开写。静态方法、类方法同样可加参数与返回标注,检查器会一并分析。

嵌套函数若很短,也可标注,便于闭包内外类型一致:

python
def make_multiplier(factor: int) -> callable[[int], int]:
    def mul(x: int) -> int:
        return x * factor
    return mul

callable[[int], int] 是内置小写写法(3.9+ 亦可用 collections.abc.Callable)。读库代码时见到这种签名,表示「接受 int、返回 int 的可调用对象」。

配置检查器:把标注变成门禁

本地可先装 mypy 或 pyright,对单文件试跑:

bash
pip install mypy
mypy your_module.py

项目级在 pyproject.tomlmypy.ini 里设 python_version、逐步开启 strict。CI 里同一条命令失败则阻断合并——这时标注从「建议」变成团队契约。新成员改函数签名时,检查器列出的调用点清单,往往比 code review 更完整地暴露遗漏。

不必追求第一天 --strict 全绿。常见路线:先开 check_untyped_defs 只查已标注函数体,再扩大目录;对暂时无法改的三方封装写 stub 或 # type: ignore 并注明 issue 号。标注与检查是长期减债,不是一次性作文。

读第三方库签名时的速查

打开类型存根的库,签名里会出现 TypedDictLiteralFinal 等进阶 constructs。日常写业务可先掌握本文的 list/Optional/Protocol;读到下列形式时知道「这是静态层概念」即可:

python
from typing import Literal, TypedDict


Mode = Literal["r", "w"]
# Mode 只能是两个字符串之一,检查器会拦别的值


class UserRow(TypedDict):
    id: int
    name: str

TypedDict 描述「键固定、类型已知」的 dict,比 dict[str, Any] 更具体。Literal 收窄到常量集合。它们不改变运行时 dict 的行为,只帮助工具在访问 row["name"] 时发现拼写错误或类型不符。

若库未提供类型信息,可在 types-* 包或社区 stub 里找;实在没有就局部用 Any 并包一层自己的 typed 封装,避免 Any 传染整个代码库。

最后收束一条实践原则:标注服务于读代码的人与静态工具,不替代测试,也不改变 Python 动态本质。写函数时顺手补上参数与返回类型,比事后扫全库补标注省力;遇到 legacy 模块,用目录级检查策略逐步收紧,比一刀切 --strict 更易被团队接受。

IDE 依赖标注做自动补全与跳转:你写 user. 时,若 user 被推断或标注为具体 dataclass,成员列表才会准确。这也是「标注给谁看」里给自己看的一层——几个月后重读自己的代码,签名比长 docstring 更不易撒谎。

回顾本篇关键词:list 等内置泛型描述容器元素类型;Optional| None 表达可空;Protocol 按方法形状做静态契约;类型检查 工具在运行前找不一致。四者组合起来,构成 Python 渐进式类型系统的主干,而不是要把语言改成另一种 Java。标注写进源码,检查交给工具,运行时仍靠测试与逻辑保证正确性——这三层分工别混为一谈。下一篇进入 import 与包结构,把模块边界与公开 API 画清楚,类型标注才有稳定的挂载点。这是本篇的收束。