流畅的 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
将检索该字段的值,而不是Event
从Record
继承的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_type
是Event
,但如果编写了名为Speaker
或Venue
的类,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_property
和 property
之间的区别。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
的接口不会改变(即,设置LineItem
的weight
仍然写作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
免受用户提供负值的影响。尽管买家通常不能设置物品的价格,但是文书错误或错误可能会创建一个具有负price
的LineItem
。为了防止这种情况,我们也可以将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,而不依赖于在它们的名称中使用get
和set
前缀的约定。
类中存在属性会影响实例中属性的查找方式,这可能一开始会让人感到惊讶。下一节将解释。
属性覆盖实例属性
属性始终是类属性,但实际上管理类的实例中的属性访问。
在“覆盖类属性”中,我们看到当一个实例及其类都有相同名称的数据属性时,实例属性会覆盖或遮蔽类属性——至少在通过该实例读取时是这样的。示例 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
现在有两个实例属性:data
和 prop
。
⑥
然而,读取 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