JEPA4Japan · 教程

类型提示与协议

1,955字 6分钟阅读 #Python

使用注解、联合类型、泛型和结构化接口记录预期。

课程进度 课程大纲 已发布 24/24 课

类型提示是便利贴,而不是守卫

类型提示告诉读者和静态工具,一个值应该是什么类型。它通常不会守在门口,阻止不同类型的运行时值进入。

  1. 注解 name: str
  2. 类型检查器 在运行前读取
  3. Python 使用实际对象
类型提示描述的是静态约定;运行时验证则是另一项工作。
def repeat(label: str, times: int) -> str:
    return label * times


print(repeat("go", 3))
print(repeat(2, 3))
gogogo
6

类型检查器应该标记第二次调用,但 Python 仍会执行整数乘法。来自用户、文件和网络的数据,需要由你自己验证。注解也不会自动转换数据。

描述容器和可空值

集合类型提示既描述容器,也描述其中的内容:

def total_by_topic(sessions: list[tuple[str, int]]) -> dict[str, int]:
    totals: dict[str, int] = {}
    for topic, minutes in sessions:
        totals[topic] = totals.get(topic, 0) + minutes
    return totals


print(total_by_topic([("Python", 20), ("Python", 25)]))
{'Python': 45}

类型提示不会在运行时检查每一个元素。只要求你真正需要的能力:一个只进行循环的只读函数可以接受 Iterable[str];需要按索引访问的函数可以接受 Sequence[str];明确需要修改集合的函数可能需要 list[str]。

str | None 表示字符串或者 None。这是 Optional[str] 的现代写法。int | str 表达的联合类型概念与 Union[int, str] 相同。

def author_label(author: str | None) -> str:
    if author is None:
        return "Unknown"
    return author.strip().title()

当处理 None 的分支返回后,类型检查器便可以将 author 缩窄为 str。可选类型并不意味着参数可以省略:调用 author_label() 时仍然必须提供参数。如果允许省略,请使用 author: str | None = None。

  1. 联合类型 int | str
  2. 检查 isinstance(value, int)
  3. 整数分支 可以安全地进行整数运算
  4. 其他分支 可以安全地进行字符串操作
运行时证据可以同时为 Python 和类型检查器缩窄联合类型。

类型别名可以为反复出现的结构赋予一个领域名称:

from typing import TypeAlias

Session: TypeAlias = tuple[str, int]

Python 3.12 还支持 type Session = tuple[str, int]。别名只是词汇,不是新的运行时类或验证器。当字段需要名称或规则时,请使用数据类。

描述行为并保留类型关系

Callable[[str], str] 描述了一个可调用对象:它接受一个字符串并返回一个字符串:

from collections.abc import Callable


def clean_all(words: list[str], clean: Callable[[str], str]) -> list[str]:
    return [clean(word) for word in words]

它可以接受符合这一约定的函数、绑定方法或可调用对象。

TypeVar 保留类型之间的关系,而不是指定某一种具体类型:

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None

对于 list[int],结果是 int | None;对于 list[str],结果是 str | None。如果返回 object | None,这种有用的联系就会丢失。只有当类型变量表达了真实的输入输出关系时,才应该添加它。

Protocol 是一张能力卡

Protocol 为使用方所需的一小组行为命名。类只要拥有兼容的成员,就能以结构化方式满足它;不需要继承它。

  1. Protocol 要求提供 summary()
  2. 文章 拥有这种能力
  3. 视频 也拥有这种能力
满足协议靠的是兼容的结构,而不是共同的继承关系。
from typing import Protocol


class Summarizable(Protocol):
    def summary(self) -> str:
        ...


def show(item: Summarizable) -> None:
    print(item.summary())

协议应该保持精简。@runtime_checkable 允许使用 isinstance() 进行有限的成员存在性检查,但它不会验证完整的方法签名或带注解的值类型:

from typing import Protocol, runtime_checkable


@runtime_checkable
class Fetcher(Protocol):
    def fetch(self) -> list[str]:
        ...


class WrongShape:
    def fetch(self, required_argument):
        return 42


print(isinstance(WrongShape(), Fetcher))

这段代码可能打印 True,因为 fetch 确实存在。静态检查和运行时数据验证仍然很重要。

小项目:带类型的通知摘要

创建 notification_digest.py。from __future__ import annotations 让这种现代注解语法也能在较旧的受支持解释器上运行。这个项目结合了类型别名、可调用对象、泛型辅助函数、协议和真正的运行时验证。

from __future__ import annotations

from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Protocol, TypeVar

RawNotice = tuple[str, int]
T = TypeVar("T")


@dataclass(frozen=True)
class Notice:
    source: str
    message: str
    score: int

    def __post_init__(self) -> None:
        if not self.source.strip() or not self.message.strip():
            raise ValueError("text must not be blank")
        if isinstance(self.score, bool) or not isinstance(self.score, int):
            raise TypeError("score must be an integer")
        if not 0 <= self.score <= 100:
            raise ValueError("score must be from 0 to 100")


class NoticeSource(Protocol):
    def fetch(self) -> Iterable[Notice]:
        ...


class MemorySource:
    def __init__(self, name: str, records: list[RawNotice]) -> None:
        self.name = name
        self.records = records

    def fetch(self) -> Iterable[Notice]:
        for message, score in self.records:
            yield Notice(self.name, message, score)


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None


def collect(
    sources: Iterable[NoticeSource], keep: Callable[[Notice], bool]
) -> list[Notice]:
    notices = [
        notice
        for source in sources
        for notice in source.fetch()
        if keep(notice)
    ]
    return sorted(notices, key=lambda notice: (-notice.score, notice.source))


def format_digest(notices: Sequence[Notice]) -> str:
    if not notices:
        return "No notices."
    lines = ["Notification digest"]
    lines.extend(
        f"- {notice.score:03d} | {notice.source}: {notice.message}"
        for notice in notices
    )
    top = first_or_none(notices)
    if top is not None:
        lines.append(f"Top source: {top.source}")
    return "\n".join(lines)


sources: list[NoticeSource] = [
    MemorySource("Python", [("Typing notes", 88), ("Style guide", 62)]),
    MemorySource("Testing", [("Regression suite", 91)]),
]
notices = collect(sources, lambda notice: notice.score >= 70)
print(format_digest(notices))
print(f"Empty helper: {first_or_none([]) is None}")

运行 python3 notification_digest.py 并检查:

Notification digest
- 091 | Testing: Regression suite
- 088 | Python: Typing notes
Top source: Testing
Empty helper: True

MemorySource 从未继承 NoticeSource;它拥有兼容的 fetch(),这对于静态结构化类型来说已经足够。再次提醒边界:类型提示不会强制分数处于规定范围内——数据类会在运行时进行检查。传入不合适的回调也仍然可能在运行时失败。

三个小任务

  1. 缩窄联合类型。 接受 int | str;在进行 isinstance() 检查后,为整数补前导零,并将非空字符串转换为大写。
  2. 保留类型。 编写 last_or_none(items: Sequence[T]) -> T | None,并使用整数和 Notice 对象进行尝试。
  3. 添加一个数据源。 创建一个以元组为基础、拥有 fetch() 的类,但不要继承 NoticeSource。

当你做到以下几点时,就可以学习第 17 章了……

  • 你能够解释类型提示通常不会强制约束运行时值;
  • 你能够根据集合所需的能力为其添加注解;
  • 你能够使用 Optional/Union 的概念,并通过实际检查缩窄类型;
  • 你知道类型别名只是为一种结构命名,并不会验证它;
  • 你能够使用 Callable 为回调添加注解;
  • 你能够使用 TypeVar 保留输入输出之间的关系;
  • 你能够定义一个小型 Protocol,并以结构化方式满足它;
  • 你知道 runtime_checkable 检查的是成员是否存在,而不是完整的方法签名;
  • 你能够运行通知摘要,并看到 Empty helper: True。

接下来,你将了解迭代器和生成器如何让数据源逐个生成值。