开场
某私募周二上午九点四十二,一位实习研究员把自写的 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.025、sigma = 0.22、T = 30/252。BlackScholesPricer(PricerABC) 同时满足两条路径——因为继承关系,它「是一种」PricerABC;因为它有一个形状匹配的 price 方法,它「行为像」PricerProto。@runtime_checkable 装饰器加在 Protocol 上是显式 opt-in,让 isinstance(obj, PricerProto) 在运行时返回 True。没有这个装饰器,对 Protocol 做 isinstance 会抛 TypeError——Protocol 默认是静态检查构造。
什么时候选哪一个?当你控制整族子类、并希望语言在实例化时强制契约——选 ABC。当消费方只要求形状合适的对象——也许是你团队没写过的 SDK 客户端,也许是测试桩——你不想强制让测试桩继承一个它没理由认识的类——选 Protocol。
泛型类:把元素类型参数化
最后一层是泛型类。容器里的元素类型在调用方往往很重要:装着 PriceQuote 的 Cache 应该静态拒绝 cache.put('k', 'oops'),并且 IDE 在 cache.get('k').price 上应当自动补全到正确类型。typing.TypeVar 与 Generic[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')
mypy 或 pyright 读到 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]。本课的三种具体决策:
| surface | use when | enforcement | canonical example in this lesson |
|---|---|---|---|
@dataclass | the data is a value with named fields | runtime; no behaviour beyond auto-synthesised dunders | PriceQuote |
abc.ABC + @abstractmethod | you control the closed family of subclasses | runtime; instantiating an unfinished subclass raises TypeError | PricerABC -> BlackScholesPricer |
typing.Protocol | the consumer accepts any object with the right shape | static; runtime via @runtime_checkable + isinstance | PricerProto matches BlackScholesPricer without inheritance |
Generic[T] + TypeVar | the element type matters for static checking | static via mypy / pyright; runtime erases type parameters | Cache[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,它保存最近 capacity 个 PriceQuote 实例(上面定义的 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:] 截尾。提示
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 官方 abc 与 typing 中文文档; 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) 都可直接使用。