在 Python 中,数据属性和方法统称属性(attribute)。其实,方法是可调用的属性。动态属性(dynamic attribute)的接口与数据属性一样(obj.attr),不过按需计算。这与 Bertrand Meyer 所说的统一访问原则(Uniform Access Principle)相符。
在 Python 中,实现动态属性的方式有好几种,例如 @property装饰器 和 __getattr__ 特殊方法。 用户定义的类通过 __getattr__ 方法可以实现一种称之为虚拟属性(irtual attribute)的动态属性。虚拟属性在类的源码中没有显式声明,也不出现在实例属性 dict 中,但是我们随时都能访问,而且当用户读取不存在的属性(例如 obj.no_such_attr)时,还能即时计算。
创建动态属性和虚拟属性是一种元编程。
使用动态属性转换数据
对于嵌套比较深的字典数据,我们可能需要比较复杂的表达式来获取值,例如 feed['Schedule']['events'][40]['name'],,在 Python 中,可以实现一个近似字典的类,然后通过 feed.Schedule.events[40].name 达到同样的效果。
1 | from collections import abc |
- FrozenJSON 类的关键是
__getattr__方法 - 仅当无法通过常规方式获取属性(例如,在实例、类或超类中找不到指定的属性)时,解释器才调用特殊方法
__getattr__ - 使用 mapping 参数构建一个字典。这么做是为了确保传入的映射或其他对象可以转换成字典。__data 前面有两条下划线,是私有属性
- 如果 name 匹配 __data 字典的某个属性,就返回对应的属性。
feed.keys()调用就是这样处理的:keys 方法是 __data 字典的一个属性。这样 FrozenJSON 实例便可以处理 dict 的方法 - 否则,从
self.__data中获取 name 键对应的项,返回调用FrozenJSON.build()方法得到的结果 - 实现为内置函数
dir()提供支持的__dir__方法,进而支持在 Python 标准控制台,以及 IPython、Jupyter Notebook 等中自动补全 - 注意,FrozenJSON 不转换或缓存原始数据集。在遍历数据的过程中,
__getattr__方法不断创建FrozenJSON 实例
从随机源中生成或仿效动态属性名的脚本必须处理一个问题:原始数据中的键可能不适合作为属性名。例如FrozenJSON 类不能处理与 Python 关键字同名的属性名。在 Python3 中,str 类提供的 s.isidentifier() 方法能根据语言的语法判断 s 是否为有效的 Python 标识符,通过 iskeyword() 能判断是否为关键字。
我们通常把 __init__ 称为构造方法,这是从其他语言借鉴过来的术语。在 Python 中,__init__ 的第一个参数是 self,可见在解释器调用 __init__ 时,对象已经存在。另外,init 方法什么也不能返回。
调用类创建实例时,为了构建实例,Python 调用的特殊方法是 __new__。这是一个类方法,以特殊方式对待,因此不必使用 @classmethod 装饰器:
- Python 会把
__new__返回的实例传给__init__的第一个参数 self - 我们几乎不需要自己编写
__new__方法,因为从 object 类继承的实现已经可以满足大多数情况 - 如有必要,__new__方法也可以返回其他类的实例。此时,解释器不调用
__init__方法 __new__方法,可以把类变成工厂函数,生成不同类型的对象,或者返回事先构建好的实例,而不是每次都创建一个新实例
Python 构建对象的过程可以使用以下伪代码概括:
1 | def make(the_class, some_arg): |
如下是 FrozenJSON 类的另一个版本,把之前类方法 build 中的逻辑移到了 __new__ 方法中:
1 | from collections import abc |
__new__是类方法,第一个参数是类本身,余下的参数与__init__方法一样,只不过没有 self- 现在通过调用
FrozenJSON(self.__data[name])即可创建实例
__new__ 方法的第一个参数是类,因为创建的对象通常是那个类的实例。所以:
- 在
FrozenJSON.__new__方法中,super().__new__(cls)表达式将调用object.__new__(FrozenJSON) - object 类构建的实例其实是 FrozenJSON 实例,新实例的
__class__属性将存储对 FrozenJSON 类的引用
计算特性
接下来将实现一个 Event 类,通过 venue 特性和 speakers 特性自动获取链接的数据,免去使用编号查找的麻烦。如下首先实现一个 Record 类型:
1 | import inspect |
Record.__init__方法用到了一个古老的 Python 编程技巧,一个对象的__dict__存储着对象的属性,除非类声明了__slots__,因此,使用一个映射更新实例的__dict__可以快速为实例创建一大批属性- __index 私有类属性最终存放一个对 load 函数返回的字典的引用
- 通过
Record.__index获取指定 key 对应的记录 - 使用 staticmethod 装饰 fetch 方法,以此强调其效果不受调用它的实例或类的影响(声明为类方法会让人误解,因为用不到第一个参数 cls)
Event 扩展 Record:
1 | class Event(Record): |
- venue 特性根据
venue_serial属性构建一个 key,传给从 Record 继承的类方法 fetch - 这里之所以调用
self.__class__.fetch(key)而不是直接调用self.fetch(key)是为了避免 Record 中出现了fetch的键,覆盖了类方法 - 从数据中创建实例属性的名称时,很容易遮盖类属性(例如方法),导致 bug,或者覆盖现有的实例属性,导致数据丢失。
- 如果 Record 类的行为更像是映射,实现了动态的
__getitem__方法,而不是动态的__getattr__方法,那么就不会受到覆盖或遮盖的影响 - 为了获取 speakers 属性的值,必须从实例属性
__dict__中直接获取,以免递归调用 speakers 特性 - 求解
obj.my_attr时,解释器首先检查 obj 的类,如果类有名为 my_attr 的特性,那么同名实例属性就会被遮盖,特性是以描述符(一种更强大且更一般的抽象)实现的
load 方法的代码如下:
1 | def load(path=JSON_PATH): |
- 把 record_type 的首字母变成大写,尝试获取类名,例如,
event变成Event - 从模块全局作用域中获取对应名称的对象。如果不存在相应的对象,就获取 Record 类
- 如果得到的对象是类,而且是 Record 的子类,就绑定 factory 名称。这意味着,factory 可以是 Record 的任何子类,具体取决于 record_type
- 否则,把 factory 名称绑定为 Record
自己实现特性缓存
特性经常需要缓存,因为普遍预期 event.venue 之类的表达式不消耗什么资源。如果 Event 中的特性内部用到的 Record.fetch 方法需要查询数据库或 Web API,则缓存必不可少。
如下代码自己实现缓存逻辑:
1 |
|
- 实现的缓存很简单,不过在实例初始化之后创建属性违背了
PEP 412—Key-Sharing Dictionary提出的优化措施。如果数据集体量较大,那么内存用量上的差异还是很大的
自己实现缓存方案时,如果想兼顾键共享优化,则需要在 Event 类中定义 __init__ 方法,把 __speaker_objs 初始化为 None,然后在 speakers 方法中再做检查:
1 | class Event(Record): |
然而,在多线程程序中,像这样自己实现的缓存容易引入竞争条件,导致数据损坏。如果两个线程同时读取一个尚未缓存的特性,那么第一个线程需要把计算得到的数据存入缓存属性(本例中的 __speaker_objs)名下,当第二个线程从缓存中读取数据时,计算过程可能还未结束。
使用 functools 缓存特性
functools.cached_property 装饰器把方法的结果缓存在同名实例属性中:
1 |
|
之前说过,特性会遮盖同名实例属性。既然如此,@cached_property 是如何运作的呢?如果特性覆盖了实例属性,那么 venue 属性将被忽略,始终调用 venue 方法,每次都计算 key 并运行 fetch。背后的答案是:@cached_property 装饰器不创建完整的特性,而是创建一个非覆盖型描述符(nonoverriding descriptor)。描述符是一种对象,负责管理如何访问另一个类的属性。property 则装饰器是一个高级 API,用于创建覆盖型描述符(overriding descriptor)。后续文章会将全面说明描述符、覆盖型描述符与非覆盖型描述符之间的区别。
cached_property() 的机制与 property() 不太一样。常规特性阻止属性写入,除非定义了设值方法。与之相反,cached_property 允许写入。
cached_property装饰器仅在执行查找且不存在同名属性时运行- 一旦运行,
cached_property就会写入同名属性。后续的属性读取和写入操作优先于cached_property方法,行为就像普通的属性一样 - 缓存的值可通过删除属性清空。如此一来,cached_property 方法将再次运行
@cached_property 有一些不可忽略的局限:
- 如果被装饰的方法已经依赖一个同名实例属性,则不能直接替代
@property - 不能在定义了
__slots__的类中使用 - 违背对实例属性__dict__的键共享优化,因为需要在初始化之后创建一个实例属性
鉴于 @cached_property 的这种行为,不太适合用于装饰 speakers,因为该方法依赖一个同名的现有属性。speakers 特性可以使用 @cached_property 文档推荐的另一种方案:叠放装饰器 @property 和@cache:
1 |
|
使用特性验证属性
除了计算属性值,特性还可用于实施业务规则,把公开属性变成受读值方法和设值方法保护的属性,而客户代码不受影响。如下代码是一个例子:
- 原始代码:
1 | class LineItem: |
- 使用特性改造的代码:
1 | class LineItem: |
- @property 装饰读值方法
- 被装饰的读值方法有一个
.setter属性,这个属性也是装饰器:把读值方法和设值方法绑定在一起 - 在设值方法中,检查传入的值是否大于 0,如果不是,则抛出 ValueError
__init__方法中的self.weight = weight已经使用特性的设值方法了,确保所创建实例的 weight属性不能为负值
抽象特性的定义有两种方式:一是使用特性工厂函数,二是使用描述符类。后者更灵活。其实,特性本身就是使用描述符类实现的。
特性全解析
虽然内置的 property 经常用作装饰器,但它其实是一个类。在 Python 中,函数和类通常可以互换,因为二者都是可调用对象,而且 Python 没有实例化对象的 new 运算符,调用构造函数与调用工厂函数没有区别。此外,只要能返回新的可调用对象,取代被装饰的函数,二者都可以用作装饰器。
property 构造函数的完整签名如下所示。
1 | property(fget=None, fset=None, fdel=None, doc=None) |
所有参数都是可选的,如果没有把函数传给某个参数,那么得到的特性对象就不允许执行相应的操作。不使用装饰器定义特性的 经典 句法如下所示:
1 | class LineItem: |
类中的特性能影响实例属性的寻找方式,接下来将详细解释。
特性覆盖实例属性
特性是类属性,但是特性管理的其实是实例属性的存取。之前介绍过,如果实例和所属的类有同名数据属性,那么实例属性就覆盖(或称遮盖)类属性——至少通过实例读取属性时是这样。
1 | class Class: |
- 为
obj.data赋值,创建一个实例属性 - 之后,从 obj 实例中读取属性时,实例属性 data 遮盖类属性 data
下面尝试覆盖 obj 实例的 prop 特性
1 | obj.prop |
- 读取
obj.prop执行特性的读值方法 - 尝试设置
prop实例属性,结果失败了 - 但是可以直接把
prop存入obj.__dict__,此时可以成功添加实例属性 - 但是,读取
obj.prop时仍会运行特性的读值方法。特性未被实例属性遮盖 - 覆盖
Class.prop特性,销毁特性对象 - 现在,
obj.prop获取的是实例属性。Class.prop不是特性了,因此不再覆盖obj.prop
最后再举一个例子,为 Class 类增加一个特性,覆盖实例属性。
1 | obj.data |
- 使用新特性覆盖
Class.data - 现在
obj.data被Class.data特性遮盖了 - 删除特性后,则恢复原样
这里的核心观点是,像 obj.data 这样的表达式:不会从 obj 而是从 obj.__class__ 开始寻找 data,而且仅当类中没有名为 data 的特性时,Python 才会在 obj 实例中寻找。这条规则适用于全体覆盖型描述符,包括特性。
特性的文档
当控制台中的 help() 函数或 IDE 等工具需要显示特性的文档时,会从特性的 __doc__ 属性中提取信息。
- 如果使用经典调用句法,那么
property对象的文档通过 doc 参数设置
1 | weight = property(get_weight, set_weight, doc='weight in kilograms') |
- 读值方法(有
@property装饰器的方法)的文档字符串作为一个整体,变成了特性的文档
定义一个特性工厂函数
如果要保护 LineItem 对象的 weight 属性和 price 属性,只允许设为大于零的值,但是不用手动实现两对几乎一样的读值方法和设值方法,我们将定义一个名为 quantity 的特性工厂函数。
1 | class LineItem: |
- 使用工厂函数把自定义的特性 weight、price 定义为类属性
- 特性是类属性。构建各个 quantity 特性对象时,要传入 LineItem 实例属性的名称,让特性管理
这行代码有个问题,weight 被输入两遍。如果想改进 quantity 特性,避免用户重复输入属性名,那么这对元编程来说是个挑战,后面会介绍如何解决这个问题。
首先来看 quantity 工厂函数的实现:
1 | def quantity(storage_name): |
- storage_name 参数确定各个特性的数据存储在哪里。对 weight 特性来说,存储的名称是
weight - qty_getter/qty_setter 函数的 instance 参数指代要把属性存储其中的 LineItem 实例
- qty_getter 引用了 storage_name,把它保存在这个函数的闭包里。值直接从
instance.__dict__中获取,以绕过特性,防止无限递归。qty_setter 函数也是类似的处理 - 通过
property函数,构建一个自定义的特性对象,然后将其返回
这个工厂函数将属性名作为参数(而不是硬编码),要依靠 storage_name 变量来判断从 __dict__ 中获取哪个属性,或者设置哪个属性。在真实的系统中,分散在多个类中的多个字段可能要做同样的验证,因此最好把quantity工厂函数放在实用工具模块中,以便重复使用。后面我们还将重构这个简单的工厂函数,改成更易扩展的描述符类,使用专门的子类执行不同的验证。
1 | nutmeg = LineItem('Moluccan nutmeg', 8, 13.95) |
weight 特性覆盖了 weight 实例属性,因此对 self.weight 或 nutmeg.weight 的每个引用都由特性函数处理,只有直接存取 __dict__ 属性才能绕过特性的处理逻辑。
处理属性删除操作
del 语句不仅可以删除变量,也可以删除属性。
1 | class Demo(): |
Python 编程不常删除属性,通过特性删除属性则更少见。但是,Python 支持这么做。定义特性时,可以使用@my_propety.deleter 装饰器包装一个方法,负责删除特性管理的属性。如下是个例子:
1 | class BlackKnight: |
1 | knight = BlackKnight() |
在不使用装饰器的经典调用句法中,fdel 参数用于设置删值函数,因此也可以这样创建 member 特性:
1 | member = property(member_getter, fdel=member_deleter) |
如果不使用特性,则还可以实现底层特殊方法 __delattr__,处理删除属性的操作。特性是一个强大的功能,不过有时更适合使用简单的或底层的替代方案。
处理属性的重要属性和函数
接下来将简单介绍 Python 为处理动态属性而提供的内置函数和特殊方法。
影响属性处理方式的特殊属性
__class__:对象所属类的引用(例如,obj.__class__与type(obj)的作用相同)。Python的某些特殊方法(例如__getattr__),只在对象的类中而不在实例中寻找__dict__:存储对象或类的可写属性的映射。有 dict 属性的对象,任何时候都能随意设置新属性。对于有__slots__属性的类,其实例可能没有__dict__属性__slots__:类可以定义这个属性,节省内存。__slots__属性的值是一个字符串元组,列出了允许有的属性。如果__slots__中没有__dict__,那么该类的实例就没有__dict__属性,而只允许有__slots__中列出的属性
处理属性的内置函数
以下 5 个内置函数会对对象的属性做读、写和内省操作:
-
dir([object]):列出对象的大多数属性。官方文档说,dir 函数的目的是交互式使用,因此没有提供完整的属性列表,只列出了一组重要的属性名。实现特殊方法__dir__可以自定义 dir 函数的输出。如果没有指定可选的 object 参数,则 dir 函数会列出当前作用域中的名称 -
vars([object]):返回 object 对象的__dict__属性。如果实例所属的类定义了__slots__属性且实例没有__dict__属性,那么 vars 函数就不能处理(相反,dir函数能处理)这样的实例。如果没有指定参数,那么vars()函数的作用与locals()函数一样:返回表示本地作用域的字典 -
getattr(object, name[, default])从 object 对象中获取 name 字符串对应的属性。主要用于获取事先不知道名称的属性(或方法)。获取的属性可能来自对象所属的类或超类。如果指定的属性不存在,则 getattr 函数会抛出 AttributeError 异常,或者返回 default 参数的值
1 | class T(): |
hasattr(object, name)如果 object 对象中存在指定的属性,或者能以某种方式(例如继承)通过object 对象获取指定的属性,那么就返回 True。这个函数的实现方法是调用getattr(object, name)函数,看看是否抛出 AttributeError 异常setattr(object, name, value):把 object 对象指定属性的值设为 value,前提是 object 对象能接受提供的值。这可能创建一个新属性,也可能覆盖现有属性
处理属性的特殊方法
在用户定义的类中,以下特殊方法用于获取、设置、删除和列出属性。使用点号表示法或内置的函数 getattr、hasattr 和 setattr 存取属性都会触这些相应的特殊方法。
对用户自己定义的类来说,如果隐式调用特殊方法,那么仅当特殊方法在对象所属的类型上而不是在对象的实例字典中定义时,才能确保调用成功。也就是说,要假定特殊方法从类上获取,即便操作目标是实例也是如此。因此,特殊方法不被同名实例属性遮盖。
假设有一个名为 Class 的类,obj 是 Class 类的实例,attr 是 obj 的属性。不管使用点号表示法存取属性,还是上面列出的某个内置函数,都会触发以下特殊方法中的一个。
-
obj.attr和getattr(obj, 'attr', 42)、hasattr都会触发Class.__getattribute__(obj, 'attr')方法。在 Python 代码中尝试直接获取指定名称的属性时始终调用这个方法。为了在获取 obj 实例的属性时不导致无限递归,__getattribute__方法的实现要使用super().__getattribute__(obj, name) -
__getattr__(self, name)仅当获取指定的属性失败,搜索过 obj、Class 及其超类之后会调用这个方法- 表达式
obj.no_such_attr、getattr(obj, 'no_such_attr')和hasattr(obj, 'no_such_attr')可能触发Class.__getattr__(obj, 'no_such_attr'),但是,仅当在 obj、Class 及其超类中找不到指定的属性时才触发 - 也就是说,
__getattr__仅在__getattribute__之后,而且仅当__getattribute__抛出 AttributeError 时调用
- 表达式
-
尝试设置指定名称的属性时总会调用
__setattr__(self, name, value)这个方法。点号表示法和内置函数 setattr 会触发这个方法。例如,obj.attr = 42和setattr(obj, 'attr', 42)都会触发Class.__setattr__(obj, 'attr', 42) -
只要使用 del 语句删除属性,就会调用
__delattr__(self, name)这个方法。例如:del obj.attr语句触发Class.__delattr__(obj, 'attr')。如果 attr 是一个特性,而且类实现了__delattr__方法,则永不调用特性的删值方法 -
dir(obj)触发Class.__dir__(obj)以列出属性
最后看一个例子,涉及数据描述符和 __getattribute__ 方法:
1 | class LoggedAttribute: |
1 | Descriptor __set__ called for x = 10 |