Python 3.7 Dataclasses 简介

Python 3.7 的 dataclass 减少了类定义中的重复代码。

Python 新手常常惊讶于完成大量工作所需的代码如此之少。强大的内置数据结构可以完成您需要的许多工作,推导式可以处理许多涉及可迭代对象的任务,并且类定义中缺少 getter 和 setter 方法,难怪 Python 程序往往比静态编译语言的程序更短。

然而,当人们开始在 Python 中定义类时,这种惊讶往往就结束了。诚然,类定义通常会非常简短。但是 __init__ 方法(它向新对象添加属性)往往相当冗长和重复——例如


class Book(object):
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

让我们忽略对使用 self 的需求,这是 Python 中 LEGB(局部、封闭、全局、内置)作用域规则的产物,并且不会消失。我们还要注意,参数 titleauthorprice 与属性 self.titleself.authorself.price 之间存在天壤之别。

新手经常想知道——在我的教学班上,他们经常大声地想知道——为什么你需要进行这些赋值。毕竟,__init__ 不能理解这三个非 self 参数旨在作为属性分配给 self 吗?如果 Python 如此智能,为什么不为您做这件事呢?

多年来,我对这个问题给出了几个答案。其中之一是 Python 试图使一切都显式化,以便您可以看到正在发生的事情。让属性自动、幕后赋值会违反这一原则。

在某个时候,我实际上提出了一个半生不熟的解决方案来解决这个问题,尽管我明确表示它不符合 Python 风格,因此不适合进行更认真的实现。在一篇博文“让 Python 的 __init__ 方法具有魔力”中,我建议您可以使用继承和内省的组合自动将参数分配给属性。这只是一个思想实验,而不是一个真正的提议。然而,尽管我心存疑虑并且实现非常简陋,但不必编写相同的样板 __init__ 方法(将参数相同地分配给属性)仍然具有吸引力。

快进到 2018 年。在我写这篇文章时,Python 3.7 即将发布。事实证明,这个新版本的一个亮点是“dataclasses”——一种编写类的方式,无需编写样板代码。该实现以与我提出的方式截然不同(且更好)的方式完成,并且它包含了我甚至没有想到的许多功能。然而,我预计对于许多人来说,dataclass 将成为他们创建 Python 类的首选方式。

因此,在本文中,我回顾了 Python 3.7 中的新 dataclass 功能。如果您在 3.7 发布之前阅读本文,我建议您下载并安装它,尽管不要将其作为您的主要生产 Python 版本,以防在第一个生产版本发布之前出现问题。

简单 Dataclasses

让我们以上面的类为例


class Book(object):
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

以下是如何将其转换为 dataclass


from dataclasses import dataclass

@dataclass
class Book(object):
    title : str
    author : str
    price : float

如果您有任何 Python 经验,您都可以识别这里正在发生的事情的轮廓,但很多事情都不同了。

首先是使用 dataclass 装饰器来修改类定义。装饰器是 Python 最强大的工具之一,允许您在函数和类定义时以及调用时对其进行修改。在这种情况下,装饰器会检查类定义,然后根据该定义动态编写 __init__ 和其他方法。

接下来,您会注意到没有定义 __init__,也没有定义任何其他方法。相反,定义的是似乎是类属性的东西。但话又说回来,它们并不是真正的类属性,因为它们缺少任何值。那么它们在做什么呢?

此外,这些类属性可能没有任何关联的值,但存在类型,使用 Python 3 中引入的类型注解语法。类型注解允许您使用特定对象标记变量。注解不会被 Python 使用或强制执行,但您的编辑器或外部程序(例如 MyPy)可以使用它们来提高代码的准确性。您不必坚持使用简单的内置类型;您可以使用 typing 模块导入各种预定义类型,包括如果您想允许任何类型,可以使用名为 Any 的类型。

因此,您可能已经看到 dataclass 的一些优势。您不需要在 __init__ 中编写样板代码,并且已经包含类型注解。但是,除了更清晰、更简短的代码以及运行代码检查器的能力之外,您还能获得什么?

嗯,事实证明 @dataclass 装饰器不仅仅创建 __init__。它还创建了许多其他方法。例如,它定义了 __eq__,该方法允许您使用 == 相等运算符确定两个类是否彼此相等。它还定义了 __repr__,使其比现有的 Python 默认值更具吸引力和实用性。

使用上面的类定义,您可以这样说


b1 = Book('MyTitle1', 'AuthorFirst AuthorLast', 20)
b2 = Book('MyTitle2', 'AuthorFirst AuthorLast', 25)

print(b1)
print(b2)

输出将是


Book(title='MyTitle1', author='AuthorFirst AuthorLast',
 ↪price=20)
Book(title='MyTitle2', author='AuthorFirst AuthorLast',
 ↪price=25)

请注意,虽然属性名称在类级别的 dataclass 中指定,但名称实际上作为各个实例的属性存储。您可以通过稍微探索新对象来看到这一点。例如,如果您要求打印 vars(b1),您将得到以下结果


{'title': 'MyTitle1', 'author': 'AuthorFirst AuthorLast',
 ↪'price': 20}

如果您要求查看 b1.title 的类型,Python 会告诉您它是一个字符串。因此,这里没有创建任何花哨的东西,例如属性或描述符。相反,这只是创建了一个普通的旧类,尽管具有一些有用且有趣的功能。

添加方法

名称“dataclass”暗示此类类用于数据,且仅用于数据。实际上,开发 dataclass 的部分想法是,人们想要一些比常规 Python 类更容易编写的东西,但具有与命名元组或字典相同的易于阅读的语法。该名称暗示此类类仅用于存储数据,而不能编写方法。

但是,情况并非如此。您可以像向任何其他类添加方法一样,向 dataclass 添加方法。例如,假设您想将书作者的姓名作为字符串列表而不是单个字符串获取。如果您想按作者的姓氏然后名字对书籍进行排序或显示,这将非常有用。

在 dataclass 中,您可以通过...添加方法来添加此类方法。在类的主体中,您将编写


def author_split(self):
    return self.author.split()

换句话说,您可以使用以前使用过的相同语法创建您想要的任何方法。

可选功能

Dataclass 提供了大量功能,可以帮助您修改默认行为。

首先,您可以为每个声明的属性提供一个默认值。这样做会使它们在您创建新实例时成为可选的。例如,假设您希望默认书价为 20 美元。您可以说


@dataclass
class Book(object):
    title : str
    author : str
    price : float = 20

请注意,语法如何反映 Python 3 中同时具有类型注解和默认值的函数参数的语法。正如函数参数默认值的情况一样,具有默认值的 dataclass 属性必须放在没有默认值的属性之后。

实际上,您可以传递一个函数,而不是为默认值声明一个值,该函数在每次创建新对象时执行(不带任何参数)。

要做到这一点,并利用许多其他与 dataclass 属性相关的功能,您必须使用 field 函数(来自 dataclass 模块),该函数允许您自定义属性的定义和使用方式。

如果您将函数传递给 default_factory 参数,则每次创建没有为该属性指定值的新实例时都会调用该函数。这与 defaultdict 类的工作方式非常相似,只不过它可以为每个属性指定。

例如,您可以通过以下方式为每本新书提供 20 美元到 100 美元之间的默认随机价格


import random
from dataclasses import dataclass, field

def random_price():
    return random.randint(20,100)

@dataclass
class Book(object):
    title : str
    author : str
    price : float = field(default_factory=random_price)

请注意,您不能同时设置 default_factory 和默认值;关键在于 default_factory 允许您运行一个函数,从而在新实例创建时动态提供值。

Python 对象中 __init__ 方法的主要作用是将属性添加到新实例。实际上,我认为我多年来编写的大多数 __init__ 方法所做的只是将参数分配给实例属性。对于此类对象,dataclass 的默认行为可以正常工作。

但在某些情况下,您希望做的不仅仅是分配值。也许您想设置不依赖于参数的值。也许您想获取参数并以某种方式调整它们。或者,也许您想做更重要的事情,例如打开文件或建立网络连接。

当然,dataclass 的全部意义在于它负责为您编写 __init__。因此,如果您想做的不仅仅是将参数分配给属性,您就无法做到这一点,至少在 __init__ 中不能这样做。我的意思是,您可以定义 __init__,但 dataclass 的全部意义在于它为您执行此操作。

对于这种情况,dataclass 还有另一种方法可供使用,称为 __post_init__。如果您定义了 __post_init__,它将在 dataclass 定义的 __init__ 之后运行。因此,您可以确信属性已设置,从而允许您根据需要调整或添加到它们。

这是 dataclass 处理的另一种情况。通常,用户创建的类的实例是可哈希的。但在 dataclass 的情况下,它们不是。这意味着您不能将 dataclass 用作字典中的键或集合中的元素。

您可以通过声明您的类为“frozen”来解决这个问题,使其不可变。换句话说,frozen dataclass 在运行时定义,然后永远不会更改——类似于命名元组。您可以通过为 dataclass 装饰器的 frozen 参数赋予 True 值来做到这一点


>>> @dataclass(frozen=True)
... class Foo(object):
...     x : int
...
>>> f1 = Foo(10)
>>> f1.x = 100
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
      File "/usr/local/lib/python3.7/dataclasses.py", line 448,
       ↪in _frozen_setattr
    raise FrozenInstanceError(f'cannot assign to field {name!r}')
dataclasses.FrozenInstanceError: cannot assign to field 'x'

此外,现在您可以在变量上运行 hash


>>> hash(f1)
3430012387537

dataclass 中还有许多其他可选功能——从指示如何比较对象、将打印哪些字段等等。看到在 dataclass 的创建中投入了如此多的思考,真是令人印象深刻。如果未来几年大多数 Python 类都定义为 dataclass,以及用户请求的任何自定义和添加,我不会感到惊讶。

结论

Python 的类总是存在一些重复,而 dataclass 旨在解决这个问题。但是,dataclass 超越了宏,提供了一个工具包,大量的 Python 开发人员可以并且应该使用它来提高代码的可读性。dataclass 如此完美地集成到其他现代 Python 工具和代码(如 MyPy)中,这告诉我它将很快成为在 Python 中创建和使用类的标准方式。

资源

Dataclass 在 PEP(Python 增强提案)557 中得到了最全面的描述。如果 Python 3.7 在您阅读本文时尚未发布,您可以访问 https://pythonlang.cn 并下载 Beta 版副本。虽然您不应在生产中使用它,但您绝对应该放心地试用它并将其用于个人项目。

Reuven Lerner 在世界各地向公司教授 Python、数据科学和 Git。您可以订阅他的免费每周“更好的开发者”电子邮件列表,并从他的书籍和课程中学习,网址为 http://lerner.co.il。Reuven 与他的妻子和孩子住在以色列的莫迪因。

加载 Disqus 评论