流畅的 Python 第二版(GPT 重译)(三)(2)

简介: 流畅的 Python 第二版(GPT 重译)(三)

流畅的 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__abc属性:

>>> 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的实例中。它将是一个公共属性,我们可以获取和设置,除非类被冻结。但是bc存在为类属性,b保存了b实例属性的默认值,而c只是一个不会绑定到实例的类属性。

现在让我们看看DemoDataClass实例的外观:

>>> dc = DemoDataClass(9)
>>> dc.a
9
>>> dc.b
1.1
>>> dc.c
'spam'

再次,ab是实例属性,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.FrozenInstanceErrorAttributeError的子类。

默认设置实际上是最常用的常见用例的最有用设置。你更有可能从默认设置中更改的选项是:

frozen=True

防止对类实例的意外更改。

order=True

允许对数据类的实例进行排序。

鉴于 Python 对象的动态特性,一个好奇的程序员很容易绕过frozen=True提供的保护。但是在代码审查中,这些必要的技巧应该很容易被发现。

如果eqfrozen参数都为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默认值的字段的类定义。但是,请注意,这是一个部分解决方案,仅适用于listdictset。其他用作默认值的可变值不会被@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必须是每个项都是strlist。这将允许类型检查器在将无效项放入列表的代码中找到(一些)错误,或者从中读取项。

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调用取代了字段注释中的默认值。如果要创建一个默认值为Falseathlete字段,并且还要在__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__ 来实现这两个目的。

首先,让我们看看名为 HackerClubMemberClubMember 子类的预期行为,如 示例 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 继承自 ClubMembernameguests,并添加了 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.handlecls.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 将阻止 @dataclassdatabase 视为常规字段。它不会被设置为实例属性,并且 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

相关文章
|
13天前
|
存储 安全 测试技术
流畅的 Python 第二版(GPT 重译)(四)(3)
流畅的 Python 第二版(GPT 重译)(四)
6 1
|
13天前
|
存储 自然语言处理 安全
流畅的 Python 第二版(GPT 重译)(十)(1)
流畅的 Python 第二版(GPT 重译)(十)
60 0
|
13天前
|
存储 缓存 Java
流畅的 Python 第二版(GPT 重译)(六)(2)
流畅的 Python 第二版(GPT 重译)(六)
58 0
|
13天前
|
缓存 算法 Java
流畅的 Python 第二版(GPT 重译)(三)(4)
流畅的 Python 第二版(GPT 重译)(三)
41 4
|
13天前
|
机器学习/深度学习 Serverless Python
流畅的 Python 第二版(GPT 重译)(六)(4)
流畅的 Python 第二版(GPT 重译)(六)
12 1
|
13天前
|
设计模式 算法 Java
流畅的 Python 第二版(GPT 重译)(九)(4)
流畅的 Python 第二版(GPT 重译)(九)
34 1
|
13天前
|
存储 程序员 API
流畅的 Python 第二版(GPT 重译)(七)(2)
流畅的 Python 第二版(GPT 重译)(七)
60 0
|
Linux 数据库 iOS开发
流畅的 Python 第二版(GPT 重译)(二)(4)
流畅的 Python 第二版(GPT 重译)(二)
48 5
|
13天前
|
安全 Java 程序员
流畅的 Python 第二版(GPT 重译)(六)(3)
流畅的 Python 第二版(GPT 重译)(六)
9 1
|
13天前
|
设计模式 算法 程序员
流畅的 Python 第二版(GPT 重译)(八)(4)
流畅的 Python 第二版(GPT 重译)(八)
43 1

热门文章

最新文章