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

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

流畅的 Python 第二版(GPT 重译)(十二)(1)https://developer.aliyun.com/article/1485174

示例 22-11. schedule_v2.py:具有新fetch方法的Record
import inspect  # ①
import json
JSON_PATH = 'data/osconfeed.json'
class Record:
    __index = None  # ②
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'
    @staticmethod  # ③
    def fetch(key):
        if Record.__index is None:  # ④
            Record.__index = load()
        return Record.__index[key]  # ⑤

inspect将在示例 22-13 中使用。

__index私有类属性最终将保存对load返回的dict的引用。

fetch是一个staticmethod,明确表示其效果不受调用它的实例或类的影响。

如有需要,填充Record.__index

使用它来检索具有给定key的记录。

提示

这是一个使用staticmethod的例子。fetch方法始终作用于Record.__index类属性,即使从子类调用,如Event.fetch()—我们很快会探讨。将其编码为类方法会产生误导,因为不会使用cls第一个参数。

现在我们来看Event类中属性的使用,列在示例 22-12 中。

示例 22-12. schedule_v2.py:Event
class Event(Record):  # ①
    def __repr__(self):
        try:
            return f'<{self.__class__.__name__} {self.name!r}>'  # ②
        except AttributeError:
            return super().__repr__()
    @property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)  # ③

Event扩展了Record

如果实例具有name属性,则用于生成自定义表示。否则,委托给Record__repr__

venue属性从venue_serial属性构建一个key,并将其传递给从Record继承的fetch类方法(使用self.__class__的原因将很快解释)。

Example 22-12 的venue方法的第二行返回self.__class__.fetch(key)。为什么不简单地调用self.fetch(key)?简单形式适用于特定的 OSCON 数据集,因为没有带有'fetch'键的事件记录。但是,如果事件记录有一个名为'fetch'的键,那么在特定的Event实例内,引用self.fetch将检索该字段的值,而不是EventRecord继承的fetch类方法。这是一个微妙的错误,它很容易在测试中被忽略,因为它取决于数据集。

警告

在从数据创建实例属性名称时,总是存在由于类属性(如方法)的遮蔽或由于意外覆盖现有实例属性而导致的错误风险。这些问题可能解释了为什么 Python 字典一开始就不像 JavaScript 对象。

如果Record类的行为更像映射,实现动态的__getitem__而不是动态的__getattr__,那么就不会有由于覆盖或遮蔽而导致的错误风险。自定义映射可能是实现Record的 Pythonic 方式。但是如果我选择这条路,我们就不会研究动态属性编程的技巧和陷阱。

该示例的最后一部分是 Example 22-13 中修改后的load函数。

示例 22-13. schedule_v2.py:load函数
def load(path=JSON_PATH):
    records = {}
    with open(path) as fp:
        raw_data = json.load(fp)
    for collection, raw_records in raw_data['Schedule'].items():
        record_type = collection[:-1]  # ①
        cls_name = record_type.capitalize()  # ②
        cls = globals().get(cls_name, Record)  # ③
        if inspect.isclass(cls) and issubclass(cls, Record):  # ④
            factory = cls  # ⑤
        else:
            factory = Record  # ⑥
        for raw_record in raw_records:  # ⑦
            key = f'{record_type}.{raw_record["serial"]}'
            records[key] = factory(**raw_record)  # ⑧
    return records

到目前为止,与schedule_v1.py中的load没有任何变化(Example 22-9)。

record_type大写以获得可能的类名;例如,'event'变为'Event'

从模块全局范围获取该名称的对象;如果没有这样的对象,则获取Record类。

如果刚刚检索到的对象是一个类,并且是Record的子类…

…将factory名称绑定到它。这意味着factory可以是Record的任何子类,取决于record_type

否则,将factory名称绑定到Record

创建key并保存记录的for循环与以前相同,只是…

…存储在records中的对象由factory构造,该factory可以是Record或根据record_type选择的Event等子类。

请注意,唯一具有自定义类的record_typeEvent,但如果编写了名为SpeakerVenue的类,load将在构建和保存记录时自动使用这些类,而不是默认的Record类。

现在我们将相同的想法应用于Events类中的新speakers属性。

第三步:覆盖现有属性

Example 22-12 中venue属性的名称与"events"集合中的记录字段名称不匹配。它的数据来自venue_serial字段名称。相比之下,events集合中的每个记录都有一个speakers字段,其中包含一系列序列号。我们希望将该信息作为Event实例中的speakers属性公开,该属性返回Record实例的列表。这种名称冲突需要特别注意,正如 Example 22-14 所示。

示例 22-14. schedule_v3.py:speakers属性
@property
    def speakers(self):
        spkr_serials = self.__dict__['speakers']  # ①
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]  # ②

我们想要的数据在speakers属性中,但我们必须直接从实例__dict__中检索它,以避免对speakers属性的递归调用。

返回一个具有与 spkr_serials 中数字对应的键的所有记录列表。

speakers 方法内部,尝试读取 self.speakers 将会快速引发 RecursionError。然而,如果通过 self.__dict__['speakers'] 读取相同的数据,Python 通常用于检索属性的算法将被绕过,属性不会被调用,递归被避免。因此,直接读取或写入对象的 __dict__ 中的数据是一种常见的 Python 元编程技巧。

警告

解释器通过首先查看 obj 的类来评估 obj.my_attr。如果类具有与 my_attr 名称相同的属性,则该属性会遮蔽同名的实例属性。“属性覆盖实例属性” 中的示例将演示这一点,而 第二十三章 将揭示属性是作为描述符实现的——这是一种更强大和通用的抽象。

当我编写 示例 22-14 中的列表推导式时,我的程序员蜥蜴大脑想到:“这可能会很昂贵。” 实际上并不是,因为 OSCON 数据集中的事件只有少数演讲者,所以编写任何更复杂的东西都会过早优化。然而,缓存属性是一个常见的需求,但也有一些注意事项。让我们在接下来的示例中看看如何做到这一点。

步骤 4:定制属性缓存

缓存属性是一个常见的需求,因为人们期望像 event.venue 这样的表达式应该是廉价的。⁸ 如果 Record.fetch 方法背后的 Event 属性需要查询数据库或 Web API,某种形式的缓存可能会变得必要。

在第一版 Fluent Python 中,我为 speakers 方法编写了自定义缓存逻辑,如 示例 22-15 所示。

示例 22-15. 使用 hasattr 的自定义缓存逻辑会禁用键共享优化
@property
    def speakers(self):
        if not hasattr(self, '__speaker_objs'):  # ①
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs  # ②

如果实例没有名为 __speaker_objs 的属性,则获取演讲者对象并将它们存储在那里。

返回 self.__speaker_objs

在 示例 22-15 中手动缓存是直接的,但在实例初始化后创建属性会破坏 PEP 412—Key-Sharing Dictionary 优化,如 “dict 工作原理的实际后果” 中所解释的。根据数据集的大小,内存使用量的差异可能很重要。

一个类似的手动解决方案,与键共享优化很好地配合使用,需要为 Event 类编写一个 __init__,以创建必要的 __speaker_objs 并将其初始化为 None,然后在 speakers 方法中检查这一点。参见 示例 22-16。

示例 22-16. 在 __init__ 中定义存储以利用键共享优化
class Event(Record):
    def __init__(self, **kwargs):
        self.__speaker_objs = None
        super().__init__(**kwargs)
# 15 lines omitted...
    @property
    def speakers(self):
        if self.__speaker_objs is None:
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs

示例 22-15 和 22-16 展示了在传统 Python 代码库中相当常见的简单缓存技术。然而,在多线程程序中,像这样的手动缓存会引入可能导致数据损坏的竞争条件。如果两个线程正在读取以前未缓存的属性,则第一个线程将需要计算缓存属性的数据(示例中的 __speaker_objs),而第二个线程可能会读取尚不完整的缓存值。

幸运的是,Python 3.8 引入了 @functools.cached_property 装饰器,它是线程安全的。不幸的是,它带来了一些注意事项,接下来会解释。

步骤 5:使用 functools 缓存属性

functools 模块提供了三个用于缓存的装饰器。我们在 “使用 functools.cache 进行记忆化”(第九章)中看到了 @cache@lru_cache。Python 3.8 引入了 @cached_property

functools.cached_property 装饰器将方法的结果缓存到具有相同名称的实例属性中。例如,在 示例 22-17 中,venue 方法计算的值存储在 self 中的 venue 属性中。之后,当客户端代码尝试读取 venue 时,新创建的 venue 实例属性将被使用,而不是方法。

示例 22-17. 使用 @cached_property 的简单示例
@cached_property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)

在 “第 3 步:覆盖现有属性的属性” 中,我们看到属性通过相同名称的实例属性进行遮蔽。如果这是真的,那么 @cached_property 如何工作呢?如果属性覆盖了实例属性,那么 venue 属性将被忽略,venue 方法将始终被调用,每次计算 key 并运行 fetch

答案有点令人沮丧:cached_property 是一个误称。@cached_property 装饰器并不创建一个完整的属性,而是创建了一个 非覆盖描述符。描述符是一个管理另一个类中属性访问的对象。我们将在 第二十三章 中深入探讨描述符。property 装饰器是一个用于创建 覆盖描述符 的高级 API。第二十三章 将详细解释 覆盖非覆盖 描述符的区别。

现在,让我们暂时搁置底层实现,关注从用户角度看 cached_propertyproperty 之间的区别。Raymond Hettinger 在 Python 文档 中很好地解释了它们:

cached_property() 的机制与 property() 有所不同。普通属性会阻止属性写入,除非定义了 setter。相比之下,cached_property 允许写入。

cached_property 装饰器仅在查找时运行,并且仅当同名属性不存在时才运行。当它运行时,cached_property 会写入具有相同名称的属性。随后的属性读取和写入优先于 cached_property 方法,并且它的工作方式类似于普通属性。

缓存的值可以通过删除属性来清除。这允许 cached_property 方法再次运行。⁹

回到我们的 Event 类:@cached_property 的具体行为使其不适合装饰 speakers,因为该方法依赖于一个名为 speakers 的现有属性,其中包含活动演讲者的序列号。

警告

@cached_property 有一些重要的限制:

  • 如果装饰的方法已经依赖于同名实例属性,则它不能作为 @property 的即插即用替代品。
  • 它不能在定义了 __slots__ 的类中使用。
  • 它打败了实例 __dict__ 的键共享优化,因为它在 __init__ 之后创建了一个实例属性。

尽管存在这些限制,@cached_property 以简单的方式满足了常见需求,并且是线程安全的。它的 Python 代码 是使用 可重入锁 的一个示例。

@cached_property文档 建议了一个替代解决方案,我们可以在 speakers 上使用 @property@cache 装饰器叠加,就像 示例 22-18 中展示的那样。

示例 22-18. 在 @property 上叠加 @cache
@property  # ①
    @cache  # ②
    def speakers(self):
        spkr_serials = self.__dict__['speakers']
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]

顺序很重要:@property 放在最上面…

@cache

从“堆叠装饰器”中回想一下该语法的含义。示例 22-18 的前三行类似于:

speakers = property(cache(speakers))

@cache应用于speakers,返回一个新函数。然后,该函数被@property装饰,将其替换为一个新构造的属性。

这结束了我们对只读属性和缓存装饰器的讨论,探索 OSCON 数据集。在下一节中,我们将开始一个新系列的示例,创建读/写属性。``````# 使用属性进行属性验证

除了计算属性值外,属性还用于通过将公共属性更改为由 getter 和 setter 保护的属性来强制执行业务规则,而不影响客户端代码。让我们通过一个扩展示例来详细讨论。

LineItem 第一次尝试:订单中的商品类

想象一个销售散装有机食品的商店的应用程序,客户可以按重量订购坚果、干果或谷物。在该系统中,每个订单将包含一系列行项目,每个行项目可以由一个类的实例表示,如示例 22-19 中所示。

示例 22-19。bulkfood_v1.py:最简单的LineItem
class LineItem:
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price
    def subtotal(self):
        return self.weight * self.price

这很简单明了。也许太简单了。示例 22-20 展示了一个问题。

示例 22-20。负重导致负小计
>>> raisins = LineItem('Golden raisins', 10, 6.95)
    >>> raisins.subtotal()
    69.5
    >>> raisins.weight = -20  # garbage in...
    >>> raisins.subtotal()    # garbage out...
    -139.0

这只是一个玩具示例,但并不像你想象的那样幻想。这是亚马逊.com 早期的一个故事:

我们发现客户可以订购负数数量的书!然后我们会用价格给他们的信用卡记账,我猜,等待他们发货。

亚马逊.com 创始人兼首席执行官杰夫·贝索斯¹⁰

我们如何解决这个问题?我们可以改变LineItem的接口,使用 getter 和 setter 来处理weight属性。那将是 Java 的方式,这并不是错误的。

另一方面,能够通过简单赋值来设置物品的weight是很自然的;也许系统已经在生产中,其他部分已经直接访问item.weight。在这种情况下,Python 的做法是用属性替换数据属性。

LineItem 第二次尝试:一个验证属性

实现一个属性将允许我们使用一个 getter 和一个 setter,但LineItem的接口不会改变(即,设置LineItemweight仍然写作raisins.weight = 12)。

示例 22-21 列出了一个读/写weight属性的代码。

示例 22-21。bulkfood_v2.py:带有weight属性的LineItem
class LineItem:
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # ①
        self.price = price
    def subtotal(self):
        return self.weight * self.price
    @property  # ②
    def weight(self):  # ③
        return self.__weight  # ④
    @weight.setter  # ⑤
    def weight(self, value):
        if value > 0:
            self.__weight = value  # ⑥
        else:
            raise ValueError('value must be > 0')  # ⑦

这里属性 setter 已经在使用中,确保不会创建带有负weight的实例。

@property装饰 getter 方法。

所有实现属性的方法都共享公共属性的名称:weight

实际值存储在私有属性__weight中。

装饰的 getter 具有.setter属性,这也是一个装饰器;这将 getter 和 setter 绑定在一起。

如果值大于零,我们设置私有__weight

否则,将引发ValueError

请注意,现在无法创建具有无效重量的LineItem

>>> walnuts = LineItem('walnuts', 0, 10.00)
Traceback (most recent call last):
    ...
ValueError: value must be > 0

现在我们已经保护了weight免受用户提供负值的影响。尽管买家通常不能设置物品的价格,但是文书错误或错误可能会创建一个具有负priceLineItem。为了防止这种情况,我们也可以将price转换为属性,但这将在我们的代码中产生一些重复。

记住保罗·格雷厄姆在第十七章中的引用:“当我在我的程序中看到模式时,我认为这是一个麻烦的迹象。”重复的治疗方法是抽象。有两种抽象属性定义的方法:使用属性工厂或描述符类。描述符类方法更灵活,我们将在第二十三章中全面讨论它。实际上,属性本身是作为描述符类实现的。但在这里,我们将通过实现一个函数作为属性工厂来继续探讨属性。

但在我们实现属性工厂之前,我们需要更深入地了解属性。

对属性进行适当的查看

尽管经常被用作装饰器,但property内置实际上是一个类。在 Python 中,函数和类通常是可互换的,因为两者都是可调用的,而且没有用于对象实例化的new运算符,因此调用构造函数与调用工厂函数没有区别。并且两者都可以用作装饰器,只要它们返回一个适当替代被装饰的可调用对象。

这是property构造函数的完整签名:

property(fget=None, fset=None, fdel=None, doc=None)

所有参数都是可选的,如果没有为其中一个参数提供函数,则生成的属性对象不允许相应的操作。

property类型是在 Python 2.2 中添加的,但@装饰器语法只在 Python 2.4 中出现,因此在几年内,属性是通过将访问器函数作为前两个参数来定义的。

用装饰器的方式定义属性的“经典”语法在示例 22-22 中有所说明。

示例 22-22。bulkfood_v2b.py:与示例 22-21 相同,但不使用装饰器
class LineItem:
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price
    def subtotal(self):
        return self.weight * self.price
    def get_weight(self):  # ①
        return self.__weight
    def set_weight(self, value):  # ②
        if value > 0:
            self.__weight = value
        else:
            raise ValueError('value must be > 0')
    weight = property(get_weight, set_weight)  # ③

一个普通的 getter。

一个普通的 setter。

构建property并将其分配给一个公共类属性。

在某些情况下,经典形式比装饰器语法更好;我们将很快讨论的属性工厂的代码就是一个例子。另一方面,在一个有许多方法的类体中,装饰器使得明确哪些是 getter 和 setter,而不依赖于在它们的名称中使用getset前缀的约定。

类中存在属性会影响实例中属性的查找方式,这可能一开始会让人感到惊讶。下一节将解释。

属性覆盖实例属性

属性始终是类属性,但实际上管理类的实例中的属性访问。

在“覆盖类属性”中,我们看到当一个实例及其类都有相同名称的数据属性时,实例属性会覆盖或遮蔽类属性——至少在通过该实例读取时是这样的。示例 22-23 说明了这一点。

示例 22-23。实例属性遮蔽类data属性
>>> class Class:  # ①
...     data = 'the class data attr'
...     @property
...     def prop(self):
...         return 'the prop value'
...
>>> obj = Class()
>>> vars(obj)  # ②
{} >>> obj.data  # ③
'the class data attr' >>> obj.data = 'bar' # ④
>>> vars(obj)  # ⑤
{'data': 'bar'} >>> obj.data  # ⑥
'bar' >>> Class.data  # ⑦
'the class data attr'

使用两个类属性data属性和prop属性定义Class

vars返回obj__dict__,显示它没有实例属性。

obj.data中读取Class.data的值。

写入 obj.data 创建一个实例属性。

检查实例以查看实例属性。

现在从 obj.data 读取将检索实例属性的值。当从 obj 实例读取时,实例 data 遮蔽了类 data

Class.data 属性保持不变。

现在,让我们尝试覆盖 obj 实例上的 prop 属性。继续之前的控制台会话,我们有示例 22-24。

示例 22-24. 实例属性不会遮蔽类属性(续自示例 22-23)
>>> Class.prop  # ①
<property object at 0x1072b7408> >>> obj.prop  # ②
'the prop value' >>> obj.prop = 'foo'  # ③
Traceback (most recent call last):
  ...
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo'  # ④
>>> vars(obj)  # ⑤
{'data': 'bar', 'prop': 'foo'} >>> obj.prop  # ⑥
'the prop value' >>> Class.prop = 'baz'  # ⑦
>>> obj.prop  # ⑧
'foo'

直接从 Class 中读取 prop 会检索属性对象本身,而不会运行其 getter 方法。

读取 obj.prop 执行属性的 getter。

尝试设置实例 prop 属性失败。

直接将 'prop' 放入 obj.__dict__ 中有效。

我们可以看到 obj 现在有两个实例属性:dataprop

然而,读取 obj.prop 仍然会运行属性的 getter。属性不会被实例属性遮蔽。

覆盖 Class.prop 会销毁属性对象。

现在 obj.prop 检索实例属性。Class.prop 不再是属性,因此不再覆盖 obj.prop

作为最后的演示,我们将向 Class 添加一个新属性,并看到它如何覆盖实例属性。示例 22-25 接续了示例 22-24。


流畅的 Python 第二版(GPT 重译)(十二)(3)https://developer.aliyun.com/article/1485177

相关文章
|
13天前
|
存储 JSON 缓存
流畅的 Python 第二版(GPT 重译)(十二)(1)
流畅的 Python 第二版(GPT 重译)(十二)
63 1
|
13天前
|
存储 JSON uml
流畅的 Python 第二版(GPT 重译)(十二)(3)
流畅的 Python 第二版(GPT 重译)(十二)
28 1
|
13天前
|
设计模式 存储 缓存
流畅的 Python 第二版(GPT 重译)(十二)(4)
流畅的 Python 第二版(GPT 重译)(十二)
42 1
|
存储 网络协议 Java
流畅的 Python 第二版(GPT 重译)(十一)(2)
流畅的 Python 第二版(GPT 重译)(十一)
79 1
|
13天前
|
网络协议 JavaScript 前端开发
流畅的 Python 第二版(GPT 重译)(十一)(3)
流畅的 Python 第二版(GPT 重译)(十一)
48 1
|
网络协议 JavaScript API
流畅的 Python 第二版(GPT 重译)(十一)(4)
流畅的 Python 第二版(GPT 重译)(十一)
36 0
|
JavaScript 前端开发 Java
流畅的 Python 第二版(GPT 重译)(十一)(1)
流畅的 Python 第二版(GPT 重译)(十一)
84 1
|
14天前
|
存储 机器学习/深度学习 安全
流畅的 Python 第二版(GPT 重译)(一)(4)
流畅的 Python 第二版(GPT 重译)(一)
40 3
|
Linux 数据库 iOS开发
流畅的 Python 第二版(GPT 重译)(二)(4)
流畅的 Python 第二版(GPT 重译)(二)
48 5
|
13天前
|
存储 测试技术 Python
流畅的 Python 第二版(GPT 重译)(九)(3)
流畅的 Python 第二版(GPT 重译)(九)
29 0