数据建模:dataclass、NamedTuple 与 Enum¶
很多类只是"一组有名字的字段"。为它们手写 __init__、__repr__、__eq__ 既啰嗦又容易出错。这一章讲怎么用 dataclass、NamedTuple、TypedDict、Enum 来表达数据,以及它们各自适合什么场景。
自测:能答上来就可以跳过本章
- dataclass 里写
tags: list = []会怎样?应该怎么写? frozen=True带来了什么?NamedTuple和 dataclass 的主要区别是什么?- 什么时候该用
TypedDict而不是 dataclass? - 枚举成员
Status.PAID的值是"paid",Status.PAID == "paid"的结果是什么?怎样让它成立?
自测参考答案(先自己答,再展开对照)
- 直接报错(dataclass 不允许可变的默认值),因为所有实例会共享同一个列表。应该写
tags: list = field(default_factory=list)。 - 实例不可修改(给字段赋值会抛出
FrozenInstanceError),并且自动生成__hash__,可以放进集合、作为字典的键;"修改"用dataclasses.replace生成新对象。 NamedTuple是元组:不可变、可以按下标访问和解包、和普通元组比较相等;dataclass 是普通的类,默认可变,不能按下标访问,更适合有行为、需要继承的数据对象。- 数据本来就是字典(JSON、配置、
**kwargs)、需要保持字典的形态传来传去时,用TypedDict只做静态的类型标注,运行时仍然是普通的字典。 False:普通的Enum成员不等于它的值。让它成立要用StrEnum(或继承str, Enum),成员本身就是字符串;或者比较Status.PAID.value == "paid"。
为什么需要 dataclass¶
手写一个"数据类"要写多少样板代码:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
用 dataclass,只需要声明字段:
>>> from dataclasses import dataclass
>>> @dataclass
... class Point:
... x: float
... y: float = 0.0
...
>>> p = Point(1.5)
>>> p
Point(x=1.5, y=0.0)
>>> p == Point(1.5, 0.0)
True
字段必须带类型标注(dataclass 靠标注识别字段),但类型不会在运行时检查:Point("a", "b") 也能创建成功。需要运行时校验时,在 __post_init__ 里做,或者使用 pydantic 之类的第三方库。
dataclass 常用选项¶
可变默认值用 field(default_factory=...)¶
>>> from dataclasses import field
>>> @dataclass
... class Bad:
... tags: list = []
...
Traceback (most recent call last):
...
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
>>> @dataclass
... class Article:
... title: str
... tags: list[str] = field(default_factory=list)
...
>>> a, b = Article("A"), Article("B")
>>> a.tags.append("py")
>>> b.tags
[]
dataclass 直接拒绝了可变默认值,替你挡掉了函数默认参数的那个坑。
frozen=True:不可变、可哈希¶
>>> @dataclass(frozen=True)
... class Money:
... amount: int
... currency: str = "CNY"
...
>>> m = Money(100)
>>> m.amount = 200
Traceback (most recent call last):
...
dataclasses.FrozenInstanceError: cannot assign to field 'amount'
>>> {Money(100), Money(100), Money(5, "USD")} == {Money(100), Money(5, "USD")}
True
不可变对象线程安全、可以当字典的键、不用担心被别人意外修改。值对象(金额、坐标、配置快照)都应该是 frozen 的。需要"修改"时,创建一个新对象:
>>> from dataclasses import replace
>>> replace(m, amount=300)
Money(amount=300, currency='CNY')
>>> import copy
>>> copy.replace(m, currency="USD") # 3.13+ 的通用写法
Money(amount=100, currency='USD')
其他选项¶
| 选项 | 作用 |
|---|---|
order=True |
按字段顺序生成 <、<= 等比较方法 |
slots=True |
生成 __slots__,更省内存,禁止随意添加属性 |
kw_only=True |
所有字段都只能按关键字传参,字段多的时候更可读 |
field(repr=False) |
不在 repr 里显示(比如密码) |
field(compare=False) |
不参与相等比较 |
field(kw_only=True) |
单个字段只能按关键字传 |
__post_init__:校验和派生字段¶
from dataclasses import dataclass, field
@dataclass(slots=True)
class Order:
item: str
unit_price: float
quantity: int = 1
total: float = field(init=False) # 不出现在 __init__ 参数里
def __post_init__(self):
if self.quantity <= 0:
raise ValueError("quantity must be positive")
self.total = round(self.unit_price * self.quantity, 2)
o = Order("book", 39.9, 3)
assert o.total == 119.7
assert "total=119.7" in repr(o)
转换为字典和元组¶
>>> from dataclasses import asdict, astuple, fields
>>> asdict(Article("A", ["x"]))
{'title': 'A', 'tags': ['x']}
>>> astuple(Point(1, 2))
(1, 2)
>>> [f.name for f in fields(Money)]
['amount', 'currency']
asdict 会递归处理嵌套的 dataclass、列表和字典,序列化成 JSON 前很方便。
NamedTuple:带名字的元组¶
typing.NamedTuple 创建的是元组的子类:不可变、可哈希、可以按索引访问、可以解包,内存占用和普通元组一样小。
>>> from typing import NamedTuple
>>> class Row(NamedTuple):
... name: str
... score: int = 0
...
>>> r = Row("amy", 95)
>>> r.name, r[1]
('amy', 95)
>>> name, score = r
>>> r._replace(score=99)
Row(name='amy', score=99)
>>> r._asdict()
{'name': 'amy', 'score': 95}
>>> r == ("amy", 95) # 它就是元组,会和普通元组相等
True
最后一行既是优点也是缺点:它可以无缝替换原来返回元组的函数,但"和任何同值元组相等"有时并不是你想要的语义。老代码里的 collections.namedtuple("Row", "name score") 是同一个东西的旧写法。
TypedDict:给字典描述结构¶
当数据本来就是字典(JSON、配置、HTTP 请求体),而你又不想转换成对象时,用 TypedDict 给它加上类型信息。它在运行时就是普通的 dict,只对类型检查器有意义:
from typing import NotRequired, TypedDict
class UserPayload(TypedDict):
id: int
name: str
email: NotRequired[str] # 这个键可以不存在
def display(u: UserPayload) -> str:
return f"{u['name']} <{u.get('email', 'n/a')}>"
payload: UserPayload = {"id": 1, "name": "amy"}
assert type(payload) is dict
assert display(payload) == "amy <n/a>"
Enum:有限的一组命名常量¶
用字符串或数字表示状态("pending"、2)很容易拼错,也看不出一共有哪些合法值。Enum 把它们变成类型:
>>> from enum import Enum, StrEnum, IntFlag, auto
>>> class Status(Enum):
... PENDING = "pending"
... PAID = "paid"
... SHIPPED = "shipped"
...
>>> Status.PAID, Status.PAID.value, Status.PAID.name
(<Status.PAID: 'paid'>, 'paid', 'PAID')
>>> Status("shipped") # 按值查找
<Status.SHIPPED: 'shipped'>
>>> Status["PENDING"] # 按名字查找
<Status.PENDING: 'pending'>
>>> [s.name for s in Status] # 可以遍历
['PENDING', 'PAID', 'SHIPPED']
>>> Status("lost")
Traceback (most recent call last):
...
ValueError: 'lost' is not a valid Status
枚举成员是单例,比较时可以用 is。
StrEnum 与 IntEnum¶
普通 Enum 成员不等于它的值:Status.PAID == "paid" 是 False。需要和字符串互通(比如直接写进 JSON、和数据库字段比较)时,用 StrEnum:
>>> class Color(StrEnum):
... RED = auto() # StrEnum 里 auto() 生成小写的名字
... GREEN = auto()
...
>>> Color.RED == "red", f"color={Color.GREEN}"
(True, 'color=green')
IntEnum 同理,适合和整数协议码互通。
Flag:可组合的开关¶
>>> class Perm(IntFlag):
... READ = auto()
... WRITE = auto()
... EXEC = auto()
...
>>> rw = Perm.READ | Perm.WRITE
>>> Perm.WRITE in rw, Perm.EXEC in rw
(True, False)
>>> int(rw)
3
给枚举加行为¶
枚举也是类,可以定义方法和属性。把"状态转移规则"写在枚举里,业务代码就不用到处写 if:
from enum import Enum
class OrderState(Enum):
CREATED = "created"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
def can_go_to(self, target):
return target in _TRANSITIONS[self]
_TRANSITIONS = {
OrderState.CREATED: {OrderState.PAID, OrderState.CANCELLED},
OrderState.PAID: {OrderState.SHIPPED, OrderState.CANCELLED},
OrderState.SHIPPED: set(),
OrderState.CANCELLED: set(),
}
assert OrderState.CREATED.can_go_to(OrderState.PAID)
assert not OrderState.SHIPPED.can_go_to(OrderState.CANCELLED)
怎么选¶
| 需求 | 推荐 |
|---|---|
| 一般的数据对象,可能有方法 | @dataclass |
| 值对象,需要可哈希、不可变 | @dataclass(frozen=True) |
| 大量小对象,关心内存 | @dataclass(slots=True) 或 NamedTuple |
| 替换返回元组的旧接口、需要解包 | NamedTuple |
| 数据本来就是 dict(JSON),只想要类型提示 | TypedDict |
| 外部输入需要运行时校验和类型转换 | 第三方的 pydantic、msgspec、attrs |
| 一组固定的取值 | Enum / StrEnum / IntFlag |
怎么讲清楚
讲数据建模:数据为主的类用 @dataclass,自动生成 __init__、__repr__、__eq__;可变默认值必须用 field(default_factory=list)(dataclass 直接拒绝 tags: list = []);frozen=True 得到不可变、可哈希的值对象,修改用 dataclasses.replace 生成新对象。NamedTuple 本身是元组(能解包、不可变、按位置比较),TypedDict 只是给字典描述结构、运行时仍是 dict,适合 JSON 这类外部数据;固定的取值集合用 Enum,要和字符串互通用 StrEnum。推理框架里的请求、采样参数大多是 dataclass 或 msgspec 结构体。
练习¶
1. 购物车建模。 用 dataclass 建模:LineItem(商品名、单价、数量,frozen);Cart(一组 LineItem,可变)。Cart 提供 add(item)、total 属性(保留两位小数),以及 from_dict(data) 类方法,从 {"items": [{"name": ..., "price": ..., "qty": ...}]} 构建。
参考答案
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class LineItem:
name: str
price: float
qty: int = 1
def __post_init__(self):
if self.price < 0 or self.qty <= 0:
raise ValueError(f"invalid line item: {self}")
@property
def subtotal(self):
return self.price * self.qty
@dataclass
class Cart:
items: list[LineItem] = field(default_factory=list)
def add(self, item: LineItem) -> None:
self.items.append(item)
@property
def total(self) -> float:
return round(sum(i.subtotal for i in self.items), 2)
@classmethod
def from_dict(cls, data: dict) -> "Cart":
return cls([LineItem(d["name"], d["price"], d.get("qty", 1)) for d in data["items"]])
cart = Cart.from_dict({"items": [{"name": "pen", "price": 2.5, "qty": 4}, {"name": "book", "price": 39.9}]})
cart.add(LineItem("bag", 0.1, 3))
assert cart.total == 50.2
round 解决不了所有浮点问题。真实的金额计算应该用整数"分"或者 decimal.Decimal,见惯用法与常见坑。
2. HTTP 状态码。 定义 HttpStatus(IntEnum),包含 200、201、400、404、500,并添加 is_success 和 is_client_error 两个属性。验证 HttpStatus(404).is_client_error 为真,并且 HttpStatus.OK == 200。
参考答案
from enum import IntEnum
class HttpStatus(IntEnum):
OK = 200
CREATED = 201
BAD_REQUEST = 400
NOT_FOUND = 404
SERVER_ERROR = 500
@property
def is_success(self):
return 200 <= self < 300
@property
def is_client_error(self):
return 400 <= self < 500
assert HttpStatus(404).is_client_error and not HttpStatus(404).is_success
assert HttpStatus.OK == 200 and HttpStatus.CREATED.is_success
标准库里其实已经有 http.HTTPStatus,它就是一个 IntEnum,并且带 is_success 等属性(3.12+)。
小结¶
- 数据为主的类用
@dataclass,可变默认值用field(default_factory=...)。 - 值对象用
frozen=True,"修改"用replace生成新对象。 -
NamedTuple是元组;TypedDict是字典;它们各有适合的场景。 - 固定的取值集合用 Enum;需要和字符串、整数互通时用
StrEnum、IntEnum。 - 需要运行时校验外部输入时,考虑 pydantic 等库。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题