类型标注
学习建议:从本章起,请在 VS Code 的设置里把 python.analysis.typeCheckingMode 打开(basic 起步,胆子大了可以上 strict),让编辑器实时帮你挑类型的毛病。本章的所有示例…
学习建议:从本章起,请在 VS Code 的设置里把
python.analysis.typeCheckingMode打开(basic起步,胆子大了可以上strict),让编辑器实时帮你挑类型的毛病。本章的所有示例,最好开着它练。
1. 一个函数签名引发的事故
先看一个再普通不过的函数:
def link(a, b):
return a + b
调用它:
link(3, 5) # 8
link("数据", "清洗") # "数据清洗" —— 合法,但未必是你想要的
link((1,), (2,)) # (1, 2) —— 同样合法
三种调用全部畅通无阻,结果却完全两回事。问题不在 Python,而在这个函数没把"我到底想收什么"说清楚:写代码的人靠猜,读代码的人靠猜,编辑器想帮忙也无处下手——参数类型一团迷雾,补全和查错统统失效。
类型标注(type hints) 就是吹开这层迷雾的办法:在代码里白纸黑字地写明"这个位置应该是什么类型"。它不改变程序运行时的行为,却能把"类型约定"从口头默契变成看得见的契约。
2. 从变量到函数:贴标签的基本功
2.1 给变量贴标签
book_title: str = "Python 与数据实践"
page_count: int = 320
price: float = 79.5
in_print: bool = True
# 也可以先声明类型,之后再赋值
chapter_no: int
chapter_no = 14
# 注意:Python 是动态语言,标注只是"声明",不是"枷锁"
fake_count: int = "很多" # 运行照样通过,但类型检查器会立刻标红
2.2 给函数贴标签
参数和返回值都可以标:
def describe_chapter(title: str, pages: int) -> str:
return f"《{title}》这一章共 {pages} 页"
describe_chapter("类型标注", 18) # 正常
describe_chapter("类型标注", "18") # 运行不报错,但类型检查器会警告
2.3 永不返回的函数
有些函数的正常出路只有一条:把程序带走。NoReturn 专门用来标注这类函数:
from typing import NoReturn
def abort(message: str) -> NoReturn:
"""打印错误并终止整个程序,不存在"正常返回"这回事"""
import sys
print(message)
sys.exit(1)
3. 容器类型:把"装的是什么"也说清楚
裸的 list、dict 信息量太少了——一串字符的列表和一串浮点数的列表,在裸标注眼里毫无区别。typing 模块(以及 Python 3.9+ 的内置泛型写法)允许把元素类型一并交代:
from typing import List, Dict, Tuple, Set
# 列表:标出元素类型
readings: List[float] = [36.5, 36.8, 37.1]
cities: List[str] = ["杭州", "苏州", "广州"]
# 字典:分别标出键类型、值类型
city_temp: Dict[str, float] = {
"杭州": 26.5,
"苏州": 25.9,
}
# 元组:长度固定,逐位标类型,每一位都可以不一样
coord: Tuple[float, float] = (120.15, 30.28)
record: Tuple[str, int, bool] = ("杭州", 1200, True)
# 集合:标出元素类型
tags: Set[str] = {"爬虫", "清洗", "可视化"}
标到这个粒度,类型检查器才有底气质问 "city_temp["上海"] = "26度" 有问题——值应该是 float"。
4. 允许缺席与多选一:Optional 和 Union
4.1 Optional:可能没有值
from typing import Optional
def find_player(no: int) -> Optional[str]:
"""按号码查队员姓名,查不到就返回 None"""
if no <= 0:
return None
return f"队员_{no}"
Optional[str] 读作"是 str,也可能是 None"。它只负责把"可能缺席"写进签名,至于返回 None 之后怎么办,是调用方的事。
4.2 Union:几选一
from typing import Union
def to_number(text: str) -> Union[int, float, str]:
"""能转成数字就转,转不动就原样奉还"""
try:
if "." in text:
return float(text)
return int(text)
except ValueError:
return text
Union[int, float, str] 表示返回值是这三种类型之一。顺带一提:新语法里 Union[int, float] 可以写成 int | float,Optional[str] 则等价于 str | None。
5. Any 和类型别名
5.1 Any:主动放弃约束
from typing import Any
def trace(payload: Any) -> None:
"""调试打印:来者不拒,什么类型都接"""
print(f"[trace] {payload}")
Any 等于告诉检查器"这里不用管了"。它适合出现在调试日志、与动态数据的交界处;但满屏 Any 的代码约等于没标——别拿它当逃避思考的借口。
5.2 类型别名:给复杂类型起小名
from typing import TypeAlias
Series: TypeAlias = List[float] # 一维数列
Frame: TypeAlias = List[List[float]] # 二维表
def series_dot(s1: Series, s2: Series) -> float:
"""计算两个数列的内积"""
return sum(a * b for a, b in zip(s1, s2))
一层套一层的类型写起来啰嗦、读起来费劲,起个别名之后,函数签名一下子清爽了。
6. 类里的标注,以及 Self
第 06 章写类的时候我们没管类型,现在可以补上了:
from typing import Self
class Pixel:
def __init__(self, col: float, row: float) -> None:
self.col = col
self.row = row
def translate(self, dc: float, dr: float) -> Self:
"""平移之后,返回一个新的像素点"""
return Pixel(self.col + dc, self.row + dr)
def gap(self, other: "Pixel") -> float:
"""算出自己与另一个像素点的距离"""
return ((self.col - other.col) ** 2 + (self.row - other.row) ** 2) ** 0.5
# 使用
origin = Pixel(0, 0)
corner = Pixel(3, 4)
print(origin.gap(corner)) # 5.0
两个细节值得注意:
- 方法内部引用本类时,可以写成字符串
"Pixel"——类定义还没执行完,直接写裸类名会找不到名字,字符串形式是经典解法; - 返回"自己的类型"用
Self,它比手写类名更稳——子类继承之后,返回值标注依然自动成立。
7. 泛型:让容器记住自己装过什么
内置的 list 能装任何东西,我们自己写的容器类也可以泛化。TypeVar 负责声明一个"类型占位符",Generic 让类接受它:
from typing import TypeVar, Generic
T = TypeVar("T")
class PlayQueue(Generic[T]):
"""泛型播放队列:放进去什么类型,拿出来还是什么类型"""
def __init__(self) -> None:
self._tracks: list[T] = []
def enqueue(self, track: T) -> None:
self._tracks.append(track)
def skip(self) -> T:
if not self._tracks:
raise IndexError("队列为空")
return self._tracks.pop(0)
def current(self) -> T | None:
if not self._tracks:
return None
return self._tracks[0]
# 用歌名当队列
name_queue: PlayQueue[str] = PlayQueue()
name_queue.enqueue("晴天")
name_queue.enqueue("七里香")
print(name_queue.skip()) # 晴天
# 用时长当队列
length_queue: PlayQueue[int] = PlayQueue()
length_queue.enqueue(269)
# length_queue.enqueue("269") # 类型检查器会警告
PlayQueue[str] 一旦写定,检查器就替你盯着这个实例:往里塞整数它会拦,从里面取出的元素它也自动按 str 来补全。
8. Callable:给"函数参数"标注
函数可以当参数传来传去(第 04 章的老知识),那这种参数的类型该怎么写?答案是 Callable——方括号里先列参数类型列表,再列返回值类型:
from typing import Callable
def apply_op(
op: Callable[[float, float], float],
x: float,
y: float,
) -> float:
"""把运算本身交给外部传入的函数"""
return op(x, y)
print(apply_op(lambda a, b: a * b, 6, 7)) # 42.0
Callable[[float, float], float] 读作"接收两个 float、返回一个 float 的可调用对象",普通函数、lambda、实现了 __call__ 的实例都算。
9. 两类高频应用场景
9.1 用 TypedDict 给字典立契约
数据脚本里到处是字典,而键名拼错一个字母,往往要等到运行时才炸。TypedDict 能给字典定下"键与值的契约":
from typing import TypedDict
class BookInfo(TypedDict):
"""接口返回的图书数据结构"""
isbn: str
title: str
author: str
in_stock: bool
def fetch_book(isbn: str) -> BookInfo:
return {
"isbn": "9787111700000",
"title": "Python 与数据实践",
"author": "课程组",
"in_stock": True,
}
少写一个键、写错一个键名,类型检查器直接在编辑器里就把你拦下。
9.2 让 IDE 的智能提示全速运转
类型标注到位之后,IDE 能帮的忙会上一个台阶:
- 成员自动补全
- 参数提示
- 类型不匹配处实时标红
from typing import Any
class SearchEngine:
def connect(self, endpoint: str, timeout: int = 30) -> "Session":
...
def search(self, keyword: str) -> list[dict[str, Any]]:
...
engine = SearchEngine()
session = engine.connect("es://localhost") # IDE 会提示还能传 timeout
标注越完整,编辑器就越像一位随叫随到的结对伙伴。
10. 实在标不动的时候:type: ignore
总有些代码没法好好标注——第三方库没有类型信息、动态属性满天飞。这时可以在行尾加 # type: ignore,让检查器跳过这一行:
# 整行放弃检查
payload = legacy_loader.parse() # type: ignore
# 只忽略指定类别的错误(错误码来自检查器给出的提示)
fake_count: int = "很多" # type: ignore[assignment]
把它当成应急的创可贴,而不是常规操作——每一处 ignore,都是写给未来维护者的那句"这里我也没搞明白"。
习题
一、补全类型标注
给下面两个函数加上合适的类型标注:
def compute_density(mass, volume):
"""计算密度"""
if volume <= 0:
raise ValueError("体积必须大于 0")
return mass / volume
def aqi_level(aqi):
"""把空气质量指数翻译成等级文字"""
if aqi <= 50:
return "优"
elif aqi <= 100:
return "良"
elif aqi <= 150:
return "轻度污染"
elif aqi <= 200:
return "中度污染"
else:
return "重度污染"
二、泛型登记表 Registry
from typing import TypeVar, Generic, Optional
K = TypeVar("K")
V = TypeVar("V")
class Registry(Generic[K, V]):
"""泛型登记表"""
def __init__(self) -> None:
# 你的代码
pass
def register(self, key: K, value: V) -> None:
"""登记一个键值对"""
# 你的代码
pass
def lookup(self, key: K) -> Optional[V]:
"""按键取值,没登记过就返回 None"""
# 你的代码
pass
def wipe(self) -> None:
"""清空登记表"""
# 你的代码
pass
# 测试
registry: Registry[str, int] = Registry()
registry.register("pv", 1024)
registry.register("uv", 256)
print(registry.lookup("pv")) # 1024
print(registry.lookup("vv")) # None
registry.wipe()
三、用 TypedDict 定义服务配置
from typing import TypedDict
class StorageConfig(TypedDict):
"""对象存储配置"""
# 你的代码:包含 endpoint(str), region(str), bucket(str), secret_key(str)
class ServiceConfig(TypedDict):
"""服务总配置"""
# 你的代码:包含 service_name(str), verbose(bool), storage(StorageConfig)
def default_config() -> ServiceConfig:
"""返回默认配置"""
return {
"service_name": "data-worker",
"verbose": False,
"storage": {
"endpoint": "https://oss.example.com",
"region": "cn-hangzhou",
"bucket": "raw-data",
"secret_key": "s3cr3t",
}
}
四、思考题:这些标注写对了吗
下面的代码在类型标注上有没有毛病?有的话该怎么改?
from typing import List, Dict
def index_records(records: List) -> Dict:
"""把记录整理成索引"""
result = {}
for record in records:
result[record["code"]] = record["score"]
return result
def pick_taller(a: float, b: float) -> float | None:
"""返回较高的身高;两人一样高时返回 None"""
if a == b:
return None
return a if a > b else b