引言
Python 的 tuple 很好用,它可以让我们快速地将不同类型的值封装在一起,作为一个整体进行管理,自带的排序规则也十分直观,简单易用。但实际用下来我发现,一旦 tuple 的字段比较多,我就被迫要自己写一下注释注明一下不同位置的字段的具体含义是啥,比如
t = (3, 4, 3.5) # (x, y, value)
后面我就可以跳到这个定义的地方通过看注释来弄清楚每个字段的含义。但这确实带来了诸多不便,代码一旦变长就不大好定位该行语句
于是想说可以写一个类,这样我就可以「用名字而不是位置」去引用某个字段,但是写一个类要自己重写 __init__、__repr__等,这里面很多代码都是冗余的。今天要讲的 dataclass就是要解决这个问题,不用写很多冗余的代码,就可以起到一样的效果,即实现一个 Named Tuple
Tip
学习任何一个语言的特性只需要弄清楚几个问题
语法
from dataclasses import dataclass@dataclassclassPoint: x: int y: int value: float = 0.0
上面是定义了一个 Point的例子,它包含 3 个字段,坐标 (x, y)和该坐标对应的值 value(默认值是 0)。从例子中可以看出
- •
dataclass是一个 Python 装饰器,用来装饰一个类 - • 对于类的实例变量,必须加上类型提示,并且允许设置默认值
语义
那么这样的一个类有啥功能呢?
... # omittedfoo = Point(3, 4, 3.5) # __init__bar = Point(3, 4, 3.5) # __init__print(foo) # __repr__print(foo.x) # named referenceprint(foo == bar) # __eq__
可以看到,@dataclass自动帮我们实现了 __init__, __repr__, __eq__,并且我们可以用名字去引用某个字段的值
从原理上来看,加上类型提示的字段会被存储在类的 __annotations__属性里面(按照声明的顺序)
print(Point.__annotations__)# {'x': <class 'int'>, 'y': <class 'int'>, 'value': <class 'float'>}
所以总结来说,语义是这样的:
- 1. 根据声明的字段自动生成
__init__, __repr__, __eq__方法,如果类本身已经有了这些方法,则优先用类自己定义的 - 2. 带有类型提示的字段会成为上述生成的方法的参数,参数的顺序就是字段声明的顺序
以 __init__为例,上面的类其实会生成下面的 __init__方法
classPoint: ...def__init__(self, x: int, y: int, value: float = 0.0):self.x = xself.y = yself.value = value
高级用法
上面的使用已经足以覆盖大多数使用场景,但 dataclass其实提供了更多,它允许我们控制装饰类的过程甚至是每个字段的行为,这是不同粒度的控制,一个是针对整体,一个是针对字段
@dataclass本身是一个装饰器,装饰器就是函数,我们可以通过控制函数的参数来控制装饰类的过程,我把几个比较重要的配置项罗列在下面,同时我给出了这些配置项的默认值
@dataclass( init=True, # generate __init__ methodrepr=True, # generate __repr__ method# default format: <classname>(field1=..., field2=..., ...) eq=True, # compare dataclasses like tuples order=False, # generate __lt/lt/gt/ge__ methods frozen=False, # if True, assigning to fields will generate an exception)
以 order为例,比如我们希望刚才的 Point类是可以比较的:先按照坐标 (x, y)然后按照 value比较,我们可以这么写代码
from dataclasses import dataclass@dataclass(order=True)classPoint: x: int y: int value: floatfoo = Point(3, 4, 3.5) # __init__bar = Point(3, 4, 4.5) # __init__print(foo < bar) # __eq__
datclass的 field是用来控制每个字段的行为的,同样有很多可以定制的选项,完整的可以参考 这里,我下面只讲一下最重要的几个
- •
defalt, default_factory,这两个是用来给字段设置默认值的,前置直接设置默认值,后者则可以指定了一个不带有参数的构造函数(比如 list, set等都是可以的),根据自己的需要选择其中一个即可 - •
repr,是否要将这个字段放到 __repr__里面
仍然用前面的 Point作为例子,我们想要把 value变成 values,也就是每个字段可以有一堆的值,那么我们可以这么写
from dataclasses import dataclass, field@dataclassclassPoint: x: int y: int value: list[float] = field(default_factory=list)foo = Point(3, 4, [3.5, 4.5, 5.5]) # __init__print(foo.__annotations__)
最后再谈一下涉及到继承的场景,被 @dataclass装饰的类也是类,也能用于继承,那么当一个 data class 继承另外一个 data class 会发生什么呢?特别是有相同名字的字段的场景,比如
from dataclasses import dataclass@dataclassclassA: x: int = 1 y: int = 2 z: int = 5@dataclassclassB(A): x: int = 3 y: int = 4foo = B()print(foo)# B(x=3, y=4, z=5)
继承的原理是这样的 [^1]
- 1. 检查被装饰类的所有父类(按照 MRO的逆序),也就是从
Object开始一路沿着子类收集 fields - 2. 最后加上被装饰类自己的 fields,这样就完成了fields 的合并。注意这里可能出现同名的 field 这种情况,后面的会覆盖前面的
FAQ
dataclass 如何区分类变量和实例变量?
用类型提示区分类变量和实例变量,类变量的类型是 typing.ClassVar
from typing import ClassVarfrom dataclasses import dataclass, field@dataclassclassPoint: x: int y: int value: list[float] = field(default_factory=list) a_class_variable: ClassVar[int] = 3a_point = Point(3, 4)print(Point.a_class_variable)
vs namedtuple
参考自 [^1]
| @dataclass | collections.namedtuple |
字段值一样但不同类型是否看作相等(Point3D(2017, 6, 2) == Date(2017, 6, 2)) | | |
| | |
控制每个字段的行为(__init__, __repr__, etc) | | |
| | |
总结
Python 的 @dataclass让我们可以用 declarative的形式描述一个类,我们只需要描述每个字段的类型、默认值等,他就会帮我们自动生成有用的一些函数,可以理解为 data class = mutable namedtuple with default value👍
[^1]: PEP 557 – Data Classes