Python 与数据实践 · 第 14 章

类型标注

学习建议:从本章起,请在 VS Code 的设置里把 python.analysis.typeCheckingMode 打开(basic 起步,胆子大了可以上 strict),让编辑器实时帮你挑类型的毛病。本章的所有示例…

约 17 分钟6,861 字20 个代码示例

学习建议:从本章起,请在 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

下一章:../15-模块与包的组织/课件.md