流畅的 Python 第二版(GPT 重译)(三)(1)https://developer.aliyun.com/article/1484430
检查使用 dataclass 装饰的类
现在,我们将检查示例 5-12。
示例 5-12. meaning/demo_dc.py:使用@dataclass
装饰的类
from dataclasses import dataclass @dataclass class DemoDataClass: a: int # ① b: float = 1.1 # ② c = 'spam' # ③
①
a
变成了一个注释,也是由描述符控制的实例属性。
②
b
是另一个注释,也成为一个具有描述符和默认值1.1
的实例属性。
③
c
只是一个普通的类属性;没有注释会引用它。
现在让我们检查DemoDataClass
上的__annotations__
、__doc__
和a
、b
、c
属性:
>>> from demo_dc import DemoDataClass >>> DemoDataClass.__annotations__ {'a': <class 'int'>, 'b': <class 'float'>} >>> DemoDataClass.__doc__ 'DemoDataClass(a: int, b: float = 1.1)' >>> DemoDataClass.a Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: type object 'DemoDataClass' has no attribute 'a' >>> DemoDataClass.b 1.1 >>> DemoDataClass.c 'spam'
__annotations__
和__doc__
并不奇怪。然而,在DemoDataClass
中没有名为a
的属性——与示例 5-11 中的DemoNTClass
相反,后者具有一个描述符来从实例中获取a
作为只读属性(那个神秘的<_collections._tuplegetter>
)。这是因为a
属性只会存在于DemoDataClass
的实例中。它将是一个公共属性,我们可以获取和设置,除非类被冻结。但是b
和c
存在为类属性,b
保存了b
实例属性的默认值,而c
只是一个不会绑定到实例的类属性。
现在让我们看看DemoDataClass
实例的外观:
>>> dc = DemoDataClass(9) >>> dc.a 9 >>> dc.b 1.1 >>> dc.c 'spam'
再次,a
和b
是实例属性,c
是我们通过实例获取的类属性。
如前所述,DemoDataClass
实例是可变的—并且在运行时不进行类型检查:
>>> dc.a = 10 >>> dc.b = 'oops'
我们甚至可以做更愚蠢的赋值:
>>> dc.c = 'whatever' >>> dc.z = 'secret stash'
现在dc
实例有一个c
属性—但这并不会改变c
类属性。我们可以添加一个新的z
属性。这是正常的 Python 行为:常规实例可以有自己的属性,这些属性不会出现在类中。⁷
关于 @dataclass 的更多信息
到目前为止,我们只看到了@dataclass
的简单示例。装饰器接受几个关键字参数。这是它的签名:
@dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
第一个位置的*
表示剩余参数只能通过关键字传递。表格 5-2 描述了这些参数。
表格 5-2. @dataclass
装饰器接受的关键字参数
选项 | 含义 | 默认值 | 注释 |
init |
生成__init__ |
True |
如果用户实现了__init__ ,则忽略。 |
repr |
生成__repr__ |
True |
如果用户实现了__repr__ ,则忽略。 |
eq |
生成__eq__ |
True |
如果用户实现了__eq__ ,则忽略。 |
order |
生成__lt__ 、__le__ 、__gt__ 、__ge__ |
False |
如果为True ,则在eq=False 时引发异常,或者如果定义或继承了将要生成的任何比较方法。 |
unsafe_hash |
生成__hash__ |
False |
复杂的语义和几个注意事项—参见:数据类文档。 |
frozen |
使实例“不可变” | False |
实例将相对安全免受意外更改,但实际上并非不可变。^(a) |
^(a) @dataclass 通过生成__setattr__ 和__delattr__ 来模拟不可变性,当用户尝试设置或删除字段时,会引发dataclass.FrozenInstanceError —AttributeError 的子类。 |
默认设置实际上是最常用的常见用例的最有用设置。你更有可能从默认设置中更改的选项是:
frozen=True
防止对类实例的意外更改。
order=True
允许对数据类的实例进行排序。
鉴于 Python 对象的动态特性,一个好奇的程序员很容易绕过frozen=True
提供的保护。但是在代码审查中,这些必要的技巧应该很容易被发现。
如果eq
和frozen
参数都为True
,@dataclass
会生成一个合适的__hash__
方法,因此实例将是可散列的。生成的__hash__
将使用所有未被单独排除的字段数据,使用我们将在“字段选项”中看到的字段选项。如果frozen=False
(默认值),@dataclass
将将__hash__
设置为None
,表示实例是不可散列的,因此覆盖了任何超类的__hash__
。
PEP 557—数据类对unsafe_hash
有如下说明:
虽然不建议这样做,但你可以通过
unsafe_hash=True
强制数据类创建一个__hash__
方法。如果你的类在逻辑上是不可变的,但仍然可以被改变,这可能是一个特殊的用例,应该仔细考虑。
我会保留unsafe_hash
。如果你觉得必须使用该选项,请查看dataclasses.dataclass
文档。
可以在字段级别进一步定制生成的数据类。
字段选项
我们已经看到了最基本的字段选项:使用类型提示提供(或不提供)默认值。你声明的实例字段将成为生成的__init__
中的参数。Python 不允许在具有默认值的参数之后使用没有默认值的参数,因此在声明具有默认值的字段之后,所有剩余字段必须也具有默认值。
可变默认值是初学 Python 开发者常见的错误来源。在函数定义中,当函数的一个调用改变了默认值时,易变默认值很容易被破坏,从而改变了后续调用的行为——这是我们将在“可变类型作为参数默认值:不好的想法”中探讨的问题(第六章)。类属性经常被用作实例的默认属性值,包括在数据类中。@dataclass
使用类型提示中的默认值生成带有默认值的参数供__init__
使用。为了防止错误,@dataclass
拒绝了示例 5-13 中的类定义。
示例 5-13. dataclass/club_wrong.py:这个类会引发ValueError
。
@dataclass class ClubMember: name: str guests: list = []
如果加载了具有ClubMember
类的模块,你会得到这个:
$ python3 club_wrong.py Traceback (most recent call last): File "club_wrong.py", line 4, in <module> class ClubMember: ...several lines omitted... ValueError: mutable default <class 'list'> for field guests is not allowed: use default_factory
ValueError
消息解释了问题并建议解决方案:使用default_factory
。示例 5-14 展示了如何纠正ClubMember
。
示例 5-14. dataclass/club.py:这个ClubMember
定义可行
from dataclasses import dataclass, field @dataclass class ClubMember: name: str guests: list = field(default_factory=list)
在示例 5-14 的guests
字段中,不是使用字面列表作为默认值,而是通过调用dataclasses.field
函数并使用default_factory=list
来设置默认值。
default_factory
参数允许你提供一个函数、类或任何其他可调用对象,每次创建数据类的实例时都会调用它以构建默认值。这样,ClubMember
的每个实例都将有自己的list
,而不是所有实例共享来自类的相同list
,这很少是我们想要的,通常是一个错误。
警告
很好的是@dataclass
拒绝了具有list
默认值的字段的类定义。但是,请注意,这是一个部分解决方案,仅适用于list
、dict
和set
。其他用作默认值的可变值不会被@dataclass
标记。你需要理解问题并记住使用默认工厂来设置可变默认值。
如果你浏览dataclasses
模块文档,你会看到一个用新语法定义的list
字段,就像示例 5-15 中一样。
示例 5-15. dataclass/club_generic.py:这个ClubMember
定义更加精确
from dataclasses import dataclass, field @dataclass class ClubMember: name: str guests: list[str] = field(default_factory=list) # ①
①
list[str]
表示“一个str
的列表”。
新的语法list[str]
是一个参数化的泛型类型:自 Python 3.9 以来,list
内置接受方括号表示法来指定列表项的类型。
警告
在 Python 3.9 之前,内置的集合不支持泛型类型表示法。作为临时解决方法,在typing
模块中有相应的集合类型。如果你需要在 Python 3.8 或更早版本中使用参数化的list
类型提示,你必须导入typing
中的List
类型并使用它:List[str]
。有关此问题的更多信息,请参阅“遗留支持和已弃用的集合类型”。
我们将在第八章中介绍泛型。现在,请注意示例 5-14 和 5-15 都是正确的,Mypy 类型检查器不会对这两个类定义提出任何异议。
区别在于guests: list
表示guests
可以是任何类型对象的list
,而guests: list[str]
表示guests
必须是每个项都是str
的list
。这将允许类型检查器在将无效项放入列表的代码中找到(一些)错误,或者从中读取项。
default_factory
很可能是field
函数的最常见选项,但还有其他几个选项,列在表 5-3 中。
表 5-3. field
函数接受的关键字参数
选项 | 含义 | 默认值 |
default |
字段的默认值 | _MISSING_TYPE ^(a) |
default_factory |
用于生成默认值的 0 参数函数 | _MISSING_TYPE |
init |
在__init__ 参数中包含字段 |
True |
repr |
在__repr__ 中包含字段 |
True |
compare |
在比较方法__eq__ 、__lt__ 等中使用字段 |
True |
hash |
在__hash__ 计算中包含字段 |
None ^(b) |
metadata |
具有用户定义数据的映射;被@dataclass 忽略 |
None |
^(a) dataclass._MISSING_TYPE 是一个标志值,表示未提供选项。它存在的原因是我们可以将None 设置为实际的默认值,这是一个常见用例。^(b) 选项hash=None 表示只有在compare=True 时,该字段才会在__hash__ 中使用。 |
default
选项的存在是因为field
调用取代了字段注释中的默认值。如果要创建一个默认值为False
的athlete
字段,并且还要在__repr__
方法中省略该字段,你可以这样写:
@dataclass class ClubMember: name: str guests: list = field(default_factory=list) athlete: bool = field(default=False, repr=False)
后初始化处理
由 @dataclass
生成的 __init__
方法只接受传递的参数并将它们分配给实例字段的实例属性,或者如果缺少参数,则分配它们的默认值。但您可能需要做的不仅仅是这些来初始化实例。如果是这种情况,您可以提供一个 __post_init__
方法。当存在该方法时,@dataclass
将在生成的 __init__
中添加代码,以调用 __post_init__
作为最后一步。
__post_init__
的常见用例是验证和基于其他字段计算字段值。我们将学习一个简单的示例,该示例使用 __post_init__
来实现这两个目的。
首先,让我们看看名为 HackerClubMember
的 ClubMember
子类的预期行为,如 示例 5-16 中的文档测试所描述。
示例 5-16. dataclass/hackerclub.py: HackerClubMember
的文档测试
""" ``HackerClubMember`` objects accept an optional ``handle`` argument:: >>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven') >>> anna HackerClubMember(name='Anna Ravenscroft', guests=[], handle='AnnaRaven') If ``handle`` is omitted, it's set to the first part of the member's name:: >>> leo = HackerClubMember('Leo Rochael') >>> leo HackerClubMember(name='Leo Rochael', guests=[], handle='Leo') Members must have a unique handle. The following ``leo2`` will not be created, because its ``handle`` would be 'Leo', which was taken by ``leo``:: >>> leo2 = HackerClubMember('Leo DaVinci') Traceback (most recent call last): ... ValueError: handle 'Leo' already exists. To fix, ``leo2`` must be created with an explicit ``handle``:: >>> leo2 = HackerClubMember('Leo DaVinci', handle='Neo') >>> leo2 HackerClubMember(name='Leo DaVinci', guests=[], handle='Neo') """
请注意,我们必须将 handle
作为关键字参数提供,因为 HackerClubMember
继承自 ClubMember
的 name
和 guests
,并添加了 handle
字段。生成的 HackerClubMember
的文档字符串显示了构造函数调用中字段的顺序:
>>> HackerClubMember.__doc__ "HackerClubMember(name: str, guests: list = <factory>, handle: str = '')"
这里, 是指某个可调用对象将为
guests
生成默认值的简便方式(在我们的例子中,工厂是 list
类)。关键是:要提供一个 handle
但没有 guests
,我们必须将 handle
作为关键字参数传递。
dataclasses
模块文档中的“继承”部分 解释了在存在多级继承时如何计算字段的顺序。
注意
在 第十四章 中,我们将讨论错误使用继承,特别是当超类不是抽象类时。创建数据类的层次结构通常不是一个好主意,但在这里,它帮助我们缩短了 示例 5-17 的长度,侧重于 handle
字段声明和 __post_init__
验证。
示例 5-17 展示了实现方式。
示例 5-17. dataclass/hackerclub.py: HackerClubMember
的代码
from dataclasses import dataclass from club import ClubMember @dataclass class HackerClubMember(ClubMember): # ① all_handles = set() # ② handle: str = '' # ③ def __post_init__(self): cls = self.__class__ # ④ if self.handle == '': # ⑤ self.handle = self.name.split()[0] if self.handle in cls.all_handles: # ⑥ msg = f'handle {self.handle!r} already exists.' raise ValueError(msg) cls.all_handles.add(self.handle) # ⑦
①
HackerClubMember
扩展了 ClubMember
。
②
all_handles
是一个类属性。
③
handle
是一个类型为 str
的实例字段,其默认值为空字符串;这使其成为可选的。
④
获取实例的类。
⑤
如果 self.handle
是空字符串,则将其设置为 name
的第一部分。
⑥
如果 self.handle
在 cls.all_handles
中,则引发 ValueError
。
⑦
将新的 handle
添加到 cls.all_handles
。
示例 5-17 的功能正常,但对于静态类型检查器来说并不令人满意。接下来,我们将看到原因以及如何解决。
类型化类属性
如果我们使用 Mypy 对 示例 5-17 进行类型检查,我们会受到批评:
$ mypy hackerclub.py hackerclub.py:37: error: Need type annotation for "all_handles" (hint: "all_handles: Set[<type>] = ...") Found 1 error in 1 file (checked 1 source file)
不幸的是,Mypy 提供的提示(我在审阅时使用的版本是 0.910)在 @dataclass
使用的上下文中并不有用。首先,它建议使用 Set
,但我使用的是 Python 3.9,因此可以使用 set
,并避免从 typing
导入 Set
。更重要的是,如果我们向 all_handles
添加一个类型提示,如 set[…]
,@dataclass
将找到该注释,并将 all_handles
变为实例字段。我们在“检查使用 dataclass 装饰的类”中看到了这种情况。
在 PEP 526—变量注释的语法 中定义的解决方法很丑陋。为了编写带有类型提示的类变量,我们需要使用一个名为 typing.ClassVar
的伪类型,它利用泛型 []
符号来设置变量的类型,并声明它为类属性。
为了让类型检查器和 @dataclass
满意,我们应该在 示例 5-17 中这样声明 all_handles
:
all_handles: ClassVar[set[str]] = set()
那个类型提示表示:
all_handles
是一个类型为set
-of-str
的类属性,其默认值为空set
。
要编写该注释的代码,我们必须从 typing
模块导入 ClassVar
。
@dataclass
装饰器不关心注释中的类型,除了两种情况之一,这就是其中之一:如果类型是 ClassVar
,则不会为该属性生成实例字段。
在声明仅初始化变量时,字段类型对 @dataclass
有影响的另一种情况是我们接下来要讨论的。
不是字段的初始化变量
有时,您可能需要向 __init__
传递不是实例字段的参数。这些参数被 dataclasses
文档 称为仅初始化变量。要声明这样的参数,dataclasses
模块提供了伪类型 InitVar
,其使用与 typing.ClassVar
相同的语法。文档中给出的示例是一个数据类,其字段从数据库初始化,并且必须将数据库对象传递给构造函数。
示例 5-18 展示了说明“仅初始化变量”部分的代码。
示例 5-18. 来自 dataclasses
模块文档的示例
@dataclass class C: i: int j: int = None database: InitVar[DatabaseType] = None def __post_init__(self, database): if self.j is None and database is not None: self.j = database.lookup('j') c = C(10, database=my_database)
注意 database
属性的声明方式。InitVar
将阻止 @dataclass
将 database
视为常规字段。它不会被设置为实例属性,并且 dataclasses.fields
函数不会列出它。但是,database
将是生成的 __init__
将接受的参数之一,并且也将传递给 __post_init__
。如果您编写该方法,必须在方法签名中添加相应的参数,如示例 5-18 中所示。
这个相当长的 @dataclass
概述涵盖了最有用的功能——其中一些出现在之前的部分中,比如“主要特性”,在那里我们并行讨论了所有三个数据类构建器。dataclasses
文档 和 PEP 526—变量注释的语法 中有所有细节。
在下一节中,我将展示一个更长的示例,使用 @dataclass
。
@dataclass 示例:Dublin Core 资源记录
经常使用 @dataclass
构建的类将具有比目前呈现的非常简短示例更多的字段。Dublin Core 为一个更典型的 @dataclass
示例提供了基础。
Dublin Core Schema 是一组可以用于描述数字资源(视频、图像、网页等)以及实体资源(如书籍或 CD)和艺术品等对象的词汇术语。⁸
维基百科上的 Dublin Core
标准定义了 15 个可选字段;示例 5-19 中的 Resource
类使用了其中的 8 个。
示例 5-19. dataclass/resource.py: Resource
类的代码,基于 Dublin Core 术语
from dataclasses import dataclass, field from typing import Optional from enum import Enum, auto from datetime import date class ResourceType(Enum): # ① BOOK = auto() EBOOK = auto() VIDEO = auto() @dataclass class Resource: """Media resource description.""" identifier: str # ② title: str = '<untitled>' # ③ creators: list[str] = field(default_factory=list) date: Optional[date] = None # ④ type: ResourceType = ResourceType.BOOK # ⑤ description: str = '' language: str = '' subjects: list[str] = field(default_factory=list)
①
这个 Enum
将为 Resource.type
字段提供类型安全的值。
②
identifier
是唯一必需的字段。
③
title
是第一个具有默认值的字段。这迫使下面的所有字段都提供默认值。
④
date
的值可以是 datetime.date
实例,或者是 None
。
⑤
type
字段的默认值是 ResourceType.BOOK
。
示例 5-20 展示了一个 doctest,演示了代码中 Resource
记录的外观。
示例 5-20. dataclass/resource.py: Resource
类的代码,基于 Dublin Core 术语
>>> description = 'Improving the design of existing code' >>> book = Resource('978-0-13-475759-9', 'Refactoring, 2nd Edition', ... ['Martin Fowler', 'Kent Beck'], date(2018, 11, 19), ... ResourceType.BOOK, description, 'EN', ... ['computer programming', 'OOP']) >>> book # doctest: +NORMALIZE_WHITESPACE Resource(identifier='978-0-13-475759-9', title='Refactoring, 2nd Edition', creators=['Martin Fowler', 'Kent Beck'], date=datetime.date(2018, 11, 19), type=<ResourceType.BOOK: 1>, description='Improving the design of existing code', language='EN', subjects=['computer programming', 'OOP'])
由 @dataclass
生成的 __repr__
是可以的,但我们可以使其更易读。这是我们希望从 repr(book)
得到的格式:
>>> book # doctest: +NORMALIZE_WHITESPACE Resource( identifier = '978-0-13-475759-9', title = 'Refactoring, 2nd Edition', creators = ['Martin Fowler', 'Kent Beck'], date = datetime.date(2018, 11, 19), type = <ResourceType.BOOK: 1>, description = 'Improving the design of existing code', language = 'EN', subjects = ['computer programming', 'OOP'], )
示例 5-21 是用于生成最后代码片段中所示格式的__repr__
的代码。此示例使用dataclass.fields
来获取数据类字段的名称。
示例 5-21. dataclass/resource_repr.py
:在示例 5-19 中实现的Resource
类中实现的__repr__
方法的代码
def __repr__(self): cls = self.__class__ cls_name = cls.__name__ indent = ' ' * 4 res = [f'{cls_name}('] # ① for f in fields(cls): # ② value = getattr(self, f.name) # ③ res.append(f'{indent}{f.name} = {value!r},') # ④ res.append(')') # ⑤ return '\n'.join(res) # ⑥
①
开始res
列表以构建包含类名和开括号的输出字符串。
②
对于类中的每个字段f
…
③
…从实例中获取命名属性。
④
附加一个缩进的行,带有字段的名称和repr(value)
—这就是!r
的作用。
⑤
附加闭括号。
⑥
从res
构建一个多行字符串并返回它。
通过这个受到俄亥俄州都柏林灵感的例子,我们结束了对 Python 数据类构建器的介绍。
数据类很方便,但如果过度使用它们,您的项目可能会受到影响。接下来的部分将进行解释。
数据类作为代码异味
无论您是通过自己编写所有代码来实现数据类,还是利用本章描述的类构建器之一,都要意识到它可能在您的设计中信号问题。
在重构:改善现有代码设计,第 2 版(Addison-Wesley)中,Martin Fowler 和 Kent Beck 提供了一个“代码异味”目录—代码中可能表明需要重构的模式。标题为“数据类”的条目开头是这样的:
这些类具有字段、获取和设置字段的方法,除此之外什么都没有。这样的类是愚蠢的数据持有者,往往被其他类以过于详细的方式操纵。
在福勒的个人网站上,有一篇标题为“代码异味”的启发性文章。这篇文章与我们的讨论非常相关,因为他将数据类作为代码异味的一个例子,并建议如何处理。以下是完整的文章。⁹
面向对象编程的主要思想是将行为和数据放在同一个代码单元中:一个类。如果一个类被广泛使用但本身没有重要的行为,那么处理其实例的代码可能分散在整个系统的方法和函数中(甚至重复)—这是维护头痛的根源。这就是为什么福勒的重构涉及将责任带回到数据类中。
考虑到这一点,有几种常见情况下,拥有一个几乎没有行为的数据类是有意义的。
流畅的 Python 第二版(GPT 重译)(三)(3)https://developer.aliyun.com/article/1484432