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

类型注解与 dataclass

3.1.2 · Python 惯用法与开发工具 · 编程

周一早上九点二十分,你坐到某私募的研究台前,接手前同事留下的一段回测脚本。第一个函数是 def calc_pnl(ticks, holdings, fee): —— 参数到底是什么?ticks 是 list 还是 dict?holdings 的字段叫 symbol 还是 code?你只能把脚本跑一遍、撒几个 print、再去翻调用方。十分钟过去了,你才搞清楚:tickslist[Tick]Tick 是一个有三个字段的轻量记录。这一课要把这十分钟压成零——用类型注解(type hints)和数据类(dataclass)让一份脚本在六个月后仍能被人一眼读懂。

一、类型注解的基本写法

函数参数和返回值的注解写在冒号与箭头后:

def average_price(ticks: list[Tick]) -> float:
    return sum(t.price for t in ticks) / len(ticks)

读法:tickslist[Tick](Tick 元素的列表),返回 float。变量也能注解,写在赋值左侧:tick: Tick = Tick('600519', 1820.50, 200)。常用类型有 intfloatstrbool,以及内建泛型 list[int]dict[str, float]tuple[str, int, float]

允许空值时写 int | None(Python 3.10 及以上)——这是 Optional[int] 的等价短写法。三者关系是 Optional[T]T | NoneUnion[T, None],新代码统一用 | 写法即可。这一整套语法也称类型提示(type hint),与「类型注解」可互换。

二、运行时不强制

类型注解的一个反直觉点:​​Python 默认不在运行时检查它们​​。下面这段代码能完全正常运行:

def f(x: int) -> int: return "hello"

调用 f(3) 会返回字符串 "hello",没有任何异常。注解本质上是一份「写给读者和静态检查器的文档」。

要把它从文档升级成保证,需要一个独立的静态类型检查(static type check)工具——业界事实标准是 mypy。在终端跑 mypy file.py,上面这个函数会被报为 Incompatible return value type (got "str", expected "int")。部分量化团队会默认在 CI 中开 mypy --strict,让任何类型不一致直接挂掉构建。(mypy 的安装与项目配置放在本模块第 4 课。)

经验判断什么 bug 它能抓、什么抓不到:拼错字段名、传错参数顺序、忘记处理 None——能抓;数值越界、浮点误差、业务逻辑错——抓不到。类型检查只是「形状对不对」的最低保障。

三、用 @dataclass 写记录

沪深300 成分股的盘后处理,最常见的数据形状是 (证券代码, 成交价, 成交量)。用 @dataclass 写出来:

from dataclasses import dataclass

@dataclass(frozen=True)
class Tick:
    """单条 A 股逐笔成交记录(沪深300 成分股口径)。"""
    code: str
    price: float
    volume: int

构造与读取都直接:Tick('600519', 1820.50, 200)Tick('000001', 12.34, 1500),访问写 tick.price。装饰器替你合成了 __init____repr____eq__——若用普通类,这三件套要手写二十多行;若用 dict,字段名拼错只能在运行时报 KeyError,IDE 也补全不出来。

frozen=True 让实例不可变:执行 tick.price = 0.0 会抛 FrozenInstanceError。不可变带来三件好处:(1) 可作为 dict 的键、set 的元素(hashable);(2) 多线程读取天然安全;(3) 一份历史数据在内存里不会被静默改写。代价是字段一旦构造就改不了。

可变的字段默认值要写 field(default_factory=list),不要直接写 holdings: list = []——后者所有实例共享同一个 list,会出现非常隐蔽的串数据问题。

顺带一句:typing.NamedTuple@dataclass(frozen=True) 的前辈,写法更紧凑但只支持元组语义,新代码优先用 dataclass。

四、用 Protocol 描述「鸭子接口」

想象一个加权均价函数,希望对「任何带 price: float 字段的对象」都能工作——不管它是 Tick、是手写的 Holding、还是某个第三方 SDK 返回的对象。Java 的思路是抽一个基类让大家继承;Python 给了更轻的工具:

from typing import Protocol

class PricedRecord(Protocol):
    price: float

def weighted_average(records: list[PricedRecord]) -> float:
    return sum(r.price for r in records) / len(records)

任何具有 price: float 属性的类——包括上面定义的 Tick,以及一个 class Holding: code: str; shares: int; price: float——都自动满足 PricedRecord 这个协议(protocol),​**​完全不需要写 class Tick(PricedRecord)**​。这是结构子类型(structural subtyping,又称鸭子类型的类型化版本):mypy 只检查形状,不要求继承关系。在量化代码里,这让你能在不修改既有数据类的前提下,给它们套上类型化的接口约束。

五、练习

Exercise

定义 @dataclass(frozen=True) class Trade,三个字段为 symbol: strquantity: intprice: float,再加一个方法 notional(self) -> float,返回 self.quantity * self.price。验证:Trade("X", 100, 50.0).notional() == 5000.0 成立;对实例执行 t.price = 99.9 会抛出 FrozenInstanceError

提示
dataclasses 导入 dataclassFrozenInstanceError。装饰器写成 @dataclass(frozen=True),括号里的关键字参数不能省。
提示
notional 是普通实例方法,定义在类体内、与字段同级,返回类型注解为 -> float;构造实例时按声明顺序传入三个位置参数。

六、延伸阅读与下一课

延伸阅读:廖雪峰 Python 教程中的「类型注解」一节;《流畅的 Python》第 2 版第 8 章(类型提示)与第 5 章末(dataclass 部分);PEP 484、PEP 604 的中文社区翻译。

下一课转向另一种「让代码更易读」的工具:迭代器、生成器与上下文管理器。当你的 A 股逐笔流是几百万条记录的文件时,把 list[Tick] 换成 Iterator[Tick]、用 with open(...) as f 控制文件资源——内存占用会从 GB 级降到 MB 级,而调用方代码几乎不需要改动。