初识 Python3.7 的 dataclasses 标准库

初识 Python3.7 的 dataclasses 标准库

最近在进行一个新的后端项目时想初步应用一下领域驱动设计的思想。DDD 开发需要对一个领域对象进行各种操作,而不是把业务数据包在dict里在 action 层,repo 层中传来传去。如何方便高效地定义实体类成为一个重要前提。Python 3.7 版本引入的新标准库 dataclasses 可以帮助我们解决这个问题。

1. dataclasses 的简介和使用

dataclasses 的官方介绍是:

This module provides a decorator and functions for automatically adding generated special methods such as init() and repr() to user-defined classes. It was originally described in PEP 557.

使用 dataclasses 我们可以很方便地利用类型注解类定义数据类。

from dataclasses import dataclass  @dataclass  class InventoryItem:      '''Class for keeping track of an item in inventory.'''      name: str      unit_price: float      quantity_on_hand: int = 0        def total_cost(self) -> float:          return self.unit_price * self.quantity_on_hand

使用了dataclass函数装饰器装饰InventoryItem类后,会生成__init__方法:

def __init__(self, name: str, unit_price: float, quantity_on_hand: int=0):      self.name = name      self.unit_price = unit_price      self.quantity_on_hand = quantity_on_hand

然后就可以直接实例化InventoryItem对象InventoryItem('apple',1,10),InventoryItem('banana',1,quantity_on_hand=10)

实际上dataclass函数是有很多参数的:

@dataclasses.dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)

这里介绍一下比较常用的参数:

  • init:是否生成__init__方法,如果用户手动定义的__init__方法,这个属性会被忽略。
  • repr:是否生成__repr__方法。
  • eq:是否生成__eq__方法以用'=='比较不同实例,会按定义顺序比较对象中的字段。
  • order:是否生成lt,gt等方法用于比较不同实例。
  • frozen:默认为 False,设置为 True 的话对这个类实例的字段进行复制会抛出异常,相当于定义一个不可变对象

上文定义的InventoryItem类没有指定参数,全部是默认参数,具体的表现行为如下:

In [1]: from dataclasses import dataclass     ...: @dataclass     ...: class InventoryItem:     ...:     '''Class for keeping track of an item in inventory.'''     ...:     name: str     ...:     unit_price: float     ...:     quantity_on_hand: int = 0     ...:     ...:     def total_cost(self) -> float:     ...:         return self.unit_price * self.quantity_on_hand     ...:    In [2]: a = InventoryItem('a',1,10) # 定义了__init__方法    In [3]: b = InventoryItem('b',2,5)    In [4]: a # 定义了__repr__方法  Out[4]: InventoryItem(name='a', unit_price=1, quantity_on_hand=10)    In [5]: a == b # 定义了__eq__方法  Out[5]: False    In [6]: a == InventoryItem('a',1,10)  Out[6]: True    In [7]: a < b # 没有定义'__lt__'f方法  ---------------------------------------------------------------------------  TypeError                                 Traceback (most recent call last)  <ipython-input-7-c41276b34900> in <module>  ----> 1 a < b # 没有定义'__lt__'方法    TypeError: '<' not supported between instances of 'InventoryItem' and 'InventoryItem'    In [8]: a.name = 'aa' # forzen 属性为False,可以修改字段    In [9]: a  Out[9]: InventoryItem(name='aa', unit_price=1, quantity_on_hand=10)

2. 使用dataclasses.field修饰字段

事实上我们不仅可以通过对 dataclass 的参数定义数据类的整体表现,也可以指定具体字段的行为,只需要用到dataclasses.field函数。

dataclasses.field(*, default=MISSING, default_factory=MISSING,   repr=True, hash=None, init=True, compare=True, metadata=None)

介绍一下field函数的常用参数:

  • default: 设置默认值
  • default_factory: 设置默认工厂函数
  • repr: 在__repr__方法中是否展示这个字段
  • init: 在__init__方法中是否需要初始化这个字段

其中default_factory属性在一些情况下作用相当大。例如我们定义如下数据类:

@dataclass  class A:      nums:list = []

如果一切正常的话我们实例化两个对象ab,然后a.nums.append(1)b.nums.append(2),此时a.nums == b.nums == [1, 2],因为a.numb.num实际指向的都是定义A时初始化过的那个空列表。

好在上述代码是无法运行的,会抛出异常ValueError: mutable default <class 'list'> for field nums is not allowed: use default_factory。按照提示,我们应该制定nums的默认工厂函数:

In [1]: from dataclasses import dataclass,field    In [2]: @dataclass     ...: class A:     ...:     nums:list = field(default_factory=list)     ...:    In [3]: a = A()    In [4]: b = A()    In [5]: a.nums.append(1)    In [6]: b.nums.append(2)    In [7]: a.nums, b.nums  Out[7]: ([1], [2])

我们指定了nums字段的默认工厂函数是list,每次实例化对象的时候,都会重新调用一次list方法生成一个新的空列表给nums,从而符合我们的预期。

3. 使用dataclasses.asdict转换对象到dict

我们经常会遇到需要持久化复杂数据对象的情况,比如存到数据库或者转化为json输出到前端。如果能方便的将对象转换成dict的话会很大的提高开发效率,幸运的是标准库提供了dataclasses.asdict函数。

我们来定义一个稍微复杂一点的数据类

@dataclass  class A:      a:int    @dataclass  class B:      name:str      a_list:List[A]

如果要手动把B类型对象转化为dict的话,我们大概要这样做:

def to_dict(self):      return {'name':self.name, 'a_list':[{'a':x.a} for x in self.a_list]}

这样的to_dict方法偶尔实现一次两次倒也罢了,如果每个数据类都要手动实现一个to_dict方法的话就太过浪费时间和精力了。使用dataclasses.asdict方法可以极大地提高我们的效率。

In [1]: from dataclasses import dataclass,asdict    In [2]: from typing import List    In [3]: @dataclass     ...: class A:     ...:     a:int     ...:     ...: @dataclass     ...: class B:     ...:     name:str     ...:     a_list:List[A]     ...:    In [4]: b = B(name='b',a_list=[A(1),A(2),A(3)])    In [5]: b  Out[5]: B(name='b', a_list=[A(a=1), A(a=2), A(a=3)])    In [6]: asdict(b)  Out[6]: {'name': 'b', 'a_list': [{'a': 1}, {'a': 2}, {'a': 3}]}

4. 使用__post_init__方法进行一些初始化操作

使用dataclass装饰的类一个主要优势就是不用手动去实现__init__方法,但我们经常需要在对象初始化的时候对一些数据进行校验或者额外操作,此时一个选择是手动实现__init__方法,其中会有大段的模板代码例如self.a=a;self.b=b,另一个选择是定义__post_init__方法来进行初始化操作,例如:

In [1]: from dataclasses import dataclass    In [2]: @dataclass     ...: class Person:     ...:     name:str     ...:     age:int     ...:     ...:     def __post_init__(self):     ...:         if self.age < 0:     ...:             raise ValueError('Age < 0')     ...:    In [3]: Person('Mike',-1)  ---------------------------------------------------------------------------  ValueError                                Traceback (most recent call last)  <ipython-input-3-4b4379de6be2> in <module>  ----> 1 Person('Mike',-1)    <string> in __init__(self, name, age)    <ipython-input-2-1e8016803a1d> in __post_init__(self)        6     def __post_init__(self):        7         if self.age < 0:  ----> 8             raise ValueError('Age < 0')        9    ValueError: Age < 0

总结

今天向大家介绍了 Python 3.7 中dataclasses标准库的简单使用。

作为一个以灵活著称的编程语言,我们使用 Python 处理结构化数据的时候经常会使用dict在不同模块间传来传去,然后在需要的地方进行数据的校验和格式转换,无意间会增加很多相似的代码,真正的核心逻辑淹没在这些校验和转换过程中,单元测试的复杂度也随之提高。

利用新版本 Python 的类型提示和 dataclasses 标准库,配合一些开发工具(mypy,pylint 等)我们可以较为放心地将数据对象在不同模块方法间传递使用,有助于改进 Python 在大型项目下的开发效率和安全性。