← 返回模块
3.3.2.1beta 可读 · 未来免费内容校验中内容版本 2026-05-24

类、双下方法与协议

3.3.2 · 设计模式与工具 · 编程

开场

某私募周二上午九点四十二,一位实习研究员把自写的 PriceQuote 类提交进团队研究包,基金经理顺手抓了一千个想往 set 里塞,做当天 510300.SH 早盘 tick 的去重。TypeError: unhashable type: 'PriceQuote'。十分钟后他想按时间戳排序,调用又换了一种方式挂掉。类能编译,对单个 quote 的单元测试也通过,可一旦碰到标准库的其他部分就站不住脚。要点不是实习写错了,而是 Python 允许你两行方法就把类发出去;只有当你刻意写出语言会消费的那套 dunder,类才真正成为一个 Python 值。本课就是教你有意识地把这套 dunder 写出来,再选对它们之上的抽象层。

值类的 dunder 套件

@dataclass 自动合成 __init____repr__,以及(eq=True 默认开启时)__eq__。它不合成的是排序、哈希以及容器 / 上下文管理器协议。先把值类的核心讲清楚。__repr__ 应当能 round-trip 回构造器——典型目标是 eval(repr(x)) == x——因为这就是 REPL 打印、print(list_of_obj) 对每个元素调用的方法。__eq__== 起作用;可一旦你定义了 __eq__,Python 会把 __hash__ 置为 None,以维持「相等对象必须哈希相等」的契约。你必须显式定义 __hash__,或者 __hash__ = object.__hash__,或者使用 @dataclass(eq=True, frozen=True)(这样合成的 __hash__ 就是字段值的函数)。三者皆不做,实例就既不能当 dict 的键也不能放进 set——正好就是基金经理的 set 失败现场。

排序是下一层。四个比较 dunder 是 __lt____le____gt____ge__。手写四个既繁琐又容易出错;functools.total_ordering 能从一个推出其余三个。装饰器栈自下而上执行——@dataclass(frozen=True) 先合成 __init__ / __eq__,然后 @total_ordering 读到你写的那一个排序方法,把剩下的补齐:

from dataclasses import dataclass
from functools import total_ordering

@total_ordering
@dataclass(frozen=True)
class PriceQuote:
    ticker: str
    price: float
    ts: int
    def __lt__(self, other: 'PriceQuote') -> bool: return (self.ticker, self.ts) < (other.ticker, other.ts)

三个 A 股风格的演示实例:PriceQuote('600519.SH', 1820.50, 1704931200)PriceQuote('510300.SH', 4.18, 1704931260)PriceQuote('000001.SZ', 12.34, 1704931320)sorted([...]) 现在能跑,因为 __lt__ 写了而 total_ordering 把剩下的补齐;set([...]) 也能跑,因为 @dataclass(frozen=True)(ticker, price, ts) 合成出了 __hash__。按 (ticker, ts) 排序的语义是「先按代码分组、再按时间排」——也就是行情簿真正的扫描顺序。仅按 ts 排会把多个代码交错,对早盘去重那一遍扫描方向是错的。

容器与上下文管理器协议

Python 容器就是把对应那组 dunder 写齐的任何类。__len__ 支持 len(obj)__bool__ 支持 if obj:;不定义时 Python 会回退到 __len__ != 0,绝大多数情形这正是你想要的。__getitem__(self, key) 在整数索引上支持下标,且通过旧协议的回退路径支持迭代——若 __iter__ 没有,for x in obj: 会依次调用 __getitem__(0), __getitem__(1), ... 直到看到 IndexError。现代写法是定义 __iter__(返回一个迭代器),不显式提供 __contains__ 时让它回退成线性扫描。下面这个 TickWindow 保存沪深300 ETF(510300.SH)最近 N 个 quote:

class TickWindow:
    def __init__(self, capacity: int) -> None: self._buf: list[PriceQuote] = []; self._capacity = capacity
    def push(self, q: PriceQuote) -> None: self._buf.append(q); self._buf = self._buf[-self._capacity:]
    def __len__(self) -> int: return len(self._buf)
    def __iter__(self): return iter(self._buf)
    def __getitem__(self, i: int) -> PriceQuote: return self._buf[i]
    def __contains__(self, q: PriceQuote) -> bool: return q in self._buf

这个类天然支持 len(tw)for q in tw:tw[-1]q in tw;调用方对它是自定义类一无所知。上下文管理器的 dunder 是 __enter__(self),它的返回值就是 with ... as x: 里的 x;以及 __exit__(self, exc_type, exc_value, tb),返回 True 抑制异常,返回假值放任传播。一个 Session 上下文管理器封装一个 Tushare 风格的行情客户端:__enter__ 里建立连接、__exit__ 里关闭;调用方读起来是 with Session() as md: bars = md.get_bars('510300.SH', '20240101', '20240131')——无须显式 close,也不会漏掉 socket。

ABC 还是 Protocol:两条抽象路径

当两个定价器需要暴露统一接口,你要做的是抽象。Python 给了你两条语义截然不同的路径。abc.ABC + @abstractmethod 声明的是名义接口:class Pricer(ABC): @abstractmethod def price(self, s: float) -> float: ...,任何没有 override price 的子类都不能实例化——TypeError 是在 Pricer() 调用时抛出的,而不是在定义类的时刻。typing.Protocol 声明的是结构接口:任何具有同形 price 方法的类都满足它,无需继承。口号:ABC = 由继承控制的「是一种」;Protocol = 由形状控制的「行为像」。

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable

class PricerABC(ABC):
    @abstractmethod
    def price(self, s: float) -> float: ...

@runtime_checkable
class PricerProto(Protocol):
    def price(self, s: float) -> float: ...

class BlackScholesPricer(PricerABC):
    def __init__(self, k: float, r: float, sigma: float, t: float) -> None: self.k, self.r, self.sigma, self.t = k, r, sigma, t
    def price(self, s: float) -> float: ...  # closed-form formula body, byte-identical across regions

定价欧式看涨期权的工作参数用 510300.SH 风格:S_0 = 4.20 元、K = 4.30 元、r = 0.025sigma = 0.22T = 30/252BlackScholesPricer(PricerABC) 同时满足两条路径——因为继承关系,它「是一种」PricerABC;因为它有一个形状匹配的 price 方法,它「行为像」PricerProto@runtime_checkable 装饰器加在 Protocol 上是显式 opt-in,让 isinstance(obj, PricerProto) 在运行时返回 True。没有这个装饰器,对 Protocol 做 isinstance 会抛 TypeError——Protocol 默认是静态检查构造。

什么时候选哪一个?当你控制整族子类、并希望语言在实例化时强制契约——选 ABC。当消费方只要求形状合适的对象——也许是你团队没写过的 SDK 客户端,也许是测试桩——你不想强制让测试桩继承一个它没理由认识的类——选 Protocol。

泛型类:把元素类型参数化

最后一层是泛型类。容器里的元素类型在调用方往往很重要:装着 PriceQuoteCache 应该静态拒绝 cache.put('k', 'oops'),并且 IDE 在 cache.get('k').price 上应当自动补全到正确类型。typing.TypeVarGeneric[T] 是机制:

from typing import TypeVar, Generic

T = TypeVar('T')

class Cache(Generic[T]):
    def __init__(self) -> None: self._store: dict[str, T] = {}
    def get(self, key: str) -> T | None: return self._store.get(key)
    def put(self, key: str, value: T) -> None: self._store[key] = value

cache: Cache[PriceQuote] = Cache()
cache.put('510300.SH', PriceQuote('510300.SH', 4.18, 1704931260))
q = cache.get('510300.SH')

mypypyright 读到 cache: Cache[PriceQuote],会把每个方法都按 def get(self, key: str) -> PriceQuote | None 来追踪。类型参数在运行时被擦除——Cache[PriceQuote] 背后的类对象就是 Cache 本身——这是静态期保证而非运行时检查。Callable[[Tick], float] 是带类型的高阶形参,下一课讲 Strategy 模式把函数作参数时你会看到。typing.overload 处理「返回类型依赖入参类型」的少数函数(脚注一句话,不是日常工具)。PEP 612 ParamSpec 给装饰器作者用、保留被包函数的完整签名——一句话点到即止,然后回到四种抽象的选型。

四种抽象的选型口诀

口号:数据 -> @dataclass;闭族继承 -> abc.ABC;鸭子接口 -> Protocol;元素类型敏感 -> Generic[T]。本课的三种具体决策:

surfaceuse whenenforcementcanonical example in this lesson
@dataclassthe data is a value with named fieldsruntime; no behaviour beyond auto-synthesised dundersPriceQuote
abc.ABC + @abstractmethodyou control the closed family of subclassesruntime; instantiating an unfinished subclass raises TypeErrorPricerABC -> BlackScholesPricer
typing.Protocolthe consumer accepts any object with the right shapestatic; runtime via @runtime_checkable + isinstancePricerProto matches BlackScholesPricer without inheritance
Generic[T] + TypeVarthe element type matters for static checkingstatic via mypy / pyright; runtime erases type parametersCache[T] of PriceQuote

PriceQuote 是带命名字段的数据——@dataclass。你团队拥有整族子类的 Pricer(Black-Scholes、二叉树、蒙特卡洛)——abc.ABC。一个 PricedRecord 形状,被并不拥有该记录类型的代码消费——Protocol。装着任意元素类型的 Cache——Generic[T]。常见踩坑是 Protocol 才对的位置反而用了 ABC,因为 ABC 要求继承而 Protocol 不要求,而继承是 Python 里最贵的抽象——每个读代码的人都得先学会继承层级才能看懂调用方。

最后一段是你自己会去写的部分。Python 支持多重继承,使用 C3 方法解析顺序算法,但写 mixin 之前请先读《Fluent Python》MRO 那一章;通过 super() 协作的多重继承是语言里最高故障隔离成本的构造之一。基于类的装饰器和 @property 那种描述符风格的实现(最简单的描述符)属于第四课。2026 年绝大多数生产团队会用 pydantic 写带校验的数据模型而非手写 ABC——但标准库的 dunder + Protocol 这一层正是每个校验库的实现基底,本课教的就是这一层。

练习

Exercise

实现 class TickBuffer,它保存最近 capacityPriceQuote 实例(上面定义的 dataclass),并支持:(a) tb = TickBuffer(capacity=3),(b) tb.push(q) 追加一个 quote、满时丢弃最旧的,(c) 推送三次后 len(tb) == 3,(d) for q in tb: 按插入顺序遍历,(e) tb[-1] 返回最新的 quote。接着定义 @runtime_checkable class Sized(Protocol): def __len__(self) -> int: ...,验证 isinstance(TickBuffer(3), Sized) is True。最后用一句话说明:如果把 Protocol 上的 @runtime_checkable 去掉,为什么 isinstance(TickBuffer(3), Sized) 会抛 TypeError

提示
从上面的 TickWindow 起步:保留一个 list[PriceQuote] 字段,定义 __len____iter____getitem__,每次 push 后用切片 [-capacity:] 截尾。
提示
对 Protocol 做 isinstance 默认在运行时被拒绝;@runtime_checkable 是显式 opt-in,注册了结构检查。

通往下一课的桥

你现在能写出可以哈希、可以排序的值类,能写出 for x in obj: 直接走通的容器,也能写出语言能强制契约的 Pricer 接口。下一课会把这些抽象付诸行动:Pricer Protocol 成为 Strategy 模式的载体,ABC 成为 Factory 的注册条目。GoF 模式目录里大部分模式都是在弥补缺失的语言特性;在 Python 里同样的模式会塌缩到十行鸭子类型代码。

阅读清单:《Fluent Python》第 2 版 中译 第 13 章 (接口、协议与抽象基类), 第 14 章 (继承利与弊), 第 18 章 (with、match 与 else 块) 的 __enter__ / __exit__ 部分; CPython 官方 abctyping 中文文档; PEP 544 (Protocol) 与 PEP 585 (内建容器泛型) 与 PEP 612 (ParamSpec) 的中文社区导读; 廖雪峰 面向对象高级编程 的 __slots__ / @property 章节作为后续阅读; 一句话提示: 国内 私募 / 资管 团队 在 2026 年通常以 Python 3.11 或 3.12 为生产基线, 因此本课所有 typing 特性 (Self, runtime_checkable Protocol 缩窄, PEP 604 union shorthand) 都可直接使用。