python中的property和描述符对象
在给出描述符的定义之前,我们首先介绍一下描述符的应用场景:
首先我们设想正在编写某个管理电影信息的类(class Movie), Movie类的代码看上去可以是这个样子:
class Movie(object):
def __init__(self, title, rating, runtime, budget, gross):
self.title = title
self.rating = rating
self.runtime = runtime
self.budget = budget
self.gross = gross
def profit(self):
return self.gross - self.budget
我们可以看到,在 init 方法中,我们建立了大量的对象属性。这些属性有的从含以上仅支持字符串,有的则仅支持属于某一个特定取值范围的数值。
可是,在其他的用户或者程序使用我们的Movie类的时候,他们可能完全不去考虑这些规则。例如某个用户可以对某个实例的budget属性赋值-999,一旦出现了这种情况,我们可能希望Moive类的实例可以禁止相关操作并对用户做出提示“不要为这个属性赋上负值”。
那我们利用仅有的oop知识,完全可以这样设计Movie类:
class Movie(object):
def __init__(self, title, rating, runtime, budget, gross):
self.title = title
self.rating = rating
self.runtime = runtime
self.gross = gross
if budget < 0:
raise ValueError("Negative value not allowed: %s" % budget)
self.budget = budget
def profit(self):
return self.gross - self.budget
我们仅仅在原来Movie类中的bugdet属性的位置添加了一个条件判断。但是这样的改进并不能满足我们的需求。因为如下的代码这这种设计下仍然合法,但是我们需求恰恰是禁止这类使用方法:
>>> s=Movie(1,1,1,1,1)
>>> s
<__main__.Movie object at 0x0319C7B0>
>>> s.budget=-999
>>>
其实分析上面的设计,不难看出,我们的改进只能确保对象在被创建时不能将budget设置为负值:
>>> s=Movie(1,1,1,-999,1)
Traceback (most recent call last):
File "<pyshell#6>", line 1, in <module>
s=Movie(1,1,1,-999,1)
File "<pyshell#1>", line 8, in __init__
raise ValueError("Negative value not allowed: %s" % budget)
ValueError: Negative value not allowed: -999
>>>
为了真正实现我们的需求,我们就要使用到属性(property):
class Movie(object):
def __init__(self, title, rating, runtime, budget, gross):
self._budget = None
self.title = title
self.rating = rating
self.runtime = runtime
self.gross = gross
self.budget = budget
@property
def budget(self):
return self._budget
@budget.setter
def budget(self, value):
if value < 0:
raise ValueError("Negative value not allowed: %s" % value)
self._budget = value
def profit(self):
return self.gross - self.budget
我们首先利用property修饰器修饰了budget方法,这相当于为Movie的budget属性建立了一个配套的getter方法,随后我们利用budget.setter修饰器修饰了另一个budget方法,作为我们的setter。这样,当用户或者程序访问某个实例的budget属性时,将会直接调用property修饰的budget,而当用户或者程序想要为budget赋值时,则会调用budget.setter方法。
>>> s=Movie(1,1,1,1,1)
>>> s
<__main__.Movie object at 0x032BE4D0>
>>> s.budget
1
>>> s.budget=-999
Traceback (most recent call last):
File "<pyshell#13>", line 1, in <module>
s.budget=-999
File "<pyshell#8>", line 18, in budget
raise ValueError("Negative value not allowed: %s" % value)
ValueError: Negative value not allowed: -999
>>>
这样,我们就实现了利用用户自定义代码实现了对变量访问权限的操作。
但是,倘若我们想对Movie类的所有属性进行这样的改进呢?很遗憾,若仅仅使用property修饰器,我们只能手动地对每一个属性进行相关的修改。这样我们的描述符(descriptor)就派上用场了:
from weakref import WeakKeyDictionary
class NonNegative(object):
"""A descriptor that forbids negative values"""
def __init__(self, default):
self.default = default
self.data = WeakKeyDictionary()
def __get__(self, instance, owner):
# we get here when someone calls x.d, and d is a NonNegative instance
# instance = x
# owner = type(x)
return self.data.get(instance, self.default)
def __set__(self, instance, value):
# we get here when someone calls x.d = val, and d is a NonNegative instance
# instance = x
# value = val
if value < 0:
raise ValueError("Negative value not allowed: %s" % value)
self.data[instance] = value
我们首先从内建库weakref中调用WeakKeyDictionary,在这里可以仅仅将之视为一个字典。然后观察NonNegative类,它除了init方法之外仅仅具有get以及set方法。
例如,在https://docs.python.org/3/howto/descriptor.html中就这样提到:
描述符是是一种带有绑定行为的对象属性,但是它的对象属性的接口(访问对象值、为对象赋值)都已经被新的 get(), set(), 和delete()方法覆盖掉了(这三个方法属于描述符协议)。这样如果某个一对象定义了这三个方法或者其中的某几个,那么它就是一个描述符。
所以上面的NonNegative类就是一个描述符,这里我们先不讨论NonNegative的内部机理。直接看描述符是如何被应用的:
class Movie(object):
#always put descriptors at the class-level
rating = NonNegative(0)
runtime = NonNegative(0)
budget = NonNegative(0)
gross = NonNegative(0)
def __init__(self, title, rating, runtime, budget, gross):
self.title = title
self.rating = rating
self.runtime = runtime
self.budget = budget
self.gross = gross
def profit(self):
return self.gross - self.budget
我们首先在 类层次(注意在这里必须是类层次,不能是实例层次),为Movie类的每一个属性创立一个对应的NonNegative对象,之后将他们直接作为Movie类的属性。这样就用十分简洁的方法为每一个属性构建了合理的访问权限控制。(可以自行尝试一下,现在每一个属性都具有上面budget的特性了)
下面我们额外讨论一下NonNegative对象的作用机理:
我们可以看到NonNegative的data属性是一个WeakKeyDictionary,我们不妨将它看作是一个字典。从NonNegative的set方法来看,每次在进行赋值时都会在data维护的字典内建立一个键值对。
你可能想这样设计NonNegative类,注意在BrokenNonNegative类中我们完全没有使用字典类型:
class BrokenNonNegative(object):
def __init__(self, default):
self.value = default
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
if value < 0:
raise ValueError("Negative value not allowed: %s" % value)
self.value = value
class Foo(object):
bar = BrokenNonNegative(5)
但是这样实现有一个很严重的问题,这种实现下Foo类的所有实例的bar属性都是完全同步的:
class Foo(object):
bar = BrokenNonNegative(5)
f = Foo()
g = Foo()
print "f.bar is %s\ng.bar is %s" % (f.bar, g.bar)
print "Setting f.bar to 10"
f.bar = 10
print "f.bar is %s\ng.bar is %s" % (f.bar, g.bar) #ouch
f.bar is 5
g.bar is 5
Setting f.bar to 10
f.bar is 10
g.bar is 10
可见,在创建第二个实例之后,第二个实例的bar值会覆盖掉第一个实例的bar值。但是,若仅仅将Foo类中的bar变量赋一个不可变对象(例如浮点数)。那么完全不会出现:”对某一个实例属性的修改会污染到其他实例乃至类的对应属性”这样及其严重的问题。
这里可能的原因是,由于BrokenNonNegative的实例是建立在类层次上的,并将其赋值给bar。当用户定义该类的一个实例时,实例中的bar变量仅仅只是类中创建的BrokenNonNegative(5) 的一个额外的引用,或者说从BrokenNonNegative类到对应的实例,Python仅仅进行了一次bar的浅拷贝。所以,从某一个实例对其属性bar进行修改,就相当于在对类中的bar进行修改。这样就污染到了全局。
进一步深入NonNegative类的机理——可变对象使用NonNegative
我们这次从list类型直接继承:
>>> class MyMistake(list):
x=NonNegative(5)
>>>
>>> m=MyMistake()
>>> m.x
Traceback (most recent call last):
File "<pyshell#46>", line 1, in <module>
m.x
File "<pyshell#40>", line 11, in __get__
return self.data.get(instance, self.default)
File "D:\Program File\Python27\lib\weakref.py", line 358, in get
return self.data.get(ref(key),default)
TypeError: unhashable type: 'MyMistake'
>>>
随后尝试访问实例m的x属性,报错。这是因为list是unhashable类型,其子类型也具有这个性质。而在描述符的set方法中,我们要将实例直接作为字典的键,但是python要求字典的键hashable。
遗憾的是,解决此类问题大多数人采用了一种比较脆弱的方法:
class Descriptor(object):
def __init__(self, label):
self.label = label
def __get__(self, instance, owner):
print '__get__', instance, owner
return instance.__dict__.get(self.label)
def __set__(self, instance, value):
print '__set__'
instance.__dict__[self.label] = value
class Foo(list):
x = Descriptor('x')
y = Descriptor('y')
f = Foo()
f.x = 5
print f.x
__set__
__get__ [] <class '__main__.Foo'>
这种方法依赖于Python的方法解析顺序(即,MRO)。我们给Foo中的每个描述符加上一个标签名,名称和我们赋值给描述符的变量名相同,比如x = Descriptor(‘x’)。之后,描述符将特定于实例的数据保存在f.dict中。
这个字典条目通常是当我们请求f.x时Python给出的返回值。然而,由于Foo.x 是一个描述符,Python不能正常的使用f.dict[‘x’],但是描述符可以安全的在这里存储数据。只是要记住,不要在别的地方也给这个描述符添加标签。
之所以这里依赖了MRO,应该是说:我们在python中访问一个对象的属性时,常常直接输入obj.attr,这样的方法等同于obj.dict[‘attr’]。但是作为描述符对象x(做f.x这种访问操作),它已经有自己的访问方法(get方法)了,所以在访问x时会优先调用Descriptor类的方法,而不会优先调用python提供的标准方法。