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提供的标准方法。

posted @ 2016-12-18 19:46  Khannian  阅读(383)  评论(0编辑  收藏  举报