属性描述符

Python 小记 2018-12-15 7141 字 3190 浏览 点赞

描述符

在Python中,描述符作为一个用语言描述起来会有些抽象的概念。其定义有如下说法:

一般来说,描述符是一个具有绑定行为的对象属性,其属性的访问被描述符协议方法覆写。这些方法是__get__()、 __set__()和__delete__(),一个对象中只要包含了这三个方法(译者注:包含至少一个),就称它为描述符。

而我认为,如果可以通俗地去解释“为什么需要描述符”,会让初学者更易接纳。


如果你知道Python中的property——也可能不曾听过,但你可以看我的@property——就会晓得我们可以对实例属性操作时做一些限制,比方说陌生人的姓名必须得字符串类型(这里当然会有些不严谨,因为Python中如"512"也是字符串类型),如果不是,就抛异常TypeError。代码可以如下:

class Stranger(object):
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("我期待一个字符串")
        else:
           self._name = value


if __name__ == "__main__":
    stger = Stranger("Ying")
    stger.name = 512

运行结果如下:

乍一看,上述代码似乎满足了我们的需求。其实不然,存在两个缺点。

其一,当我们初始化一个对象,就给name传int类型的参数,程序会默然允许:

if __name__ == "__main__":
    stger = Stranger(512)
    print(type(stger.name))

# 输出:
# <class 'int'>

其二,当我们需要限制的属性较多时:

class Stranger(object):
    def __init__(self, name, age, hobbies, addr, school):
        self._name = name
        self._age = age
        self._hobbies = hobbies
        ...

为了一一限制,便不得不对应的去定义@name.setter@age.setter ...... 代码怎么不优雅了?


Python的代码需要优雅,这就是引进描述符的原因。你也可以认为描述符是property的升级版。

描述符协议

描述符协议包含:

  • __get__ 用于访问属性的值。当请求的属性不存在时,抛出AttributeError
  • __set__ 设置操作
  • __delete__ 删除操作

一个对象中只要包含了这三个方法中的一个,就称它为描述符。

示例

import numbers


class InterField(object):
    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        if not isinstance(value, numbers.Integral):
            raise ValueError("期待一个整数")
        else:
            self.value = value


class CharField(object):
    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise ValueError("期待一个字符串")
        else:
            self.value = value


class Stranger(object):
    name = CharField()
    age = InterField()
    hobbies = CharField()
    addr = CharField()
    school = CharField()

    def __init__(self, name, age, hobbies, addr, school):
        self.name = name
        self.age = age
        self.hobbies = hobbies
        self.addr = addr
        self.school = school


if __name__ == "__main__":
    stger = Stranger("Ying", 22, "music", "chengdu", "XHU")
    print(stger.name, stger.age, stger.hobbies)

# 输出:
# Ying 22 music

此时

stger = Stranger("Ying", "22", "music", "chengdu", "XHU")

或者

stger = Stranger("Ying", 22, "music", "chengdu", "XHU")
stger.name = 512

资料描述符与非资料描述符

认为,如果一个描述符只定义了__get__方法,则为非资料描述符;如果同时定义了__get____set__,为资料描述符

  • 资料描述符:当类属性与实例属性同名时,优先访问类属性
class CharField(object):
    def __init__(self, value):
        self.value = value

    def __get__(self, instance, owner):
        return self.value

    # 此时,我甚至不需要对__set__添加任何逻辑
    def __set__(self, instance, value):
        pass


class Stranger(object):
    # 这是一个资料描述符
    name = CharField("Guan")

    def __init__(self):
        self.name = "Ying"


if __name__ == "__main__":
    stger = Stranger()
    print(stger.name)

# 输出:
# Guan
  • 非资料描述符:当类属性与实例属性同名时,优先访问实例属性
class CharField(object):
    def __init__(self, value):
        self.value = value

    def __get__(self, instance, owner):
        return self.value


class Stranger(object):
    # 这是一个非资料描述符
    name = CharField("Guan")

    def __init__(self):
        self.name = "Ying"


if __name__ == "__main__":
    stger = Stranger()
    print(stger.name)

# 输出:
# Ying

描述符的陷阱

第一点:描述符必须在类的层次上(类属性)

...
class Stranger(object):
    def __init__(self):
        self.name = CharField("Guan")

if __name__ == "__main__":
    stger = Stranger()
    print(stger.name)

# 输出:
# <__main__.CharField object at 0x7ff3178f1cc0>

这是因为:只有类层次上的描述符才会默认调用_get_

第二点:确保实例属性属于实例本身

事实上类属性是该类实例对象共有的,可参看我的类属性与实例属性。所以很可能出现“一荣俱荣,一损俱损”的现象:

class CharField(object):
    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value

class Stranger(object):
    name = CharField()

if __name__ == "__main__":
    g = Stranger()
    g.name = "Guan"
    print(g.name)  # 输出: Guan

    y = Stranger()
    y.name = "Ying"
    print(g.name)  # 输出: Ying
    print(y.name)  # 输出: Ying

解决方案:
在描述符类中维护一个字典,将每个实例对象作为字典的key,而类属性对应的值作为字典的value:

class CharField(object):
    def __init__(self):
        # 一些实例使用的WeakKeyDictionary()类型字典
        # 暂时我并不能明白为什么
        self.data = dict()

    def __get__(self, instance, owner):
        return self.data.get(instance)
    
    # 当y.name时,instance == y
    # 当g.name时,instance == g
    def __set__(self, instance, value):
        self.data[instance] = value

这样做的缺点是,也许会碰到不可以被hash的实例,那么就不能作为字典的key。常用的解决方案是:为描述符增加标签,并通过这个标签,把本来应该访问类属性这一过程,“偷偷地”转化成访问实例属性的过程

class CharField(object):
    def __init__(self, label):
        self.label = label

    def __get__(self, instance, owner):
        return instance.__dict__.get(self.label)

    def __set__(self, instance, value):
        instance.__dict__[self.label] = value

class MyList(list):
    name = CharField("NAME")

这种做的缺点是,你可能会在没有察觉的情况下修改了name值

mylist = MyList()
mylist.name = "Guan"  # 这里是name
print(mylist.name)  # 输出: Guan

mylist.NAME = "Ying"  # 这里是NAME
print(mylist.name)  # 输出:Ying

建议标签名最好与描述符对象的名字相同


最后可以利用元类来简化这个过程:

class LabelType(type):
    def __new__(cls, name, bases, attrs):
        for k, v in attrs.items():
            if isinstance(v, CharField):
                v.label = k  # 为描述符增加label
        return super().__new__(cls, name, bases, attrs)

class CharField(object):
    def __get__(self, instance, owner):
        return instance.__dict__.get(self.label)

    def __set__(self, instance, value):
        instance.__dict__[self.label] = value

class MyList(list, metaclass=LabelType):
    name = CharField()

如果你对元类不熟悉,不妨参看我的元类

感谢



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论