第五部分:元编程
第二十二章:动态属性和属性
属性的关键重要性在于,它们的存在使得将公共数据属性作为类的公共接口的一部分完全安全且确实可取。
Martelli、Ravenscroft 和 Holden,“为什么属性很重要”¹
在 Python 中,数据属性和方法统称为属性。方法是可调用的属性。动态属性呈现与数据属性相同的接口——即,obj.attr
——但是根据需要计算。这遵循 Bertrand Meyer 的统一访问原则:
模块提供的所有服务都应通过统一的表示法可用,这种表示法不会泄露它们是通过存储还是计算实现的。²
在 Python 中有几种实现动态属性的方法。本章涵盖了最简单的方法:@property
装饰器和__getattr__
特殊方法。
实现__getattr__
的用户定义类可以实现我称之为虚拟属性的动态属性变体:这些属性在类的源代码中没有明确声明,也不在实例__dict__
中存在,但可能在用户尝试读取不存在的属性时在其他地方检索或在需要时动态计算,例如obj.no_such_attr
。
编写动态和虚拟属性是框架作者所做的元编程。然而,在 Python 中,基本技术很简单,因此我们可以在日常数据整理任务中使用它们。这就是我们将在本章开始的方式。
本章的新内容
本章大部分更新的动机来自对@functools.cached_property
(Python 3.8 中引入)的讨论,以及@property
与@functools.cache
(3.9 中新引入)的联合使用。这影响了出现在“计算属性”中的Record
和Event
类的代码。我还添加了一项重构以利用PEP 412—共享键字典优化。
为了突出更相关的特性,同时保持示例的可读性,我删除了一些非必要的代码——将旧的DbRecord
类合并到Record
中,用dict
替换shelve.Shelve
,并删除了下载 OSCON 数据集的逻辑——示例现在从Fluent Python代码库中的本地文件中读取。
使用动态属性进行数据整理
在接下来的几个示例中,我们将利用动态属性处理 O’Reilly 为 OSCON 2014 会议发布的 JSON 数据集。示例 22-1 展示了该数据集中的四条记录。³
示例 22-1。来自 osconfeed.json 的示例记录;一些字段内容已缩写
{ "Schedule": { "conferences": [{"serial": 115 }], "events": [ { "serial": 34505, "name": "Why Schools Don´t Use Open Source to Teach Programming", "event_type": "40-minute conference session", "time_start": "2014-07-23 11:30:00", "time_stop": "2014-07-23 12:10:00", "venue_serial": 1462, "description": "Aside from the fact that high school programming...", "website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505", "speakers": [157509], "categories": ["Education"] } ], "speakers": [ { "serial": 157509, "name": "Robert Lefkowitz", "photo": null, "url": "http://sharewave.com/", "position": "CTO", "affiliation": "Sharewave", "twitter": "sharewaveteam", "bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..." } ], "venues": [ { "serial": 1462, "name": "F151", "category": "Conference Venues" } ] } }
示例 22-1 展示了 JSON 文件中的 895 条记录中的 4 条。整个数据集是一个带有键"Schedule"
的单个 JSON 对象,其值是另一个具有四个键"conferences"
、"events"
、"speakers"
和"venues"
的映射。这四个键中的每一个都映射到一个记录列表。在完整数据集中,"events"
、"speakers"
和"venues"
列表有几十个或几百个记录,而"conferences"
只有在示例 22-1 中显示的那一条记录。每条记录都有一个"serial"
字段,这是记录在列表中的唯一标识符。
我使用 Python 控制台来探索数据集,如示例 22-2 所示。
示例 22-2. 交互式探索 osconfeed.json
>>> import json >>> with open('data/osconfeed.json') as fp: ... feed = json.load(fp) # ① >>> sorted(feed['Schedule'].keys()) # ② ['conferences', 'events', 'speakers', 'venues'] >>> for key, value in sorted(feed['Schedule'].items()): ... print(f'{len(value):3} {key}') # ③ ... 1 conferences 484 events 357 speakers 53 venues >>> feed['Schedule']['speakers'][-1]['name'] # ④ 'Carina C. Zona' >>> feed['Schedule']['speakers'][-1]['serial'] # ⑤ 141590 >>> feed['Schedule']['events'][40]['name'] 'There *Will* Be Bugs' >>> feed['Schedule']['events'][40]['speakers'] # ⑥ [3471, 5199]
①
feed
是一个包含嵌套字典和列表、字符串和整数值的dict
。
②
列出"Schedule"
内的四个记录集合。
③
显示每个集合的记录计数。
④
浏览嵌套的字典和列表以获取最后一个演讲者的姓名。
⑤
获取相同演讲者的序列号。
⑥
每个事件都有一个带有零个或多个演讲者序列号的'speakers'
列表。
使用动态属性探索类似 JSON 的数据
示例 22-2 足够简单,但是feed['Schedule']['events'][40]['name']
这样的语法很繁琐。在 JavaScript 中,您可以通过编写feed.Schedule.events[40].name
来获取相同的值。在 Python 中,可以很容易地实现一个类似dict
的类来做同样的事情——网络上有很多实现。⁴ 我写了FrozenJSON
,比大多数方案更简单,因为它只支持读取:它只是用于探索数据。FrozenJSON
也是递归的,自动处理嵌套的映射和列表。
示例 22-3 是FrozenJSON
的演示,源代码显示在示例 22-4 中。
示例 22-3. FrozenJSON
来自示例 22-4,允许读取属性如name
,并调用方法如.keys()
和.items()
>>> import json >>> raw_feed = json.load(open('data/osconfeed.json')) >>> feed = FrozenJSON(raw_feed) # ① >>> len(feed.Schedule.speakers) # ② 357 >>> feed.keys() dict_keys(['Schedule']) >>> sorted(feed.Schedule.keys()) # ③ ['conferences', 'events', 'speakers', 'venues'] >>> for key, value in sorted(feed.Schedule.items()): # ④ ... print(f'{len(value):3} {key}') ... 1 conferences 484 events 357 speakers 53 venues >>> feed.Schedule.speakers[-1].name # ⑤ 'Carina C. Zona' >>> talk = feed.Schedule.events[40] >>> type(talk) # ⑥ <class 'explore0.FrozenJSON'> >>> talk.name 'There *Will* Be Bugs' >>> talk.speakers # ⑦ [3471, 5199] >>> talk.flavor # ⑧ Traceback (most recent call last): ... KeyError: 'flavor'
①
从由嵌套字典和列表组成的raw_feed
构建一个FrozenJSON
实例。
②
FrozenJSON
允许通过属性表示法遍历嵌套字典;这里显示了演讲者列表的长度。
③
也可以访问底层字典的方法,比如.keys()
,以检索记录集合名称。
④
使用items()
,我们可以检索记录集合的名称和内容,以显示每个集合的len()
。
⑤
一个list
,比如feed.Schedule.speakers
,仍然是一个列表,但其中的项目如果是映射,则转换为FrozenJSON
。
⑥
events
列表中的第 40 项是一个 JSON 对象;现在它是一个FrozenJSON
实例。
⑦
事件记录有一个带有演讲者序列号的speakers
列表。
⑧
尝试读取一个不存在的属性会引发KeyError
,而不是通常的AttributeError
。
FrozenJSON
类的关键是__getattr__
方法,我们已经在“Vector Take #3: Dynamic Attribute Access”中的Vector
示例中使用过它,通过字母检索Vector
组件:v.x
、v.y
、v.z
等。需要记住的是,只有在通常的过程无法检索属性时(即,当实例、类或其超类中找不到命名属性时),解释器才会调用__getattr__
特殊方法。
示例 22-3 的最后一行揭示了我的代码存在一个小问题:尝试读取一个不存在的属性应该引发AttributeError
,而不是显示的KeyError
。当我实现错误处理时,__getattr__
方法变得两倍长,分散了我想展示的最重要的逻辑。考虑到用户会知道FrozenJSON
是由映射和列表构建的,我认为KeyError
并不会太令人困惑。
示例 22-4. explore0.py:将 JSON 数据集转换为包含嵌套FrozenJSON
对象、列表和简单类型的FrozenJSON
from collections import abc class FrozenJSON: """A read-only façade for navigating a JSON-like object using attribute notation """ def __init__(self, mapping): self.__data = dict(mapping) # ① def __getattr__(self, name): # ② try: return getattr(self.__data, name) # ③ except AttributeError: return FrozenJSON.build(self.__data[name]) # ④ def __dir__(self): # ⑤ return self.__data.keys() @classmethod def build(cls, obj): # ⑥ if isinstance(obj, abc.Mapping): # ⑦ return cls(obj) elif isinstance(obj, abc.MutableSequence): # ⑧ return [cls.build(item) for item in obj] else: # ⑨ return obj
①
从mapping
参数构建一个dict
。这确保我们得到一个映射或可以转换为映射的东西。__data
上的双下划线前缀使其成为私有属性。
②
只有当没有具有该name
的属性时才会调用__getattr__
。
③
如果name
匹配实例__data
dict
中的属性,则返回该属性。这就是处理像feed.keys()
这样的调用的方式:keys
方法是__data
dict
的一个属性。
④
否则,从self.__data
中的键name
获取项目,并返回调用FrozenJSON.build()
的结果。⁵
⑤
实现__dir__
支持dir()
内置函数,这将支持标准 Python 控制台以及 IPython、Jupyter Notebook 等的自动补全。这段简单的代码将基于self.__data
中的键启用递归自动补全,因为__getattr__
会动态构建FrozenJSON
实例——对于交互式数据探索非常有用。
⑥
这是一个替代构造函数,@classmethod
装饰器的常见用法。
⑦
如果obj
是一个映射,用它构建一个FrozenJSON
。这是鹅类型的一个例子——如果需要复习,请参阅“鹅类型”。
⑧
如果它是一个MutableSequence
,它必须是一个列表,⁶因此我们通过递归地将obj
中的每个项目传递给.build()
来构建一个list
。
⑨
如果不是dict
或list
,则返回原样。
FrozenJSON
实例有一个名为_FrozenJSON__data
的私有实例属性,如“Python 中的私有和‘受保护’属性”中所解释的那样。尝试使用其他名称检索属性将触发__getattr__
。该方法首先查看self.__data
dict
是否具有该名称的属性(而不是键!);这允许FrozenJSON
实例处理dict
方法,比如通过委托给self.__data.items()
来处理items
。如果self.__data
没有具有给定name
的属性,__getattr__
将使用name
作为键从self.__data
中检索项目,并将该项目传递给FrozenJSON.build
。这允许通过build
类方法将 JSON 数据中的嵌套结构转换为另一个FrozenJSON
实例。
请注意,FrozenJSON
不会转换或缓存原始数据集。当我们遍历数据时,__getattr__
会一遍又一遍地创建FrozenJSON
实例。对于这个大小的数据集和仅用于探索或转换数据的脚本来说,这是可以接受的。
任何生成或模拟来自任意来源的动态属性名称的脚本都必须处理一个问题:原始数据中的键可能不适合作为属性名称。下一节将解决这个问题。
无效属性名称问题
FrozenJSON
代码不处理作为 Python 关键字的属性名称。例如,如果构建一个这样的对象:
>>> student = FrozenJSON({'name': 'Jim Bo', 'class': 1982})
你无法读取student.class
,因为class
是 Python 中的保留关键字:
>>> student.class File "<stdin>", line 1 student.class ^ SyntaxError: invalid syntax
当然你总是可以这样做:
>>> getattr(student, 'class') 1982
但FrozenJSON
的理念是提供对数据的便捷访问,因此更好的解决方案是检查传递给FrozenJSON.__init__
的映射中的键是否是关键字,如果是,则在其末尾添加_
,这样就可以像这样读取属性:
>>> student.class_ 1982
通过用示例 22-5 中的版本替换示例 22-4 中的一行__init__
,可以实现这一点。
示例 22-5. explore1.py:为 Python 关键字添加_
作为属性名称的后缀
def __init__(self, mapping): self.__data = {} for key, value in mapping.items(): if keyword.iskeyword(key): # ① key += '_' self.__data[key] = value
①
keyword.iskeyword(…)
函数正是我们需要的;要使用它,必须导入keyword
模块,这在这个片段中没有显示。
如果 JSON 记录中的键不是有效的 Python 标识符,可能会出现类似的问题:
>>> x = FrozenJSON({'2be':'or not'}) >>> x.2be File "<stdin>", line 1 x.2be ^ SyntaxError: invalid syntax
在 Python 3 中,这些有问题的键很容易检测,因为str
类提供了s.isidentifier()
方法,告诉您s
是否是根据语言语法的有效 Python 标识符。但将一个不是有效标识符的键转换为有效的属性名称并不是简单的。一个解决方案是实现__getitem__
,允许使用x['2be']
这样的表示法进行属性访问。为简单起见,我不会担心这个问题。
在考虑动态属性名称之后,让我们转向FrozenJSON
的另一个重要特性:build
类方法的逻辑。Frozen.JSON.build
被__getattr__
使用,根据正在访问的属性的值返回不同类型的对象:嵌套结构转换为FrozenJSON
实例或FrozenJSON
实例列表。
相同的逻辑可以实现为__new__
特殊方法,而不是类方法,我们将在下面看到。
使用__new__
进行灵活的对象创建
我们经常将__init__
称为构造方法,但这是因为我们从其他语言中采用了术语。在 Python 中,__init__
将self
作为第一个参数,因此当解释器调用__init__
时,对象已经存在。此外,__init__
不能返回任何内容。因此,它实际上是一个初始化器,而不是构造函数。
当调用类以创建实例时,Python 在该类上调用的特殊方法来构造实例是__new__
。它是一个类方法,但得到特殊处理,因此不适用@classmethod
装饰器。Python 获取__new__
返回的实例,然后将其作为__init__
的第一个参数self
传递。我们很少需要编写__new__
,因为从object
继承的实现对绝大多数用例都足够了。
如果必要,__new__
方法也可以返回不同类的实例。当发生这种情况时,解释器不会调用__init__
。换句话说,Python 构建对象的逻辑类似于这个伪代码:
# pseudocode for object construction def make(the_class, some_arg): new_object = the_class.__new__(some_arg) if isinstance(new_object, the_class): the_class.__init__(new_object, some_arg) return new_object # the following statements are roughly equivalent x = Foo('bar') x = make(Foo, 'bar')
示例 22-6 展示了FrozenJSON
的一个变体,其中前一个build
类方法的逻辑移至__new__
。
示例 22-6. explore2.py:使用__new__
而不是build
来构建可能是FrozenJSON
实例的新对象。
from collections import abc import keyword class FrozenJSON: """A read-only façade for navigating a JSON-like object using attribute notation """ def __new__(cls, arg): # ① if isinstance(arg, abc.Mapping): return super().__new__(cls) # ② elif isinstance(arg, abc.MutableSequence): # ③ return [cls(item) for item in arg] else: return arg def __init__(self, mapping): self.__data = {} for key, value in mapping.items(): if keyword.iskeyword(key): key += '_' self.__data[key] = value def __getattr__(self, name): try: return getattr(self.__data, name) except AttributeError: return FrozenJSON(self.__data[name]) # ④ def __dir__(self): return self.__data.keys()
①
作为类方法,__new__
的第一个参数是类本身,其余参数与__init__
得到的参数相同,除了self
。
②
默认行为是委托给超类的__new__
。在这种情况下,我们从object
基类调用__new__
,将FrozenJSON
作为唯一参数传递。
③
__new__
的其余行与旧的build
方法完全相同。
④
以前调用FrozenJSON.build
的地方;现在我们只需调用FrozenJSON
类,Python 会通过调用FrozenJSON.__new__
来处理。
__new__
方法将类作为第一个参数,因为通常创建的对象将是该类的实例。因此,在FrozenJSON.__new__
中,当表达式super().__new__(cls)
有效地调用object.__new__(FrozenJSON)
时,由object
类构建的实例实际上是FrozenJSON
的实例。新实例的__class__
属性将保存对FrozenJSON
的引用,即使实际的构造是由解释器的内部实现的object.__new__
在 C 中执行。
OSCON JSON 数据集的结构对于交互式探索并不有用。例如,索引为40
的事件,标题为'There *Will* Be Bugs'
,有两位演讲者,3471
和5199
。查找演讲者的姓名很麻烦,因为那些是序列号,而Schedule.speakers
列表不是按照它们进行索引的。要获取每位演讲者,我们必须遍历该列表,直到找到一个具有匹配序列号的记录。我们的下一个任务是重组数据,以准备自动检索链接记录。
我们在第十一章“可散列的 Vector2d”中首次看到@property
装饰器。在示例 11-7 中,我在Vector2d
中使用了两个属性,只是为了使x
和y
属性只读。在这里,我们将看到计算值的属性,从而讨论如何缓存这些值。
OSCON JSON 数据中的'events'
列表中的记录包含指向'speakers'
和'venues'
列表中记录的整数序列号。例如,这是会议演讲的记录(省略了描述):
{ "serial": 33950, "name": "There *Will* Be Bugs", "event_type": "40-minute conference session", "time_start": "2014-07-23 14:30:00", "time_stop": "2014-07-23 15:10:00", "venue_serial": 1449, "description": "If you're pushing the envelope of programming...", "website_url": "http://oscon.com/oscon2014/public/schedule/detail/33950", "speakers": [3471, 5199], "categories": ["Python"] }
我们将实现一个具有venue
和speakers
属性的Event
类,以便自动返回链接数据,换句话说,“解引用”序列号。给定一个Event
实例,示例 22-7 展示了期望的行为。
示例 22-7。读取venue
和speakers
返回Record
对象
>>> event # ① <Event 'There *Will* Be Bugs'> >>> event.venue # ② <Record serial=1449> >>> event.venue.name # ③ 'Portland 251' >>> for spkr in event.speakers: # ④ ... print(f'{spkr.serial}: {spkr.name}') ... 3471: Anna Martelli Ravenscroft 5199: Alex Martelli
①
给定一个Event
实例…
②
…读取event.venue
返回一个Record
对象,而不是一个序列号。
③
现在很容易获取venue
的名称。
④
event.speakers
属性返回一个Record
实例列表。
和往常一样,我们将逐步构建代码,从Record
类和一个函数开始,该函数读取 JSON 数据并返回一个带有Record
实例的dict
。
步骤 1:基于数据创建属性
示例 22-8 展示了指导这一步骤的 doctest。
示例 22-8. 测试 schedule_v1.py(来自示例 22-9)
>>> records = load(JSON_PATH) # ① >>> speaker = records['speaker.3471'] # ② >>> speaker # ③ <Record serial=3471> >>> speaker.name, speaker.twitter # ④ ('Anna Martelli Ravenscroft', 'annaraven')
①
load
一个带有 JSON 数据的dict
。
②
records
中的键是由记录类型和序列号构建的字符串。
③
speaker
是在示例 22-9 中定义的Record
类的实例。
④
可以将原始 JSON 中的字段作为Record
实例属性检索。
schedule_v1.py的代码在示例 22-9 中。
示例 22-9. schedule_v1.py:重新组织 OSCON 日程数据
import json JSON_PATH = 'data/osconfeed.json' class Record: def __init__(self, **kwargs): self.__dict__.update(kwargs) # ① def __repr__(self): return f'<{self.__class__.__name__} serial={self.serial!r}>' # ② 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] # ⑥ for raw_record in raw_records: key = f'{record_type}.{raw_record["serial"]}' # ⑦ records[key] = Record(**raw_record) # ⑧ return records
①
这是一个常见的快捷方式,用关键字参数构建属性的实例(详细解释如下)。
②
使用serial
字段构建自定义的Record
表示,如示例 22-8 所示。
③
load
最终将返回Record
实例的dict
。
④
解析 JSON,返回本机 Python 对象:列表、字典、字符串、数字等。
⑤
迭代四个名为'conferences'
、'events'
、'speakers'
和'venues'
的顶级列表。
⑥
record_type
是列表名称去掉最后一个字符,所以speakers
变成speaker
。在 Python ≥ 3.9 中,我们可以更明确地使用collection.removesuffix('s')
来做到这一点——参见PEP 616—删除前缀和后缀的字符串方法。
⑦
构建格式为'speaker.3471'
的key
。
⑧
创建一个Record
实例,并将其保存在带有key
的records
中。
Record.__init__
方法展示了一个古老的 Python 技巧。回想一下,对象的__dict__
是其属性所在的地方——除非在类中声明了__slots__
,就像我们在“使用 slots 节省内存”中看到的那样。因此,使用映射更新实例__dict__
是一种快速创建该实例中一堆属性的方法。⁷
注意
根据应用程序的不同,Record
类可能需要处理不是有效属性名称的键,就像我们在“无效属性名称问题”中看到的那样。处理这个问题会分散这个示例的关键思想,并且在我们正在读取的数据集中并不是一个问题。
在示例 22-9 中Record
的定义是如此简单,以至于你可能会想为什么我没有在之前使用它,而是使用更复杂的FrozenJSON
。有两个原因。首先,FrozenJSON
通过递归转换嵌套映射和列表来工作;Record
不需要这样做,因为我们转换的数据集中没有映射嵌套在映射或列表中。记录只包含字符串、整数、字符串列表和整数列表。第二个原因:FrozenJSON
提供对嵌入的__data
dict
属性的访问——我们用它来调用像.keys()
这样的方法——现在我们也不需要那个功能了。
注意
Python 标准库提供了类似于Record
的类,其中每个实例都有一个从给定给__init__
的关键字参数构建的任意属性集:types.SimpleNamespace
、argparse.Namespace
和multiprocessing.managers.Namespace
。我编写了更简单的Record
类来突出显示基本思想:__init__
更新实例__dict__
。
重新组织日程数据集后,我们可以增强Record
类,自动检索event
记录中引用的venue
和speaker
记录。我们将在接下来的示例中使用属性来实现这一点。
第 2 步:检索链接记录的属性
下一个版本的目标是:给定一个event
记录,读取其venue
属性将返回一个Record
。这类似于 Django ORM 在访问ForeignKey
字段时的操作:您将获得链接的模型对象,而不是键。
我们将从venue
属性开始。查看示例 22-10 中的部分交互作为示例。
示例 22-10. 从 schedule_v2.py 的 doctests 中提取
>>> event = Record.fetch('event.33950') # ① >>> event # ② <Event 'There *Will* Be Bugs'> >>> event.venue # ③ <Record serial=1449> >>> event.venue.name # ④ 'Portland 251' >>> event.venue_serial # ⑤ 1449
①
Record.fetch
静态方法从数据集中获取一个Record
或一个Event
。
②
请注意,event
是Event
类的一个实例。
③
访问event.venue
将返回一个Record
实例。
④
现在很容易找出event.venue
的名称。
⑤
Event
实例还具有来自 JSON 数据的venue_serial
属性。
Event
是Record
的一个子类,添加了一个venue
来检索链接的记录,以及一个专门的__repr__
方法。
本节的代码位于schedule_v2.py模块中,位于Fluent Python代码库中。示例有近 60 行,所以我将分部分呈现,从增强的Record
类开始。
流畅的 Python 第二版(GPT 重译)(十二)(2)https://developer.aliyun.com/article/1485176