Python 基础体系 · 第 27/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python dataclass 与 Enum:数据模型、不可变性、比较和序列化
dataclass 和 Enum 经常一起出现在配置对象、领域模型、消息协议和状态机中,但它们解决的是两个不同的问题:
dataclass负责描述一组有名字的属性,并自动生成构造、表示、比较等方法;Enum负责表示有限且有明确身份的符号集合;frozen=True只提供受限的“不可重新赋值”语义,不等于递归不可变;==、is、排序和哈希分别遵循不同规则;- 序列化不是
dataclass或Enum自动获得的能力,必须明确协议中的字段形状和值表示。
下面的示例以 Python 3.14 为范围。
一、先区分三种不同的数据含义
假设订单有一个状态字段:
status = "paid"
这个字符串虽然简单,但它允许任意值:
status = "paied" # 拼写错误
status = "finished" # 可能不属于当前协议
status = "" # 空值
如果状态集合是固定的,可以使用 Enum:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "created"
PAID = "paid"
CANCELLED = "cancelled"
此时:
status = OrderStatus.PAID
表达的不是“一个恰好等于 "paid" 的字符串”,而是“状态集合中的 PAID 成员”。Enum 是由符号名称和对应值组成的有限集合,成员可以通过名称访问,也可以通过值构造。(docs.python.org)
而订单本身通常由多个字段组成:
from dataclasses import dataclass
@dataclass
class Order:
order_id: str
status: OrderStatus
amount: int
这里:
OrderStatus描述字段的取值集合;Order描述多个字段组成的数据模型;dataclass负责减少样板代码;- 类型标注主要服务于开发工具、静态分析和代码可读性,
dataclass通常不会运行时校验标注类型。@dataclass识别字段的主要依据是类变量上的类型标注,但除了ClassVar和InitVar等特殊情况,它不会根据标注自动执行类型检查。(docs.python.org)
二、dataclass 到底生成了什么
2.1 字段顺序决定构造和比较顺序
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: int
stock: int = 0
可以把它近似理解为生成了:
class Product:
def __init__(self, name: str, price: int, stock: int = 0):
self.name = name
self.price = price
self.stock = stock
def __repr__(self):
return (
f"Product(name={name!r}, price={price!r}, "
f"stock={stock!r})"
)
实际生成的 __repr__ 细节由 Python 实现负责,但字段顺序来自类定义顺序。默认情况下,dataclass 会生成 __init__、__repr__ 和 __eq__,并且只有在相应方法没有被显式定义时才生成。(docs.python.org)
运行:
product = Product("keyboard", 199)
print(product)
print(product.stock)
输出类似:
Product(name='keyboard', price=199, stock=0)
0
默认字段必须出现在默认字段之前:
@dataclass
class Invalid:
x: int = 1
y: int
这会在类创建阶段失败,因为生成的构造函数无法形成合法的参数顺序:
TypeError: non-default argument 'y' follows default argument
这个约束不仅适用于单个类,也适用于继承后合并出来的字段。
2.2 field() 控制单个字段的行为
field() 用于表达普通默认值无法表达的字段策略:
from dataclasses import dataclass, field
@dataclass
class User:
username: str
tags: list[str] = field(default_factory=list)
password_hash: str = field(repr=False)
internal_id: int = field(compare=False, default=0)
几个参数的含义不同:
default:直接默认值;default_factory:需要默认值时调用的零参数函数;init=False:不放入生成的__init__;repr=False:不出现在生成的repr中;compare=False:不参与生成的比较方法;hash=False:不参与生成的哈希;kw_only=True:只允许关键字传参;metadata:供第三方工具使用的只读元数据;- Python 3.14 新增
doc,用于为字段提供文档字符串。metadata本身不被dataclass解释,它只是一个扩展入口。(docs.python.org)
2.3 为什么可变默认值必须使用 default_factory
下面的写法是错误的:
@dataclass
class Bad:
tags: list[str] = []
Python 的默认值本质上是类定义阶段创建的对象。如果允许这个列表作为默认值,那么多个实例可能共享同一个列表。Python 3.11 起,dataclass 会拒绝不可哈希的默认值,用不可哈希性近似判断对象可能是可变对象。(docs.python.org)
正确写法是:
@dataclass
class Good:
tags: list[str] = field(default_factory=list)
default_factory=list 表示:每次调用 Good() 且没有显式传入 tags 时,重新执行一次 list()。
a = Good()
b = Good()
a.tags.append("python")
print(a.tags)
print(b.tags)
print(a.tags is b.tags)
输出:
['python']
[]
False
default_factory 解决的是默认对象共享问题,并不意味着字段本身变成不可变对象。
三、Enum 的身份、值和别名
3.1 name 与 value
from enum import Enum
class PaymentMethod(Enum):
CARD = "card"
ALIPAY = "alipay"
两个属性分别表示:
PaymentMethod.CARD.name # "CARD"
PaymentMethod.CARD.value # "card"
name 是 Python 代码中的成员名,value 是与外部系统交互时可以使用的值。二者不应该混为一谈。
通常建议:
- 成员名使用稳定、清晰的英文大写标识;
- 对外协议使用显式字符串值;
- 不要依赖
auto()生成的整数作为长期存储格式,除非你明确接受成员增删导致的编号变化。
通过值构造成员:
PaymentMethod("card") is PaymentMethod.CARD
如果值不存在,会抛出:
ValueError: 'cash' is not a valid PaymentMethod
这使得 Enum("外部值") 也可以作为一种边界解析操作。
3.2 普通 Enum、IntEnum 和 StrEnum
普通 Enum 不会自动等于底层值:
class Color(Enum):
RED = 1
Color.RED == 1 # False
Color.RED is Color.RED # True
IntEnum 是 int 的子类:
from enum import IntEnum
class HttpStatus(IntEnum):
OK = 200
NOT_FOUND = 404
HttpStatus.OK == 200 # True
int(HttpStatus.OK) # 200
HttpStatus.OK + 1 # 201,结果是 int
IntEnum 的便利也会带来边界模糊:不同的 IntEnum 类型只要整数值相同,也可能比较相等:
class DatabaseCode(IntEnum):
OK = 200
HttpStatus.OK == DatabaseCode.OK # True
因此,IntEnum 适合兼容已有整数 API 的场景,但不适合要求不同领域枚举严格隔离的场景。
StrEnum 同理,是 str 的子类:
from enum import StrEnum
class Environment(StrEnum):
DEV = "dev"
PROD = "prod"
Environment.PROD == "prod" # True
StrEnum 对字符串 API 和 JSON 边界更方便,但它也意味着枚举成员会参与字符串语义。普通 Enum 则更能强制调用方明确写出 .value。
3.3 别名不是新成员
默认情况下,相同值可以产生别名:
class JobState(Enum):
RUNNING = "running"
ACTIVE = "running"
此时:
JobState.RUNNING is JobState.ACTIVE # True
JobState.ACTIVE.name # "RUNNING"
list(JobState)
迭代结果只包含规范成员,不包含别名;__members__ 则包含全部名称,包括别名。(docs.python.org)
如果不允许重复值,使用 @unique:
from enum import Enum, unique
@unique
class JobState(Enum):
RUNNING = "running"
ACTIVE = "running"
类定义阶段会抛出 ValueError。
四、比较:dataclass 与 Enum 的规则完全不同
4.1 dataclass 的相等比较是字段比较
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
Point(1, 2) == Point(1, 2) # True
Point(1, 2) == Point(2, 1) # False
默认生成的 __eq__ 会按字段顺序比较,并且要求两边是完全相同的类类型:
@dataclass
class BasePoint:
x: int
y: int
@dataclass
class ColoredPoint(BasePoint):
color: str = "black"
BasePoint(1, 2) == ColoredPoint(1, 2) # False
即使共同字段的值相同,类型不同也不相等。这避免了不同模型因为字段“碰巧相同”而被视为相等。Python 3.13 起,生成的 __eq__ 按字段逐项比较,而不是先构造字段元组;这可能影响包含 NaN 等特殊值的边界行为。(docs.python.org)
如果某个字段只是缓存、日志或运行时辅助信息,可以排除它:
@dataclass
class Request:
method: str
path: str
trace_id: str = field(compare=False, default="")
于是:
Request("GET", "/", "trace-a") == Request("GET", "/", "trace-b")
# True
这不是“忽略字段更好”,而是一个领域建模决策:如果两个对象的 trace_id 不应该影响业务身份,才应该使用 compare=False。
4.2 Enum 的比较以身份为核心
普通 Enum 成员是单例:
class Color(Enum):
RED = 1
BLUE = 2
Color.RED is Color.RED # True
Color.RED is Color.BLUE # False
Color.RED == Color.RED # True
Color.RED == 1 # False
普通 Enum 不支持有序比较:
Color.RED < Color.BLUE
会抛出:
TypeError: '<' not supported between instances of 'Color' and 'Color'
如果业务确实有顺序,不应该把成员的整数值偶然当作排序规则。可以显式建立排序键:
from enum import Enum
class Priority(Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
priority_rank = {
Priority.LOW: 10,
Priority.NORMAL: 20,
Priority.HIGH: 30,
}
Priority.HIGH.value
priority_rank[Priority.HIGH]
这样“协议值”和“业务排序”是两个独立概念。
4.3 order=True 不是自动获得业务排序
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
此时会生成 <、<=、>、>=,比较逻辑类似按字段元组顺序比较:
Version(2, 0, 0) > Version(1, 9, 9) # True
形式上,它比较的是:
比较步骤是:
- 比较第一个字段;
- 如果不相等,直接得到结果;
- 如果相等,比较下一个字段;
- 直到出现不相等字段,或所有字段都相等。
但这个规则只有在字段顺序恰好就是业务顺序时才正确。例如金额模型中:
@dataclass(order=True)
class Money:
currency: str
amount: int
它会先按货币字符串排序,再按金额排序,通常不是你想要的“金额大小”。因此,order=True 是机械的字段排序,不是领域语义推导器。
五、不可变性:frozen=True 只冻结属性绑定
5.1 frozen=True 的直接效果
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
point = Coordinate(1, 2)
point.x = 3
会抛出:
dataclasses.FrozenInstanceError
frozen=True 会生成阻止 __setattr__ 和 __delattr__ 的方法,从而模拟只读实例。它不是 Python 级别的绝对不可变,因为 Python 不提供真正不可绕过的不可变对象机制。冻结实例的生成构造函数也需要通过 object.__setattr__ 初始化字段,因此存在轻微额外开销。(docs.python.org)
5.2 冻结是浅层的
@dataclass(frozen=True)
class Config:
labels: list[str]
config = Config(["stable"])
config.labels.append("python")
这段代码可以执行,因为禁止的是:
config.labels = [...]
而不是阻止列表对象内部修改。
如果模型需要更强的不可变性,应使用不可变字段类型:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Config:
labels: tuple[str, ...]
config = Config(("stable",))
这里有三层不同保证:
| 层次 | 示例 | 能否修改 |
|---|---|---|
| 属性绑定 | config.labels = ... |
frozen=True 阻止 |
| 容器内容 | config.labels.append(...) |
tuple 没有该操作 |
| 嵌套对象内部状态 | tuple([some_mutable_object]) |
仍可能修改 |
所以,“不可变数据模型”通常需要同时考虑:
@dataclass(frozen=True, slots=True)
class User:
roles: tuple[str, ...]
attributes: tuple[tuple[str, str], ...]
slots=True 主要改变实例属性存储方式,减少实例动态属性能力;它不是不可变性的替代品。生成 __slots__ 时还涉及继承、弱引用和 __init_subclass__ 等约束,不能把它理解为简单的“性能开关”。(docs.python.org)
六、不可变性与哈希:必须同时考虑 eq 和字段状态
哈希表要求一个对象在作为键期间保持:
如果对象放入集合后,参与相等比较的字段发生变化,集合就可能无法按原来的哈希位置找到它。因此,dataclass 默认不会随意生成哈希方法。
主要规则如下:
eq |
frozen |
默认行为 |
|---|---|---|
True |
True |
生成 __hash__ |
True |
False |
__hash__ = None,不可哈希 |
False |
任意 | 保留父类哈希行为 |
这些规则对应一个直接因果链:
eq=True表示对象身份由字段值决定;- 如果对象可变,字段值可能变化;
- 字段值变化会改变相等关系;
- 因此可变且按字段比较的对象默认不应作为哈希键。
示例:
from dataclasses import dataclass
@dataclass(frozen=True)
class UserId:
value: str
ids = {UserId("u-1")}
print(UserId("u-1") in ids)
输出:
True
而下面的对象默认不可哈希:
@dataclass
class MutableUser:
name: str
hash(MutableUser("alice"))
会抛出:
TypeError: unhashable type: 'MutableUser'
unsafe_hash=True 会强制生成哈希,但名字中的 unsafe 是有实际含义的:它可能让一个可变对象进入集合或字典,从而破坏哈希容器的不变量。除非你能证明参与哈希的状态不会变化,否则不应使用它。(docs.python.org)
还要注意字段级别设置:
@dataclass(frozen=True)
class CacheEntry:
key: str
payload: bytes = field(compare=False)
payload 不参与比较,也默认不参与哈希。这样两个对象只要 key 相同就相等:
CacheEntry("a", b"x") == CacheEntry("a", b"y")
# True
如果这不是业务身份定义,就不能为了“让对象可哈希”而随意排除字段。
七、dataclass 与 Enum 的正确组合方式
常见模型是:
from dataclasses import dataclass
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "created"
PAID = "paid"
CANCELLED = "cancelled"
@dataclass(frozen=True, slots=True)
class Order:
order_id: str
status: OrderStatus
amount_cents: int
这个组合表达了两个层次:
OrderStatus.PAID
└── 一个有限状态成员
Order(...)
├── order_id
├── status: OrderStatus
└── amount_cents
创建对象时,Python 不会因为标注为 OrderStatus 就自动把字符串转换为枚举:
Order("o-1", "paid", 1299)
这在运行时可能被接受,因为普通 dataclass 不负责类型校验。更严格的边界构造应该显式转换:
Order(
order_id="o-1",
status=OrderStatus("paid"),
amount_cents=1299,
)
或者在 __post_init__ 中校验:
@dataclass(frozen=True)
class StrictOrder:
order_id: str
status: OrderStatus
amount_cents: int
def __post_init__(self) -> None:
if self.amount_cents < 0:
raise ValueError("amount_cents must be non-negative")
__post_init__ 会在生成的 __init__ 结束后调用,适合表达依赖多个字段的局部不变量。它不是完整的数据校验框架,也不会自动递归检查类型。(docs.python.org)
不要直接给 Enum 加 @dataclass
下面的写法不应该使用:
from dataclasses import dataclass
from enum import Enum
@dataclass
class BadEnum(Enum):
RED = 1
BLUE = 2
官方文档明确指出,直接给 Enum 或其子类添加 @dataclass 不受支持,可能导致不同枚举成员比较相等等异常结果。(docs.python.org)
如果需要带结构化数据的枚举,可以让枚举继承一个 dataclass mixin:
from dataclasses import dataclass, field
from enum import Enum
@dataclass
class CreatureData:
size: str
legs: int
tail: bool = field(default=True, repr=False)
class Creature(CreatureData, Enum):
BEETLE = ("small", 6)
DOG = ("medium", 4)
print(Creature.DOG)
Python 3.12 起,相关 repr 会只展示 dataclass 字段值区域,而不是重复展示 dataclass 类名。这个能力适合描述“有限集合中的结构化常量”,但不应替代普通的“Enum 字段嵌入 dataclass”模式。(docs.python.org)
八、序列化:对象形状和值表示必须分别设计
8.1 JSON 不知道什么是 dataclass
import json
from dataclasses import dataclass
from enum import Enum
class Status(Enum):
PAID = "paid"
@dataclass
class Payment:
status: Status
amount: int
payment = Payment(Status.PAID, 100)
json.dumps(payment)
默认会抛出:
TypeError: Object of type Payment is not JSON serializable
原因是标准 JSON 编码器只认识基本 JSON 类型,以及由这些类型组成的列表和字典。无法编码的对象可以通过 default 参数转换;如果 default 没有返回可编码对象,最终仍会抛出 TypeError。(docs.python.org)
8.2 asdict() 不是完整的 JSON 序列化器
from dataclasses import asdict
asdict(payment)
结果是:
{"status": Status.PAID, "amount": 100}
asdict() 会递归转换嵌套 dataclass、字典、列表和元组;对于其他对象,会执行 copy.deepcopy()。它不会自动把普通 Enum 转换成 .value,因此结果仍可能不能直接交给 json.dumps()。(docs.python.org)
可以这样写一个明确的转换函数:
import json
from dataclasses import fields, is_dataclass
from enum import Enum
from typing import Any
def to_jsonable(value: Any) -> Any:
if is_dataclass(value) and not isinstance(value, type):
return {
field.name: to_jsonable(getattr(value, field.name))
for field in fields(value)
}
if isinstance(value, Enum):
return to_jsonable(value.value)
if isinstance(value, dict):
return {
str(key): to_jsonable(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [to_jsonable(item) for item in value]
if value is None or isinstance(value, (str, int, float, bool)):
return value
raise TypeError(f"unsupported type: {type(value).__name__}")
运行:
payload = to_jsonable(payment)
text = json.dumps(payload, ensure_ascii=False, sort_keys=True)
print(payload)
print(text)
输出:
{'status': 'paid', 'amount': 100}
{"amount": 100, "status": "paid"}
这里的转换顺序很重要:
- 先判断是否为 dataclass 实例;
- 再判断是否为
Enum; - 递归处理容器;
- 最后接受 JSON 基本类型;
- 其他类型明确失败。
如果先把所有对象交给 str(),虽然不会报错,但会把结构化数据变成不可逆的日志字符串,例如:
{"status": "Status.PAID"}
这通常无法可靠还原为 Status.PAID。
九、一个完整的订单模型示例
下面的代码展示从输入字典、领域对象到 JSON,再从 JSON 恢复领域对象的完整路径。
from __future__ import annotations
import json
from dataclasses import dataclass, fields, is_dataclass
from enum import StrEnum
from typing import Any
class OrderStatus(StrEnum):
CREATED = "created"
PAID = "paid"
CANCELLED = "cancelled"
@dataclass(frozen=True, slots=True)
class Order:
order_id: str
status: OrderStatus
amount_cents: int
def __post_init__(self) -> None:
if not self.order_id:
raise ValueError("order_id must not be empty")
if self.amount_cents < 0:
raise ValueError("amount_cents must be non-negative")
def order_from_dict(data: dict[str, Any]) -> Order:
try:
return Order(
order_id=str(data["order_id"]),
status=OrderStatus(data["status"]),
amount_cents=int(data["amount_cents"]),
)
except KeyError as exc:
raise ValueError(f"missing field: {exc.args[0]}") from exc
except (TypeError, ValueError) as exc:
raise ValueError("invalid order payload") from exc
def to_jsonable(value: Any) -> Any:
if is_dataclass(value) and not isinstance(value, type):
return {
field.name: to_jsonable(getattr(value, field.name))
for field in fields(value)
}
if isinstance(value, Enum):
return to_jsonable(value.value)
if isinstance(value, dict):
return {
str(key): to_jsonable(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [to_jsonable(item) for item in value]
if value is None or isinstance(value, (str, int, float, bool)):
return value
raise TypeError(f"unsupported type: {type(value).__name__}")
def order_to_json(order: Order) -> str:
return json.dumps(
to_jsonable(order),
ensure_ascii=False,
sort_keys=True,
)
def order_from_json(text: str) -> Order:
try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError("invalid JSON") from exc
if not isinstance(raw, dict):
raise ValueError("order JSON must be an object")
return order_from_dict(raw)
if __name__ == "__main__":
incoming = {
"order_id": "o-100",
"status": "paid",
"amount_cents": 1299,
}
order = order_from_dict(incoming)
encoded = order_to_json(order)
restored = order_from_json(encoded)
print(order)
print(encoded)
print(restored)
print(order == restored)
预期输出:
Order(order_id='o-100', status=<OrderStatus.PAID: 'paid'>, amount_cents=1299)
{"amount_cents": 1299, "order_id": "o-100", "status": "paid"}
Order(order_id='o-100', status=<OrderStatus.PAID: 'paid'>, amount_cents=1299)
True
这里的边界职责是分开的:
外部字典
│
├── 字段存在性检查
├── status 字符串 → OrderStatus
├── amount_cents → int
└── Order.__post_init__ 不变量检查
│
▼
Order
│
├── Enum → .value
├── dataclass → 字典
└── 字典 → JSON
序列化时使用 .value,反序列化时使用 OrderStatus(raw_value),这构成了一个明确的双向映射:
如果外部协议保存的是 "PAID",就应该明确采用 .name,而不是一会儿使用名称、一会儿使用值:
status.name # "PAID"
status.value # "paid"
协议字段应当固定一种表示方式。
十、修改冻结对象:使用 replace() 创建新对象
冻结对象不能原地修改,但可以创建替换后的新对象:
from dataclasses import replace
paid_order = replace(order, status=OrderStatus.PAID)
print(order.status)
print(paid_order.status)
print(order is paid_order)
输出:
OrderStatus.PAID
OrderStatus.PAID
False
replace() 会调用类的 __init__,因此也会再次触发 __post_init__。这意味着它不是简单复制内存,而是重新经过构造路径。对于 init=False 字段,replace() 有特殊限制:不能直接在 changes 中指定这类字段,它们也不会简单地从原对象复制。(docs.python.org)
这使得冻结模型适合用“状态转换”表达业务动作:
def pay(order: Order) -> Order:
if order.status is not OrderStatus.CREATED:
raise ValueError("only created orders can be paid")
return replace(order, status=OrderStatus.PAID)
状态转换不是:
order.status = OrderStatus.PAID
而是:
new_order = pay(order)
旧对象保持不变,调用方可以安全地保存旧状态、重试转换或在并发代码中共享它。
十一、ClassVar、InitVar 与真正的字段
并不是所有带标注的类变量都会成为实例字段。
11.1 ClassVar 不属于实例数据
from dataclasses import dataclass, fields
from typing import ClassVar
@dataclass
class User:
name: str
table_name: ClassVar[str] = "users"
print([field.name for field in fields(User)])
输出:
['name']
table_name 是类级别配置,不会进入构造函数、比较方法或 fields() 结果。(docs.python.org)
11.2 InitVar 只参与初始化
from dataclasses import dataclass, InitVar
@dataclass
class User:
name: str
normalized_name: str = ""
normalize: InitVar[bool] = True
def __post_init__(self, normalize: bool) -> None:
if normalize:
self.normalized_name = self.name.strip().lower()
normalize 会进入生成的 __init__,也会传给 __post_init__,但不会成为持久字段,也不会出现在 fields() 结果中。(docs.python.org)
这适合表示“构造过程需要的依赖”或“初始化选项”,不适合表示模型状态。
十二、序列化边界的几个失败路径
12.1 普通 Enum 不能直接被当成 JSON 值
class Status(Enum):
PAID = "paid"
json.dumps({"status": Status.PAID})
需要显式使用:
json.dumps({"status": Status.PAID.value})
如果使用 StrEnum,由于它是字符串子类,在很多标准字符串处理路径中更方便,但这不应成为省略协议设计的理由。需要决定外部协议表达的是字符串值,而不是依赖 Python 类的继承关系。
12.2 asdict() 可能产生深拷贝成本
asdict() 对非 dataclass 对象使用 copy.deepcopy()。对于大型嵌套对象、缓存对象或包含不可复制资源的字段,这可能带来额外开销或失败。需要浅转换时,可以基于 fields() 手动读取:
from dataclasses import fields
def shallow_dict(obj: Any) -> dict[str, Any]:
return {
field.name: getattr(obj, field.name)
for field in fields(obj)
}
这个函数不会递归转换,也不会自动复制字段值。
12.3 JSON 的字典键会被转换为字符串
JSON 对象的键是字符串。标准库文档明确指出,字典转 JSON 时,键会被强制转换为字符串,因此含有非字符串键的数据经过编码再解码后,可能不再等于原字典。(docs.python.org)
data = {1: "one"}
text = json.dumps(data)
restored = json.loads(text)
print(text) # {"1": "one"}
print(restored) # {'1': 'one'}
因此,协议模型中的映射字段最好明确使用字符串键,而不是依赖 Python 字典键的任意类型。
12.4 NaN 默认不是严格 JSON
json.dumps() 默认允许 NaN、Infinity 和 -Infinity,但这些值不符合严格 JSON 规范。需要严格输出时:
json.dumps({"value": float("nan")}, allow_nan=False)
会抛出 ValueError。这是协议边界应显式决定的兼容性选项,而不是默认行为可以替代的规范。(docs.python.org)
十三、JSON、pickle 与对象复制不是同一件事
这几个操作经常被混淆:
| 操作 | 目的 | 典型边界 |
|---|---|---|
dataclasses.replace() |
创建同类型的新 dataclass 对象 | 业务状态转换 |
dataclasses.asdict() |
把 dataclass 转为 Python 字典 | 内部数据整理 |
json.dumps() |
转为跨语言文本协议 | HTTP、消息、文件 |
pickle.dumps() |
Python 对象持久化或进程间传输 | Python 内部环境 |
pickle 不应被当作通用外部协议。枚举可以被 pickle,但通常要求枚举定义在模块顶层,因为反序列化需要通过模块路径重新导入它。(docs.python.org)
如果数据需要跨语言、跨版本或被不信任的系统读取,应使用明确的 JSON、MessagePack、协议缓冲区等协议,并定义字段名称、枚举值、缺失字段和未知值的处理方式。
十四、如何选择建模方式
使用普通 dataclass
适合:
@dataclass
class SearchResult:
query: str
total: int
特点:
- 字段可变;
- 默认按字段比较;
- 默认不适合作为哈希键;
- 适合构造过程中逐步填充的内部对象。
使用 frozen=True 的 dataclass
适合:
@dataclass(frozen=True, slots=True)
class Money:
currency: str
amount_cents: int
特点:
- 构造后不能直接重新绑定字段;
- 默认可以生成哈希;
- 适合值对象、配置快照、消息对象和状态转换;
- 必须避免内部嵌套可变对象破坏“不可变”假设。
使用普通 Enum
适合要求类型身份明确的状态或分类:
class OrderStatus(Enum):
CREATED = "created"
PAID = "paid"
优点是不会自动等于字符串或整数,能够迫使边界代码明确转换。
使用 StrEnum 或 IntEnum
适合需要兼容既有字符串或整数 API 的场景:
class HttpMethod(StrEnum):
GET = "GET"
POST = "POST"
但应记住:它们会参与底层 str 或 int 的比较和操作,类型隔离能力弱于普通 Enum。
十五、最终检查模型是否正确
设计一个 dataclass + Enum 模型时,至少应逐项回答:
- 哪些字段是模型的真正业务身份?
- 哪些字段只用于日志、缓存或追踪,是否应
compare=False? - 对象是否需要
frozen=True? - 冻结字段中是否仍包含列表、字典或其他可变对象?
- 对象是否要作为字典键或集合成员?
- 如果需要哈希,参与哈希的字段是否稳定?
- 枚举对外暴露
.name还是.value? - 未知枚举值在反序列化时应该失败、映射到
UNKNOWN,还是保留原始字符串? dataclass是否只是结构描述,还是还需要__post_init__维护不变量?- JSON 转换是否可逆,是否明确处理嵌套 dataclass、Enum、日期、Decimal 和非字符串字典键?
dataclass 解决的是“怎样表达对象字段并减少样板代码”,Enum 解决的是“怎样表达有限的符号身份”,frozen 解决的是“怎样禁止常规属性重新绑定”,而序列化解决的是“怎样把内存对象映射到外部协议”。只有把这四个问题分开,模型的比较、哈希、状态转换和跨边界传输才会保持一致。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python ABC 与 Protocol:名义子类型、结构化类型和接口设计
- 下一篇:Python 特殊方法:容器、运算符、调用、表示与上下文协议
- 延伸:Python 类型标注基础:Union、Literal、TypedDict、Narrowing 与边界
- 延伸:Pydantic v2:模型、校验器、序列化、Settings 和性能边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论