28、面向对象与数据类:dataclasses模块详解,NamedTuple,不可变对象设计
说到数据类,我得先坦白一件事。
早些年写 Python,我经常为了一个简单的数据容器,写一堆 __init__、__repr__、__eq__ 方法。说实话,又臭又长。后来 dataclasses 一出来,我整个人都清爽了。今天我们就来聊聊这个模块,顺便把 NamedTuple 和不可变对象设计也一并讲透。
为什么需要数据类?
你想想看,一个普通的类,如果只是用来存数据,你得写多少样板代码?
class Person:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def __repr__(self):
return f"Person(name={self.name!r}, age={self.age!r})"
def __eq__(self, other):
if not isinstance(other, Person):
return NotImplemented
return self.name == other.name and self.age == other.age
这才三个字段,代码已经快20行了。要是字段再多一点,你写起来不烦,我看都看累了。
dataclasses 就是来解决这个问题的。它用装饰器的方式,自动帮你生成 __init__、__repr__、__eq__ 等方法。说白了,你只需要声明字段,剩下的交给它。
核心思想:数据类就是专门用来存储数据的类。它不关心行为,只关心数据本身。
dataclasses 模块详解
先看一个最简单的例子:
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
就这两行,上面那一大堆代码的功能全有了。你可以直接 Person("张三", 25) 创建对象,打印出来也是清晰的格式,两个对象也能直接比较相等。
我个人习惯,只要一个类的主要职责是「装数据」,我就会用 @dataclass。它让代码变得非常干净。
常用参数
@dataclass 装饰器有几个参数,我挑最常用的说:
| 参数 | 默认值 | 说明 |
|---|---|---|
init |
True | 是否生成 __init__ |
repr |
True | 是否生成 __repr__ |
eq |
True | 是否生成 __eq__ |
order |
False | 是否生成比较方法(<, > 等) |
frozen |
False | 是否创建不可变对象 |
举个例子,如果你想让对象支持排序:
@dataclass(order=True)
class Student:
name: str
score: int
s1 = Student("小明", 90)
s2 = Student("小红", 85)
print(s1 > s2) # True
字段的默认值与类型
字段可以设置默认值,也可以使用 field 函数做更精细的控制:
from dataclasses import dataclass, field
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
tags: list = field(default_factory=list) # 避免可变默认值陷阱
注意:不要用 tags: list = [] 这种写法。所有实例会共享同一个列表,这是 Python 的一个经典陷阱。用 default_factory=list 才是正确做法。
我曾经在项目里见过有人因为这个 bug 排查了一整天。两个不同的配置对象,改了其中一个的 tags,另一个也跟着变了。嗯,这种坑踩过一次就记住了。
NamedTuple:轻量级数据容器
如果你只需要一个简单的、不可变的数据结构,NamedTuple 可能比 dataclass 更合适。
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(3.0, 4.0)
print(p.x, p.y) # 3.0 4.0
print(p[0]) # 3.0 也支持索引访问
NamedTuple 本质上是元组的子类。它既有元组的轻量和不可变性,又有关键字访问的便利性。
| 特性 | dataclass |
NamedTuple |
|---|---|---|
| 可变性 | 默认可变,可设置 frozen=True |
不可变 |
| 性能 | 普通类性能 | 接近元组,更轻量 |
| 继承 | 支持 | 有限支持 |
| 方法定义 | 可以定义任意方法 | 可以定义,但较少用 |
| 索引访问 | 不支持 | 支持 |
我个人建议:如果数据量很大,或者你需要频繁创建和销毁对象,用 NamedTuple。如果数据类需要一些行为(比如方法、属性),用 dataclass。
不可变对象设计
为什么要设计不可变对象?
说白了,就是为了安全。一个对象一旦创建,它的状态就不能再改变。这在多线程环境下特别有用——你不用担心一个线程在修改数据,另一个线程在读数据,结果读到一半的数据。
在 dataclasses 中,设置 frozen=True 就能得到不可变对象:
@dataclass(frozen=True)
class ImmutablePoint:
x: float
y: float
p = ImmutablePoint(1.0, 2.0)
# p.x = 3.0 # 会报错:FrozenInstanceError
但是要注意,frozen 只保证字段引用不可变。如果字段本身是可变对象(比如列表),那内容还是可以改的:
@dataclass(frozen=True)
class Team:
name: str
members: list
t = Team("开发组", ["张三"])
t.members.append("李四") # 不会报错!列表内容变了
技巧:要实现真正的深度不可变,字段类型也要用不可变类型。比如用 tuple 代替 list,用 frozenset 代替 set。
知识体系结构图
下面这张图梳理了本章的核心内容:
实战建议
最后,我分享几个实际项目中的经验:
- 配置类用
dataclass:配置通常需要可变,而且字段多,dataclass的默认值功能很方便。 - 数据传输对象用
NamedTuple:比如 API 返回的数据结构,不可变更安全,而且可以解包。 - 多线程共享数据用不可变对象:设置
frozen=True,配合tuple、frozenset等不可变类型。 - 不要滥用:如果一个类有复杂的行为逻辑,还是老老实实写普通类吧。数据类只适合「装数据」的场景。
一句话总结:数据类让你的代码更简洁,不可变对象让你的代码更安全。两者结合,事半功倍。
好了,这一章就到这里。记住,工具是为人服务的,别为了用而用。选最适合你场景的方案,才是正道。