0%

流畅的 Python 第 2 版(22):动态属性和特性

在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from collections import abc


class FrozenJSON:
"""一个只读接口,该接口使用属性表示法访问JSON类对象
"""

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
  • 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
2
3
4
5
6
7
8
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

x = Foo('bar')
x = make(Foo, 'bar')

如下是 FrozenJSON 类的另一个版本,把之前类方法 build 中的逻辑移到了 __new__ 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from collections import abc
import keyword

class FrozenJSON:
"""一个只读接口,该接口使用属性表示法访问JSON类对象
"""
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
  • 现在通过调用 FrozenJSON(self.__data[name]) 即可创建实例

__new__ 方法的第一个参数是类,因为创建的对象通常是那个类的实例。所以:

  • FrozenJSON.__new__ 方法中,super().__new__(cls) 表达式将调用 object.__new__(FrozenJSON)
  • object 类构建的实例其实是 FrozenJSON 实例,新实例的 __class__ 属性将存储对 FrozenJSON 类的引用

计算特性

接下来将实现一个 Event 类,通过 venue 特性和 speakers 特性自动获取链接的数据,免去使用编号查找的麻烦。如下首先实现一个 Record 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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]
  • Record.__init__ 方法用到了一个古老的 Python 编程技巧,一个对象的 __dict__ 存储着对象的属性,除非类声明了 __slots__,因此,使用一个映射更新实例的 __dict__ 可以快速为实例创建一大批属性
  • __index 私有类属性最终存放一个对 load 函数返回的字典的引用
  • 通过 Record.__index 获取指定 key 对应的记录
  • 使用 staticmethod 装饰 fetch 方法,以此强调其效果不受调用它的实例或类的影响(声明为类方法会让人误解,因为用不到第一个参数 cls)

Event 扩展 Record:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)

@property
def speakers(self):
spkr_serials = self.__dict__['speakers']
fetch = self.__class__.fetch
return [fetch(f'speaker.{key}')
for key in spkr_serials]
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
  • 把 record_type 的首字母变成大写,尝试获取类名,例如,event 变成 Event
  • 从模块全局作用域中获取对应名称的对象。如果不存在相应的对象,就获取 Record 类
  • 如果得到的对象是类,而且是 Record 的子类,就绑定 factory 名称。这意味着,factory 可以是 Record 的任何子类,具体取决于 record_type
  • 否则,把 factory 名称绑定为 Record

自己实现特性缓存

特性经常需要缓存,因为普遍预期 event.venue 之类的表达式不消耗什么资源。如果 Event 中的特性内部用到的 Record.fetch 方法需要查询数据库或 Web API,则缓存必不可少。

如下代码自己实现缓存逻辑:

1
2
3
4
5
6
7
8
@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
  • 实现的缓存很简单,不过在实例初始化之后创建属性违背了 PEP 412—Key-Sharing Dictionary 提出的优化措施。如果数据集体量较大,那么内存用量上的差异还是很大的

自己实现缓存方案时,如果想兼顾键共享优化,则需要在 Event 类中定义 __init__ 方法,把 __speaker_objs 初始化为 None,然后在 speakers 方法中再做检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Event(Record):
def __init__(self, **kwargs):
self.__speaker_objs = None
super().__init__(**kwargs)

@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

然而,在多线程程序中,像这样自己实现的缓存容易引入竞争条件,导致数据损坏。如果两个线程同时读取一个尚未缓存的特性,那么第一个线程需要把计算得到的数据存入缓存属性(本例中的 __speaker_objs)名下,当第二个线程从缓存中读取数据时,计算过程可能还未结束。

使用 functools 缓存特性

functools.cached_property 装饰器把方法的结果缓存在同名实例属性中:

1
2
3
4
@cached_property
def venue(self):
key = f'venue.{self.venue_serial}'
return self.__class__.fetch(key)

之前说过,特性会遮盖同名实例属性。既然如此,@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
2
3
4
5
6
7
@property
@cache
def speakers(self):
spkr_serials = self.__dict__['speakers']
fetch = self.__class__.fetch
return [fetch(f'speaker.{key}')
for key in spkr_serials]

使用特性验证属性

除了计算属性值,特性还可用于实施业务规则,把公开属性变成受读值方法和设值方法保护的属性,而客户代码不受影响。如下代码是一个例子:

  • 原始代码:
1
2
3
4
5
6
7
8
9
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
  • 使用特性改造的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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')
  • @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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)

类中的特性能影响实例属性的寻找方式,接下来将详细解释。

特性覆盖实例属性

特性是类属性,但是特性管理的其实是实例属性的存取。之前介绍过,如果实例和所属的类有同名数据属性,那么实例属性就覆盖(或称遮盖)类属性——至少通过实例读取属性时是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> 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'
  • obj.data 赋值,创建一个实例属性
  • 之后,从 obj 实例中读取属性时,实例属性 data 遮盖类属性 data

下面尝试覆盖 obj 实例的 prop 特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> obj.prop
'the prop value'
>>> obj.prop = "foo"
Traceback (most recent call last):
File "<python-input-10>", line 1, in <module>
obj.prop = "foo"
^^^^^
AttributeError: property 'prop' of 'Class' object has no setter

>>> obj.__dict__['prop'] = 'foo'
>>> vars(obj)
{'data': 'bar', 'prop': 'foo'}

>>> obj.prop
'the prop value'

>>> Class.prop = 'baz'
>>> obj.prop
'foo'
  • 读取 obj.prop 执行特性的读值方法
  • 尝试设置 prop 实例属性,结果失败了
  • 但是可以直接把 prop 存入 obj.__dict__,此时可以成功添加实例属性
  • 但是,读取 obj.prop 时仍会运行特性的读值方法。特性未被实例属性遮盖
  • 覆盖 Class.prop 特性,销毁特性对象
  • 现在,obj.prop 获取的是实例属性。Class.prop 不是特性了,因此不再覆盖 obj.prop

最后再举一个例子,为 Class 类增加一个特性,覆盖实例属性。

1
2
3
4
5
6
7
8
9
10
>>> obj.data
'bar'
>>> Class.data
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" prop value')
>>> obj.data
'the "data" prop value'
>>> del Class.data
>>> obj.data
'bar'
  • 使用新特性覆盖 Class.data
  • 现在 obj.dataClass.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
2
3
4
5
6
7
8
9
10
11
class LineItem:
weight = quantity('weight')
price = quantity('price')

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price
  • 使用工厂函数把自定义的特性 weight、price 定义为类属性
  • 特性是类属性。构建各个 quantity 特性对象时,要传入 LineItem 实例属性的名称,让特性管理

这行代码有个问题,weight 被输入两遍。如果想改进 quantity 特性,避免用户重复输入属性名,那么这对元编程来说是个挑战,后面会介绍如何解决这个问题。

首先来看 quantity 工厂函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
def quantity(storage_name):

def qty_getter(instance):
return instance.__dict__[storage_name]

def qty_setter(instance, value):
if value > 0:
instance.__dict__[storage_name] = value
else:
raise ValueError('value must be > 0')

return property(qty_getter, qty_setter)
  • storage_name 参数确定各个特性的数据存储在哪里。对 weight 特性来说,存储的名称是 weight
  • qty_getter/qty_setter 函数的 instance 参数指代要把属性存储其中的 LineItem 实例
  • qty_getter 引用了 storage_name,把它保存在这个函数的闭包里。值直接从 instance.__dict__ 中获取,以绕过特性,防止无限递归。qty_setter 函数也是类似的处理
  • 通过 property 函数,构建一个自定义的特性对象,然后将其返回

这个工厂函数将属性名作为参数(而不是硬编码),要依靠 storage_name 变量来判断从 __dict__ 中获取哪个属性,或者设置哪个属性。在真实的系统中,分散在多个类中的多个字段可能要做同样的验证,因此最好把quantity工厂函数放在实用工具模块中,以便重复使用。后面我们还将重构这个简单的工厂函数,改成更易扩展的描述符类,使用专门的子类执行不同的验证。

1
2
3
4
5
>>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95)
>>> nutmeg.weight, nutmeg.price
(8, 13.95)
>>> nutmeg.__dict__
{'description': 'Moluccan nutmeg', 'weight': 8, 'price': 13.95}

weight 特性覆盖了 weight 实例属性,因此对 self.weightnutmeg.weight 的每个引用都由特性函数处理,只有直接存取 __dict__ 属性才能绕过特性的处理逻辑。

处理属性删除操作

del 语句不仅可以删除变量,也可以删除属性

1
2
3
4
5
6
7
8
9
10
11
12
>>> class Demo():
... pass
...
>>> d = Demo()
>>> d.x = 1
>>> d.x
1
>>> del d.x
>>> d.x
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Demo' object has no attribute 'x'

Python 编程不常删除属性,通过特性删除属性则更少见。但是,Python 支持这么做。定义特性时,可以使用@my_propety.deleter 装饰器包装一个方法,负责删除特性管理的属性。如下是个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BlackKnight:

def __init__(self):
self.phrases = [
('an arm', "'Tis but a scratch."),
('another arm', "It's just a flesh wound."),
('a leg', "I'm invincible!"),
('another leg', "All right, we'll call it a draw.")
]

@property
def member(self):
print('next member is:')
return self.phrases[0][0]

@member.deleter
def member(self):
member, text = self.phrases.pop(0)
print(f'BLACK KNIGHT (loses {member}) -- {text}')
1
2
3
4
5
6
7
8
9
10
11
12
>>> knight = BlackKnight()
>>> knight.member
next member is:
'an arm'
>>> del knight.member
BLACK KNIGHT (loses an arm) -- 'Tis but a scratch.
>>> del knight.member
BLACK KNIGHT (loses another arm) -- It's just a flesh wound.
>>> del knight.member
BLACK KNIGHT (loses a leg) -- I'm invincible!
>>> del knight.member
BLACK KNIGHT (loses another leg) -- All right, we'll call it a draw.

在不使用装饰器的经典调用句法中,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
2
3
4
5
6
7
8
9
10
11
12
13
>>> class T():
... t = 1
... def f(): pass
...
>>> tt = T()
>>> getattr(tt, "t")
1
>>> getattr(tt, "f")
<bound method T.f of <__main__.T object at 0x7fa676ae38f0>>
>>> getattr(T, "t")
1
>>> getattr(tt, "t")
1
  • 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.attrgetattr(obj, 'attr', 42)hasattr 都会触发 Class.__getattribute__(obj, 'attr') 方法。在 Python 代码中尝试直接获取指定名称的属性时始终调用这个方法。为了在获取 obj 实例的属性时不导致无限递归,__getattribute__ 方法的实现要使用 super().__getattribute__(obj, name)

  • __getattr__(self, name) 仅当获取指定的属性失败,搜索过 obj、Class 及其超类之后会调用这个方法

    • 表达式 obj.no_such_attrgetattr(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 = 42setattr(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class LoggedAttribute:
def __init__(self, name):
self.name = name

def __get__(self, obj, objtype=None):
print(f"Descriptor __get__ called for {self.name}")
return obj.__dict__.get(self.name, "default")

def __set__(self, obj, value):
print(f"Descriptor __set__ called for {self.name} = {value}")
obj.__dict__[self.name] = value

class MyClass:
x = LoggedAttribute("x")

def __getattribute__(self, name):
print(f"__getattribute__ called for: {name}")
return super().__getattribute__(name)

obj = MyClass()
obj.x = 10
print("----")
print(obj.x)
1
2
3
4
5
6
7
Descriptor __set__ called for x = 10
__getattribute__ called for: __dict__
----
__getattribute__ called for: x
Descriptor __get__ called for x
__getattribute__ called for: __dict__
10