7-2-管理属性
1. 为什么管理属性
通常,属性只是对象的名称
1 | person.name # 获取属性值 |
大多数情况下,属性位于对象自身之中,或者继承自对象所派生自的一个类。
假设编写了一个程序来直接使用一个 name 属性,但是随后需要修改,编写一个方法来管理对属性值的访问,这是很直接的。
然而,这需要在整个程序中用到了名称的所有地方都进行修改。此外,这种方法需要程序知道如何导出值:是作为简单的名称或是作为调用的方法。
2. 插入在属性访问时运行的代码
一个更好的解决方案是,如果需要的话,在属性访问时自动运行代码。
__getattr__
和__setattr__
方法 ,把未定义的属性获取和所有的属性赋值指向通用的处理器方法。__getattribute__
方法,把所有属性获取都指向 Python 的所有类中的一个泛型处理器方法。property
内置函数,把特定属性访问定位到 get 和 set 处理器函数,也叫做特性。- 描述符协议,把特定属性访问定位到具有任意 get 和 set 处理器方法的类的实例。
3. 特性
特性协议允许我们把一个特定属性的 get 和 set 操作指向我们所提供的函数或方法,使得我们能够插入在属性访问的时候自动运行的代码,拦截属性删除,还可为属性提供文档。
通过 property
内置函数来创建特性并将其分配给类属性,就像方法函数一样。
可以通过子类和实例继承属性。
访问拦截功能通过 self 实例参数提供,该参数确保了在主体实例上访问状态信息和类属性是可行的。
一个特性管理一个单个的、特定的属性。它允许我们控制访问的赋值操作,并且允许我们自由地把一个属性从简单的数据改变为一个计算,而不会影响已有的代码。
3.1 基础知识
可以通过把一个内置函数的结果赋给一个类属性来创建一个特性:
1 | attribute = property(fget, fset, fdel, doc) |
这个内置函数的参数都不是必需的,并且如果没有传递参数的话,所有都取默认值 None。对于前三个,None 意味着对应的操作是不支持的,并且尝试使用默认值将会引发一个异常。
当使用它们的时候,我们向 fget 传递一个函数来拦截属性访问,给 fset 传递一个函数进行赋值,并且给 fdel 传递一个函数进行属性删除。
从技术上讲,这三个参数都接受任何可调用的参数,包括类的方法,第一个参数接收被限定的实例。当稍后被调用时,fget 函数返回计算出的属性值,fset 和 fdel 不返回任何东西(实际上是一个 None),并且这三种方法都可能引发异常来拒绝访问请求。
如果想要的话,doc 参数接收该属性的一个文档字符串,否则,该特性会赋值 fget 函数的文档字符串,通常默认值为 None。
这个内置 property
函数调用返回一个特性对象,我们将它赋给了在类的作用域中要管理的属性的名称。
3.2 第一个例子
使用一个特性来记录对一个名为 name 的属性的访问,实际存储的数据名为 _name
1 | class Person: |
change...
fetch...
Bob Smith
change...
fetch...
Robert Smith
remove...
--------------------
change...
fetch...
Sue Jones
name property docs
实例和较低的子类都继承特性。
3.3 计算的属性
例如,当获取属性的时候,动态地计算属性的值。
1 | class PropSquare: |
9
16
1024
定义了一个 X 属性,将其当做静态数据一样访问,实际运行的代码在获取该属性的时候计算了它的值。
3.4 使用装饰器编写特性
内置函数 property
可以充当一个装饰器,来定义一个函数,当获取一个属性的时候自动运行该函数:
1 | class Person: |
运行的时候,装饰的方法自动传递给 property 内置函数的第一个参数。
Setter 和 deleter 装饰器
property
对象也有 getter、setter 和 deleter 方法,这些方法指定相应的特性访问器方法赋值并且返回特性自身的一个副本。也可以使用这些方法,通过装饰常规方法来指定特性的组成部分,getter 部分通常由创建特性自身的行为自动填充:
1 | class Person: |
fetch...
Bob Smith
change...
remove...
这段代码等同于第一个示例,装饰只是编写特性的一种替代方法。
4. 描述符
描述符提供了拦截属性访问的一种替代方法。
特性是描述符的一种,从技术上讲,property 内置函数只是创建一个特定类型的描述符的一种简化方式,而这种描述符在属性访问时运行方法函数。
从功能上讲,描述符协议允许我们把一个特定属性的 get 和 set 操作指向我们提供的一个单独类对象的方法:它们提供了一种方式来插入在访问属性的时候自动运行的代码,并且它们允许我们拦截属性删除并且为属性提供文档。
描述符作为独立的类创建。可以通过子类和实例继承。描述符提供了对获取和赋值访问的控制,并且允许我们自由地把简单的数据修改为计算值从而改变一个属性,而不会影响已有的代码。
4.1 基础知识
1 | class Descriptor: |
带有任何这些方法的类都可以看作是描述符,并且当它们的一个实例分配给另一个类的属性的时候,它们的这些方法是特殊的——当访问属性的时候,会自动调用它们。
省略一个 __set__
意味着允许这个名字在一个实例中重新定义。因此,隐藏了描述符——要使得一个属性是只读的,必须定义 __set__
来捕获赋值并引发一个异常。
带有 __set__
的描述符在形式上称为数据描述符,它优先于由常规继承规则定位的其他名称。例如,名称 __class__
的继承描述符覆盖实例名称空间字典中的相同名称。这也可以确保你在自己的类中编写的数据描述符优先于其他类。
描述符方法参数
__get__
访问方法额外地接收一个 owner 参数,指定了描述符实例要附加到的类。instance 参数要么是访问的属性所属的实例(用于 instance.attr),要么当所访问的属性直接属于类的时候是None(用于 class.attr)。
1 | class Descriptor: |
<__main__.Descriptor object at 0x00000263457C3C50>
<__main__.Subject object at 0x00000263457C3C88>
<class '__main__.Subject'>
1 | Subject.attr |
<__main__.Descriptor object at 0x00000263457C3C50>
None
<class '__main__.Subject'>
当获取 X.attr 的时候,Python 自动运行 Descriptor 类的 __get__
方法,Subject.attr 类属性分配给该方法。好像发生了如下转换:
X.attr -> Descriptor.__get__(Subject.attr, X, Subject)
只读描述符
使用描述符直接忽略 __set__
方法不足以让属性成为只读的,因为描述符名称可以赋给一个实例。
1 | class D: |
get
1 | C.a |
get
1 | X.a = 99 # 保存在 X 中,隐藏了 C.a |
99
1 | list(X.__dict__.keys()) |
['a']
1 | Y = C() |
get
1 | C.a |
get
要让基于描述符的属性成为只读的,捕获描述符类中的赋值并引发一个异常来阻止属性赋值——当要赋值的属性是一个描述符的时候,Python 有效地绕过了常规实例层级的赋值行为,并且把操作指向描述符对象:
1 | class D: |
get
1 | X.a = 99 # 路由到 C.a.__set__ |
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-11-93d9f4bcef68> in <module>
----> 1 X.a = 99 # 路由到 C.a.__set__
<ipython-input-10-de8bfdf7e74f> in __set__(*args)
3 print('get')
4 def __set__(*args):
----> 5 raise AttributeError('cannot set')
6
7 class C:
AttributeError: cannot set
不要把描述符 __delete__
方法和通用的 __del__
方法搞混淆。调用前者是试图删除所有者类的一个实例上的管理属性名称;后者是一种通用的实例析构器方法,当任何类的一个实例将要进行垃圾回收的时候调用。
4.2 第一个示例
1 | class Name: |
fetch...
Bob Smith
change...
fetch...
Robert Smith
remove...
name descriptor docs
实际上,我们必须像这样把描述符赋给一个类属性——如果赋给一个 self 实例属性,它将无法工作。当描述符的 __get__
方法运行的时候,它传递了 3 个对象来定义其上下文:
- self 是 Name 类实例
- instance 是 Person 类实例
- owner 是 Person 类
描述符类实例是一个类属性,并且由客户类和任何子类的所有实例所继承。
当一个描述符在客户类之外无用的话,将描述符的定义嵌入客户类之中,这在语法上是完全合理的。
1 | class Person: |
name descriptor docs
4.3 计算的属性
1 | class DescSquare: |
9
16
1024
4.4 在描述符中使用状态信息
- 描述符状态用来管理内部用于描述符工作的数据。
- 实例状态记录了和客户类相关的信息,以及可能由客户类创建的信息。
描述符状态是每个描述符的数据,实例状态是每个客户实例的数据。
描述符方法也可以使用状态形式,但是描述符状态常常使得不必要使用特定的命名惯例,以避免存储在一个实例上的描述符数据的名称冲突。
例如,描述符把信息附加到自己的实例,因此,它不会与客户类的实例上的信息冲突。
对描述符存储或使用附加到客户类的实例的一个属性,而不是自己的属性,这也是可行的。
描述符和实例状态都有各自的用途。实际上,这是描述符优于特性的一个通用优点——因为它们都有自己的状态,所以可以很容易地在内部保存数据,而不用将数据添加到客户实例对象的命名空间中。
4.5 特性和描述符是如何相关的
可以使用如下的一个描述符类来模拟 property 内置函数:
1 | class Property: |
getName...
setName...
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-19-39d314b0f201> in <module>
34 x.name
35 x.name = 'Bob'
---> 36 del x.name
<ipython-input-19-39d314b0f201> in __delete__(self, instance)
21 def __delete__(self, instance):
22 if self.fdel is None:
---> 23 raise AttributeError("can't delete attribute")
24 self.fdel(instance)
25
AttributeError: can't delete attribute
属性获取从 Person 类指向 Property 类的 __get__
方法,再回到 Person 类的 getName。
5. __getattr__
和 __getattribute__
__getattr__
和 __getattribute__
操作符重载方法提供了拦截类实例的属性获取的另一种方法。
属性获取拦截表现为两种形式,可用两个不同的方法来编写:
__getattr__
针对未定义的属性运行——也就是说,属性没有存储在实例上,或者没有从其类之一继承。__getattribute__
针对每个属性,因此,当使用它的时候,必须小心避免通过把属性访问传递给超类而导致递归循环。
这些方法是 Python 的操作符重载协议的一部分——是类的特殊命名的方法,由子类继承,并且当在隐式的内置操作中使用实例的时候自动调用。
它们可以用来拦截对任何(几乎所有的)实例属性的获取,而不仅仅只是分配给它们的那些特定名称。
它们只是拦截属性获取,而不拦截属性赋值。要捕获赋值对属性的更改,我们必须编写一个 __setattr__
方法——这是一个操作符重载方法,只对每个属性获取运行,必须小心避免由于通过实例命名空间字典指向属性赋值而导致的递归循环。
5.1 基础知识
如果一个类定义了或继承了如下方法,那么当一个实例用于后面的注释所提到的情况时,它们将自动运行:
1 | def __getattr__(self, name): # 获取未定义属性 [obj.name] |
两个 get 方法通常返回一个属性的值,另两个方法不返回什么(None)。
例如,要捕获每个属性获取,我们可以使用上面的前两个方法;要捕获属性赋值,可以使用第三个方法:
1 | class Catcher: |
Get: job
Get: pay
Set: pay 99
在特定情况下,使用 __getattribute__
有相同效果:
1 | class Catcher: |
Get: job
Get: pay
Set: pay 99
避免属性拦截方法中的循环
由于 __getattribute__
和 __setattr__
针对所有的属性运行,因此,它们的代码要注意在访问其他属性的时候避免再次调用自己并触发一次递归循环。
例如,在一个 __getattribute__
方法代码内部的另一次属性获取,将会再次触发 __getattribute__
,并且代码将会循环直到内存耗尽:
1 | def __getattribute__(self, name): |
要解决这个问题,把获取指向一个更高的超类,而不是跳过这个层级的版本——object 类总是一个超类,并且它在这里可以很好地起作用:
1 | def __getattribute__(self, name): |
对于 __setattr__
,把属性作为实例的 __dict__
命名空间字典中的一个键赋值。这样就避免了直接的属性赋值:
1 | def __setattr__(self, name, value): |
__setattr__
也可以把自己的属性赋值传递给一个更高的超类而避免循环,就像 __getattribute__
一样。
相反,我们不能使用 __dict__
技巧在 __getattribute__
中避免循环。获取 __dict__
属性本身会再次触发 __getattribute__
,导致一个递归循环。
5.2 第一个示例
1 | class Person: |
fetch...
Bob Smith
change...
fetch...
Robert Smith
remove...
__init__
构造函数中的属性赋值也触发了 __setattr__
,这个方法捕获了每次属性赋值,即便是类自身之中的那些。
与特性和描述符不同,这里没有为属性直接声明指定的文档,管理的属性存在于我们拦截方法的代码之中,而不是在不同的对象中。
**使用 __getattribute__
要实现与 __getattribute__
相同的结果,用下面的代码替换示例中的 __getattr__
,由于它会捕获所有的属性获取,因此必须通过把新的获取传递到超类来避免循环,并且通常不能假设未知的名称是错误:
1 | # 将 __getattr__ 替换如下 |
运行时会得到 _setattr__
中的获取中的 __getattribute__
调用。
在只有单个的属性要管理的情况下,特性和描述符可能会做得更好。
5.3 计算属性
1 | class AttrSquare: |
9
16
1024
使用 __getattribute__
通过使用直接超类方法调用而不是 __dict__
键来修改 __setattr__
赋值方法从而避免循环:
1 | class AttrSquare: |
9
16
1024
隐式的指向在类的方法内部进行:
- 构造函数中的
self.value=start
触发__setattr__
。 __getattribute__
中self.value
再次触发__getattribute__
。
每次我们获取属性 X 的时候,__getattribute__
都运行了两次。这并没有在 __getattr__
版本中发生,因为 value 属性没有定义。
5.4 __getattr__
和 __getattribute__
比较
attr1 是一个类属性,attr2 是一个实例属性,attr3 是一个虚拟的管理属性,当获取时计算它:
1 | class GetAttr: |
1
2
get: attr3
3
1 | class GetAttribute: |
get: attr1
1
get: attr2
2
get: attr3
3
尽管 __getattribute__
可以捕获比 __getattr__
更多的属性获取,但是实际上,它们只是一个主题的不同变体——如果属性没有物理地存储,二者具有相同的效果。
5.5 管理技术比较
5.6 拦截内置操作属性
对于隐式地使用内置操作获取的方法名属性,这些方法可能根本不会运行。这意味着操作符重载方法调用不能委托给被包装的对象,除非包装类自己重新定义这些方法。
换句话说,在 Python 3 的类中,没有直接的方法来通用地拦截像打印和加法这样的内置操作。
包装类可以通过在自身中重新定义所有相关的操作符重载方法,从而委托调用以解决这一约束。
这个问题只适用于 __getattr__
和 __getattribute__
。由于特性和描述符只针对特定属性定义,所以它们根本不能真正应用于基于代理的类——单个特性或描述符不能用于拦截任意属性。
1 | class GetAttr: |
GetAttr===========================================
getattr: other
__len__: 42
fail []
fail +
fail ()
getattr: __call__
<__main__.GetAttr object at 0x00000000051933C8>
<__main__.GetAttr object at 0x00000000051933C8>
GetAttribute======================================
getattribute: eggs
getattribute: spam
getattribute: other
__len__: 42
fail []
fail +
fail ()
getattribute: __call__
getattribute: __str__
[GetAttribute str]
<__main__.GetAttribute object at 0x00000000051934A8>
当通过内置操作获取属性的时候,没有隐式运行的操作符重载方法会触发哪个属性拦截方法。
__str__
访问有两次未能被__getattr__
捕获:一次是针对内置打印,一次是针对显式获取,因为从该类继承了一个默认方法。__str__
只有一次未能被__getattribute__
捕获,在内置打印操作中,显式获取绕过了继承的版本。__call__
在 Python 3 中用于内置调用表达式的两次都没有捕获,但是,当显式获取的时候,它两次都拦截到了;和__str__
不同,没有继承的__call__
默认版本能够超越__getattr__
。__len__
被两个类都捕获了,直接原因是,它在类自身中是一个显式定义的方法——它的名称指明了,在 Python 3 中,如果我们删除了类的__len__
方法,它不会指向__getattr__
或__getattribute__
。- 所有其他的内置操作在 Python 3 中都没有被两种方案拦截。
6. 示例:属性验证
6.1 使用特性来验证
特性根据属性访问自动运行代码,但是关注属性的一个特定集合,它们不会用来广泛地拦截所有属性。
__init__
构造函数方法内部的属性赋值也触发了特性的 setter 方法。
特性使用公用的实例状态并且没有自己的实例状态。存储在一个属性中的数据叫做 __name
,而叫做 name
的属性总是特性,而非数据。
1 | class CardHolder: |
测试代码
1 | bob = CardHolder('1234-5678', 'Bob Smith', 40, '123 main st') |
12345*** / bob_smith / 40 / 19.5 / 123 main st
23456*** / bob_q._smith / 50 / 9.5 / 123 main st
56781*** / sue_jones / 35 / 24.5 / 124 main st
Bad age for Sue
Can't set sue.remain
Bad acct for Sue
6.2 使用描述符验证
特性基本上是描述符的一种受限制的形式。和特性不同,描述符有自己的状态,并且它们是一种更为通用的方案。
实际的 name 值附加到了描述符对象,而不是客户类实例。
1 | class CardHolder: |
6.3 使用 __getattr__
来验证
像 __getattr__
这样的通用工具可能更适合于通用委托,而特性和描述符更直接是为了管理特定属性而设计。
1 | class CardHolder: |
6.4 使用 __getattribute__
验证
由于每个属性获取都指向了 __getattribute__
,所以这里我们不需要压缩名称以拦截它们(acct 存储为 acct)。
对于设置和获取未管理的属性(例如, addr ),这个版本都会引发额外调用。如果速度极为重要,这个替代方法可能会是所有方案中最慢的。
1 | class CardHolder: |
1 |