@dataclass 简明教程(Python3.7)

Python 的 tuple 很好用,它可以让我们快速地将不同类型的值封装在一起,作为一个整体进行管理,自带的排序规则也十分直观,简单易用。但实际用下来我发现,一旦 tuple 的字段比较多,我就被迫要自己写一下注释注明一下不同位置的字段的具体含义是啥,比如

python

t = (3, 4, 3.5)  # (x, y, value)

后面我就可以跳到这个定义的地方通过看注释来弄清楚每个字段的含义。但这确实带来了诸多不便,代码一旦变长就不大好定位该行语句

于是想说可以写一个类,这样我就可以「用名字而不是位置」去引用某个字段,但是写一个类要自己重写 __init____repr__ 等,这里面很多代码都是冗余的。今天要讲的 dataclass 就是要解决这个问题,不用写很多冗余的代码,就可以起到一样的效果,即实现一个 Named Tuple

技巧

学习任何一个语言的特性只需要弄清楚几个问题

  • 语法是什么?
  • 语义是什么?
  • 有哪些适合的场景

python

from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int
    value: float = 0.0

上面是定义了一个 Point 的例子,它包含 3 个字段,坐标 (x, y) 和该坐标对应的值 value(默认值是 0)。从例子中可以看出

  • dataclass 是一个 Python 装饰器,用来装饰一个类
  • 对于类的实例变量,必须加上类型提示,并且允许设置默认值

那么这样的一个类有啥功能呢?

python

... # omitted
foo = Point(3, 4, 3.5)  # __init__
bar = Point(3, 4, 3.5)  # __init__
print(foo)  # __repr__
print(foo.x)  # named reference
print(foo == bar)  # __eq__

可以看到,@dataclass 自动帮我们实现了 __init__, __repr__, __eq__,并且我们可以用名字去引用某个字段的值


从原理上来看,加上类型提示的字段会被存储在类的 __annotations__ 属性里面(按照声明的顺序

python

print(Point.__annotations__)
# {'x': <class 'int'>, 'y': <class 'int'>, 'value': <class 'float'>}

所以总结来说,语义是这样的:

  1. 根据声明的字段自动生成 __init__, __repr__, __eq__ 方法,如果类本身已经有了这些方法,则优先用类自己定义的
  2. 带有类型提示的字段会成为上述生成的方法的参数,参数的顺序就是字段声明的顺序

__init__ 为例,上面的类其实会生成下面的 __init__ 方法

python

class Point:
    ...
    def __init__(self, x: int, y: int, value: float = 0.0):
        self.x = x
        self.y = y
        self.value = value

上面的使用已经足以覆盖大多数使用场景,但 dataclass 其实提供了更多,它允许我们控制装饰类的过程甚至是每个字段的行为,这是不同粒度的控制,一个是针对整体,一个是针对字段

@dataclass 本身是一个装饰器,装饰器就是函数,我们可以通过控制函数的参数来控制装饰类的过程,我把几个比较重要的配置项罗列在下面,同时我给出了这些配置项的默认值

python

@dataclass(
    init=true,      # generate __init__ method
    repr=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 比较,我们可以这么写代码

python

from dataclasses import dataclass


@dataclass(order=True)
class Point:
    x: int
    y: int
    value: float


foo = Point(3, 4, 3.5)  # __init__
bar = Point(3, 4, 4.5)  # __init__
print(foo < bar)  # __eq__

datclassfield 是用来控制每个字段的行为的,同样有很多可以定制的选项,完整的可以参考 这里,我下面只讲一下最重要的几个

  • defalt, default_factory,这两个是用来给字段设置默认值的,前置直接设置默认值,后者则可以指定了一个不带有参数的构造函数(比如 list, set 等都是可以的),根据自己的需要选择其中一个即可
  • repr,是否要将这个字段放到 __repr__ 里面

仍然用前面的 Point 作为例子,我们想要把 value 变成 values,也就是每个字段可以有一堆的值,那么我们可以这么写

python

from dataclasses import dataclass, field


@dataclass
class Point:
    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 会发生什么呢?特别是有相同名字的字段的场景,比如

python

from dataclasses import dataclass


@dataclass
class A:
    x: int = 1
    y: int = 2
    z: int = 5


@dataclass
class B(A):
    x: int = 3
    y: int = 4


foo = B()
print(foo)
# B(x=3, y=4, z=5)

继承的原理是这样的 1

  1. 检查被装饰类的所有父类(按照 MRO逆序),也就是从 Object 开始一路沿着子类收集 fields
  2. 最后加上被装饰类自己的 fields,这样就完成了fields 的合并。注意这里可能出现同名的 field 这种情况,后面的会覆盖前面的

用类型提示区分类变量和实例变量,类变量的类型是 typing.ClassVar

python

from typing import ClassVar
from dataclasses import dataclass, field


@dataclass
class Point:
    x: int
    y: int
    value: list[float] = field(default_factory=list)
    a_class_variable: ClassVar[int] = 3


a_point = Point(3, 4)
print(Point.a_class_variable)

参考自 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 👍