Python 基础体系 · 第 27/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python dataclass 与 Enum:数据模型、不可变性、比较和序列化

dataclassEnum 经常一起出现在配置对象、领域模型、消息协议和状态机中,但它们解决的是两个不同的问题:

  • dataclass 负责描述一组有名字的属性,并自动生成构造、表示、比较等方法;
  • Enum 负责表示有限且有明确身份的符号集合
  • frozen=True 只提供受限的“不可重新赋值”语义,不等于递归不可变;
  • ==is、排序和哈希分别遵循不同规则;
  • 序列化不是 dataclassEnum 自动获得的能力,必须明确协议中的字段形状和值表示。

下面的示例以 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 识别字段的主要依据是类变量上的类型标注,但除了 ClassVarInitVar 等特殊情况,它不会根据标注自动执行类型检查。(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 namevalue

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 普通 EnumIntEnumStrEnum

普通 Enum 不会自动等于底层值:

class Color(Enum):
    RED = 1

Color.RED == 1       # False
Color.RED is Color.RED  # True

IntEnumint 的子类:

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


四、比较:dataclassEnum 的规则完全不同

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

形式上,它比较的是:

(a1,a2,,an)<(b1,b2,,bn)(a_1, a_2, \ldots, a_n) < (b_1, b_2, \ldots, b_n)

比较步骤是:

  1. 比较第一个字段;
  2. 如果不相等,直接得到结果;
  3. 如果相等,比较下一个字段;
  4. 直到出现不相等字段,或所有字段都相等。

但这个规则只有在字段顺序恰好就是业务顺序时才正确。例如金额模型中:

@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 和字段状态

哈希表要求一个对象在作为键期间保持:

a==bhash(a)==hash(b)a == b \Rightarrow hash(a) == hash(b)

如果对象放入集合后,参与相等比较的字段发生变化,集合就可能无法按原来的哈希位置找到它。因此,dataclass 默认不会随意生成哈希方法。

主要规则如下:

eq frozen 默认行为
True True 生成 __hash__
True False __hash__ = None,不可哈希
False 任意 保留父类哈希行为

这些规则对应一个直接因果链:

  1. eq=True 表示对象身份由字段值决定;
  2. 如果对象可变,字段值可能变化;
  3. 字段值变化会改变相等关系;
  4. 因此可变且按字段比较的对象默认不应作为哈希键。

示例:

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

如果这不是业务身份定义,就不能为了“让对象可哈希”而随意排除字段。


七、dataclassEnum 的正确组合方式

常见模型是:

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"}

这里的转换顺序很重要:

  1. 先判断是否为 dataclass 实例;
  2. 再判断是否为 Enum
  3. 递归处理容器;
  4. 最后接受 JSON 基本类型;
  5. 其他类型明确失败。

如果先把所有对象交给 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),这构成了一个明确的双向映射:

OrderStatus.PAID"paid"OrderStatus.PAID\text{OrderStatus.PAID} \rightarrow \text{"paid"} \rightarrow \text{OrderStatus.PAID}

如果外部协议保存的是 "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)

旧对象保持不变,调用方可以安全地保存旧状态、重试转换或在并发代码中共享它。


十一、ClassVarInitVar 与真正的字段

并不是所有带标注的类变量都会成为实例字段。

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() 默认允许 NaNInfinity-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"

优点是不会自动等于字符串或整数,能够迫使边界代码明确转换。

使用 StrEnumIntEnum

适合需要兼容既有字符串或整数 API 的场景:

class HttpMethod(StrEnum):
    GET = "GET"
    POST = "POST"

但应记住:它们会参与底层 strint 的比较和操作,类型隔离能力弱于普通 Enum


十五、最终检查模型是否正确

设计一个 dataclass + Enum 模型时,至少应逐项回答:

  1. 哪些字段是模型的真正业务身份?
  2. 哪些字段只用于日志、缓存或追踪,是否应 compare=False
  3. 对象是否需要 frozen=True
  4. 冻结字段中是否仍包含列表、字典或其他可变对象?
  5. 对象是否要作为字典键或集合成员?
  6. 如果需要哈希,参与哈希的字段是否稳定?
  7. 枚举对外暴露 .name 还是 .value
  8. 未知枚举值在反序列化时应该失败、映射到 UNKNOWN,还是保留原始字符串?
  9. dataclass 是否只是结构描述,还是还需要 __post_init__ 维护不变量?
  10. JSON 转换是否可逆,是否明确处理嵌套 dataclass、Enum、日期、Decimal 和非字符串字典键?

dataclass 解决的是“怎样表达对象字段并减少样板代码”,Enum 解决的是“怎样表达有限的符号身份”,frozen 解决的是“怎样禁止常规属性重新绑定”,而序列化解决的是“怎样把内存对象映射到外部协议”。只有把这四个问题分开,模型的比较、哈希、状态转换和跨边界传输才会保持一致。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。