Python 里最容易被低估的库:dataclasses 不只是数据容器

2026-08-10 0 227

上个月重构一个用户服务,里面有各种各样的配置结构体,有构造函数接收七个参数排序特别容易混的,有需要深拷贝防止外部修改的,还有一堆比大小的。一打开代码文件,几百行全是 __init____repr____eq__ 这种样板,看着就烦。

后来同事推荐我试试 dataclasses,我说这不就是个生成一堆方法的装饰器嘛,早就听说过了。结果我耐下心把几个核心类改完,发现它真的被低估了。不光是省代码,关键是它能让我把数据的形状直接写在类上,代码可读性提了一个档次。

事情得从这坨旧代码说起

原来的代码长这样:

class Point:
    def __init__(self, x, y, z):
        self.x = x
        self.y = self._validate_y(y)
        self.z = z
        self._color = None
        self._created_at = None
        self._tag = 'default'
    
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y}, z={self.z})"
    
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y, self.z) == (other.x, other.y, other.z)
    
    def _validate_y(self, val):
        if val < 0:
            raise ValueError('y 不能是负数')
        return val

每次新增一个字段,就要同步修改 __init____repr____eq__。有时候漏改一个字段,整个判断逻辑就是错的,而且这种错误在测试阶段还不容易发现,非常阴间。

dataclasses 重写以后:

from dataclasses import dataclass, field, InitVar

@dataclass
class Point:
    x: float
    y: float
    z: float
    
    tag: str = 'default'
    color: str = field(default=None, repr=False)
    
    # 注意这个字段我们不直接存储
    created_at: InitVar[float] = None
    
    def __post_init__(self, created_at):
        if self.y < 0:
            raise ValueError('y 不能是负数')
        if created_at is not None:
            self._created_at = created_at
        else:
            import time
            self._created_at = time.time()
    
    def is_origin(self):
        return self.x == self.y == self.z == 0

构造的时候 Point(1.0, 2.0, 3.0),打印进控制台自动有清晰的格式,两个 Point 对象可以直接比较内容,不需要写一堆方法。最爽的是,以后增加一个 scale 字段,只需要在类里加一个类型注解,其他的方法全部自动生成。

field 参数才是真正的隐藏武器

很多人只知道 dataclass 可以帮你生成 __init__,但不知道 field() 里藏着这么多控制选项。

我平时用得最多的几个:

  • default_factory:处理可变默认值。
  • repr=False:有些字段(比如密码hash)不想在日志里暴露。
  • compare=False:有些字段(比如内部自增id)不应该参与对象比较。
  • init=False:字段不通过构造函数传入。

举个例子,一个用户类:

@dataclass
class User:
    name: str
    email: str
    password_hash: str = field(repr=False)
    login_count: int = field(default=0, compare=False)
    age: int = field(default=0, init=False)
    
    def __post_init__(self):
        self.age = 18

这样在日志里不会暴露密码,比较用户对象时也不会因为登录次数不同而认为不是同一个人,同时 age 只能用内部逻辑设置,外部无法乱传。

field(default_factory=list) 这个几乎是所有 Python 程序员都踩过可变默认参数的坑:

@dataclass
class Order:
    items: list = field(default_factory=list)

再也不怕多个订单共享同一个列表了。

不做序列化可惜了:搭配 dataclasses.asdict

写接口时经常需要把 dataclass 对象转成字典给 json.dumps 用。标准库提供了 dataclasses.asdict,但它递归转换,很实用。

from dataclasses import asdict

@dataclass
class Config:
    host: str
    port: int
    tags: list = field(default_factory=list)

cfg = Config('localhost', 8080, ['service', 'api'])
payload = asdict(cfg)
# {'host': 'localhost', 'port': 8080, 'tags': ['service', 'api']}

但这个函数有个坑:它只会转换基础类型和嵌套 dataclass,如果里面有 lambda 或自定义类,可能会出问题。所以我的建议是,如果在业务代码中用 asdict,一定要确保所有字段都是可序列化的。否则就自己写一个 to_dict() 方法。

继承和组合:dataclass 也能优雅扩展

当你有几个数据模型拥有同样的公共字段时,用继承可以省事不少:

@dataclass
class BaseMessage:
    id: int
    timestamp: float

@dataclass
class TextMessage(BaseMessage):
    content: str

@dataclass
class ImageMessage(BaseMessage):
    url: str
    width: int = 0
    height: int = 0

两个子类都自动继承了 idtimestamp,而且它们的 __eq__ 也会自动比较父类字段,完全就是我自己想要的样子。

但有一个奇葩规则:如果父类有默认值,子类中无默认值的字段必须放在有默认值字段的前面,否则报错。比如:

@dataclass
class Test(BaseMessage):
    content: str   # 想加一个必填字段,但父类里 id 和 timestamp 没有默认值,这样可以吗?
    # 实际上 OK,因为 id 和 timestamp 都没有默认值, content 也没有,都是必填,顺序无所谓
    pass

# 但如果父类有默认值,子里出现无默认值就会出错:
@dataclass
class Base:
    x: int
    y: int = 0

@dataclass
class Child(Base):
    z: int  # 这行会报错,因为 z 没有默认值,而 y 有默认值,顺序冲突

碰到这个情况就把 base 类里带默认值的字段全放后面,或者用 field(default=None) 解决。总之踩过一次你就长记性了。

不亚于 namedtuple 的轻量场景

有人说 namedtuple 也能做同样的事情,但 dataclass 类型注解更清晰,并且可以写方法。唯一我觉得 namedtuple 更好的地方在于它可以当作元组用,比如解包 a,b=point,而 dataclass 不支持。但说实话,这种操作在业务代码里很少,反而是字段名更常用。

如果你只是想要一个不可变对象,可以用 frozen=True

@dataclass(frozen=True)
class Color:
    r: int
    g: int
    b: int

c = Color(255, 0, 0)
c.r = 10  # 报错:FrozenInstanceError

对哈希操作也友好,能直接放在 set 里当字典 key。不过注意,如果你希望对象可哈希,最好不要加可变字段(比如 list),不然哈希结果可能不稳定。

差点忘了 InitVar:初始化时需要经过计算的字段

InitVar 是一个比较少见但很有用的特性。它表示这个参数只在 __init__ 里接收,但不会被保存为对象字段。我经常用它来接受时间戳(由外部传入),然后在 __post_init__ 里加工成 datetime 对象。

from datetime import datetime, timezone
from dataclasses import dataclass, InitVar

@dataclass
class Event:
    name: str
    unix_ts: InitVar[int]
    happens_at: datetime = None

    def __post_init__(self, unix_ts):
        self.happens_at = datetime.fromtimestamp(unix_ts, tz=timezone.utc)

ev = Event('test', 1710000000)
print(ev.happens_at)  # 自动转换

直接在字段类型注解里写 datetime,外部传一个 int,经过转换后存成 datetime,这个模式很符合“参数合理化”的场景。

搭配 match-case:让数据校验更顺滑

Python 3.10 以后的 match-case 和 dataclass 配合起来可以写很干净的解构判断:

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Square:
    side: float

def area(shape):
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Rectangle(width=w, height=h):
            return w * h
        case Square(side=s):
            return s * s
        case _:
            return 0

对于多类型数据处理,比如消息路由,这种写法比 if isinstance 清爽得多。因为你直接通过 dataclass 的类型和属性去匹配,不需要先取字段再判断格式。

写一个好用的 __post_init__

这个方法是我在项目中使用频率最高的。它会在 __init__ 自动生成后被调用,你可以在这里做校验、派生字段、规范化输入。

@dataclass
class Money:
    amount: float
    currency: str = 'CNY'

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("金额不能为负")
        if self.currency.upper() not in ('CNY', 'USD', 'EUR'):
            raise ValueError("不支持的货币类型")
        self.currency = self.currency.upper()

注意,在 __post_init__ 里可以直接修改字段。甚至连字段的类型都可以修改,只要你不怕静态检查痛苦。我一般只做字符串格式化和范围校验。

有些时候你需要根据一个字段的值动态生成另一个字段,比如昵称默认和用户名相同:

@dataclass
class Profile:
    username: str
    display_name: str = None
    
    def __post_init__(self):
        if self.display_name is None:
            self.display_name = self.username

比写一堆 if 简洁。

在项目里到底该怎么用不显得过度加工

很多朋友刚开始用 dataclasses 会有一个误区:所有类都加上 @dataclass。其实那些有复杂行为、有业务逻辑的类不一定适合,比如订单有状态流转,还是普通类加方法更直观。dataclass 最适合的是那些“没有行为的数据载体”,像传输对象 DTO、值对象、配置对象、查询结果。

我会把项目里的 response modelform data 都改成 dataclass,每个字段一改,IDE 会提示所有使用的地方。有一些地方需要用到字典,直接用 asdict 转换,不用再手写 params = {'a': self.a, 'b': self.b}

如果你担心 dataclass 性能,放心,它就是普通类加上自动生成的方法,不会慢到哪去。可能比手写方法多一点点处理,但可维护性是完全值得的。

我把以前那个用户服务重构完后,删了差不多一半的样板代码。最关键的是,我好几个星期没有因为“构造函数参数顺序传错”这种低级问题来回去翻了。

最后来个小例子:配置管理

配置文件加载场景特别好用。以前写解析代码要维护一堆字段,现在把 dataclass 和 yamljson 结合起来,代码缩到最短。

import json
from dataclasses import dataclass, field

@dataclass
class DatabaseConfig:
    host: str
    port: int = 3306
    username: str = 'root'
    password: str = field(default='', repr=False)
    pool_size: int = field(default=5, compare=False)

    @classmethod
    def from_dict(cls, data):
        return cls(
            host=data.get('host', 'localhost'),
            port=data.get('port', 3306),
            username=data.get('username', 'root'),
            password=data.get('password', ''),
            pool_size=data.get('pool_size', 5),
        )

config = DatabaseConfig.from_dict(json.load(open('config.json')))
print(config)

任何结构式数据都可以套用这个模式:先用 dataclass 定义字段,再写一个 from_dict 类方法做映射。数据的默认值、校验逻辑都集中在 dataclass 里,而不是散落在各处。

写在最后

说句实话,dataclasses 并不是一个新技术,但很多人只停留在用它生成 __init__ 的层面。真正把它用活,是在重构的时候你会发现,能用一张类型注解表代替一堆模板方法,简直舒服到不行。

如果你还没试过,可以现在打开你手头的一个简单的类,把它改成 @dataclass,然后把 __init____repr____eq__ 全删掉,看看代码变短了多少。放心,不会破坏任何东西,反而会让代码更清爽。

Python 里最容易被低估的库:dataclasses 不只是数据容器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python 里最容易被低估的库:dataclasses 不只是数据容器 https://www.taomawang.com/server/python/2518.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务