Python学习笔记37:属性描述符

概览

Python学习笔记36:动态属性和特性中我们介绍了如何使用特性来“代理”对实例属性的访问,事实上特性是一种特殊的属性描述符

所谓的属性描述符,是一种实现了描述符协议的特殊类,这个关于属性访问的协议包括__set__\__get__\delete

下面我们看下如何实现属性描述符。

实现

我们假设有这么一个订单类:

class Order:
    def __init__(self, quantity, price) -> None:
        self.quantity = quantity
        self.price = price

    def total(self):
        return self.quantity*self.price

order = Order(1.5, 5)
print(order.total())
# 7.5

显然,订单中的数量(quantity)和单价(price)必须是大于零的数,当然我们可以像Python学习笔记36:动态属性和特性中那样使用特性,但这里我们使用标准的属性描述符。

其实现也很简单,只要实现前面提到的描述符协议,或者部分协议即可。

class PositiveNumber:
    def __init__(self, name) -> None:
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.name] = value
        else:
            raise ValueError('vlaue mast > 0')


class Order:
    quantity = PositiveNumber('quantity')
    price = PositiveNumber('price')

    def __init__(self, quantity, price) -> None:
        self.quantity = quantity
        self.price = price

    def total(self):
        return self.quantity*self.price


order = Order(1.5, 5)
print(order.total())
order2 = Order(0, 3)
print(order2.total())
# 7.5
# Traceback (most recent call last):
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 29, in <module>
#     order2 = Order(0, 3)
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 20, in __init__
#     self.quantity = quantity
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 12, in __set__
#     raise ValueError('vlaue mast > 0')
# ValueError: vlaue mast > 0

具体的描述符协议包括下面三个协议方法:

  • __get__(self, instatnce, owner)
  • __set__(self, instance, value)
  • __delete__(self, instance)

这里仅实现了__get____set__,属性描述符通常会实现这两个方法,或者其中之一,__delete__并不常用。

这里是实现协议,并非是基类的抽象方法,所以不需要继承,更不需要强制实现所有协议方法。有关协议的灵活性我们在Python学习笔记28:从协议到抽象基类中有过讨论。

协议方法中的self和普通的类没有区别,就是指属性描述符实例本身。instance为属性描述符绑定的目标类的实例,owner为属性描述符绑定的类的引用。

这里或许会觉得__get__中会持有一个绑定类引用owner很突兀,而且好像没什么用,但后面会说明其用途。

这里我们创建了一个属性描述符PositiveNumber,其用途和我们在Python学习笔记36:动态属性和特性中创建的特性工厂函数极为相似,后者是创建一个只能设置为正数的特性,这里是创建一个只能设置为正数的属性描述符实例。

属性描述符的内部定义很简单,这里不过多赘述。

  • 需要注意的是,在读和写的时候,我们都是将真实的数据存储在instance.__dict__中,而非是属性描述符实例self.__dict__中,这是因为虽然在这个具体示例中我们的属性描述符PositiveNumber的两个实例PositiveNumber('quantity')PositiveNumber('price')仅仅绑定到了Order类,但PositiveNumber这个属性描述符创建后其实是可以绑定到任意需要使用类似的访问控制的类中的,所以从这点来说,属性描述符只是用于对绑定到的具体类实例的具体属性的访问控制,当然具体读写的属性存储在绑定的目标实例中,而非属性描述符自己的实例。
  • 这里使用instance.__dict__而非getattr(instance,name)是因为后者会再次触发属性描述符,陷入无限递归。

将属性描述符绑定到目标类和在类中用特性工厂函数创建特性也没有区别,都是在类定义中给相应的类属性进行赋值。

绑定好属性描述符以后,所有对Order类实例的quantityprice属性的读写操作都将由绑定的属性描述符处理,这点和特性没有区别,因为特性就是特殊的属性描述符。

既然特性是特殊的属性描述符,那属性描述符和特性有什么区别?

和特性的区别

总的来说,特性可以看做是Python定义好的一个特定用途的属性描述符,而用户自定义的属性描述符比特性相对更为灵活,可以根据需要进行抽象和继承,构建灵活用途的属性描述符。

假设我们现在需要给订单添加一个属性用于描述物品信息,并且需要验证这个字符串不能是空字符串。

import abc
from typing import ValuesView


class AttributeProxy:
    def __init__(self, name) -> None:
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

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


class ValidatableAttr(abc.ABC, AttributeProxy):
    @abc.abstractmethod
    def validate(self, value):
        pass

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


class PositiveNumber(ValidatableAttr):
    def validate(self, value):
        if value > 0:
            return value
        else:
            raise ValueError('value must > 0')


class TextNotEmpty(ValidatableAttr):
    def validate(self, value):
        value = str(value).strip()
        if len(value) > 0:
            return value
        else:
            raise ValueError('text must not empty string')


class Order:
    quantity = PositiveNumber('quantity')
    price = PositiveNumber('price')
    des = TextNotEmpty('des')

    def __init__(self, quantity, price, des) -> None:
        self.quantity = quantity
        self.price = price
        self.des = des

    def total(self):
        return self.quantity*self.price


order = Order(1.5, 5, 'banana')
print(order.total())
print(vars(order))
order2 = Order(2, 3, '')
print(order2.total())
# 7.5
# {'quantity': 1.5, 'price': 5, 'des': 'banana'}
# Traceback (most recent call last):
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 60, in <module>
#     order2 = Order(2, 3, '')
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 51, in __init__
#     self.des = des
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 22, in __set__
#     value = self.validate(value)
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 40, in validate
#     raise ValueError('text must not empty string')
# ValueError: text must not empty string

在这个示例中我们将前边创建的代理属性读写的属性描述符分解和抽象为四个,最上边的基类为AttributeProxy,用于最基本的属性读写功能代理,其直接子类ValidatableAttr在代理写方法的时候增加一个验证的功能,并且具体的验证方法validate是一个抽象方法,所以这里将其也定义为抽象类(继承了abc.ABC)。这样就可以让继承ValidatableAttr的子类可以很容易地通过实现validate方法实现写属性的时候的验证功能,这其实是设计模式中的模版方法。

因为我们已经将属性描述符代理属性赋值时候的验证行为封装到了ValidatableAttr中的validate方法,所以PositiveNumber的实现就相当简单,同样新建的用于验证订单描述字段不能是非空字符串的属性描述符TextNotEmpty也同样可以很简单地实现。

使用非同名存储

如同之前所展示的,一般属性描述符会与实际用于存储的委托实例中的属性同名:

class PositiveNumber:
    def __init__(self, name) -> None:
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

在与委托类绑定的时候也需要类属性与传入的委托类实例的属性名一致:

class Order:
    quantity = PositiveNumber('quantity')
    price = PositiveNumber('price')

这或许会有些不便,我们可以使用一种折中的方式解决:

class PositiveNumber:
    count = 0
    def __init__(self) -> None:
        clsName = self.__class__.__name__
        self.realName = "{}#{}".format(clsName, self.__class__.count)
        self.__class__.count += 1
        
    def __get__(self, instance, owner):
        return getattr(instance, self.realName)

    def __set__(self, instance, value):
        if value > 0:
            setattr(instance, self.realName, value)
        else:
            raise ValueError('vlaue mast > 0')


class Order:
    quantity = PositiveNumber()
    price = PositiveNumber()

    def __init__(self, quantity, price) -> None:
        self.quantity = quantity
        self.price = price

    def total(self):
        return self.quantity*self.price


order = Order(1.5, 5)
print(order.total())
print(vars(order))
# 7.5
# {'PositiveNumber#0': 1.5, 'PositiveNumber#1': 5}

这里给属性描述符类添加了一个类计数器,利用这个计数器我们给每个创建的描述符实例指定了一个在代理属性的目标类实例中的唯一存储属性名PositiveNumber#xxx

  • 因为这里的实际存储属性名已经不再与属性描述符名称一致,所以可以使用setattrgetattr,必须要担心会触发属性描述符而陷入无限递归。
  • PositiveNumber#0这样使用#的变量命名方式不能使用常规的.运算符进行访问,但是依然可以使用getattr或者instance.__dict__访问,我们正好可以利用这点设置特殊的属性作为属性描述符的真实存储属性,而不需要担心用户的意外访问。

覆盖与非覆盖

还记得我们之前说过的描述符协议中__get__参数中的那个奇怪的owner吗?正是因为这个,不同实现了不同协议的属性描述符,其表现的效果也有不同的差异。具体分为覆盖与非覆盖两种。

覆盖

覆盖型的属性描述符指实现了__set__的属性描述符:

class OverrideAttr:
    def __init__(self, name) -> None:
        self.name = name
    def __set__(self, instance, value):
        print('__set__ is called')
        instance.__dict__[self.name] = value
    def __get__(self, instance, owner):
        print('__get__ is called')
        return instance.__dict__[self.name]
class ProxyClass:
    overrideAttr = OverrideAttr('overrideAttr')

pc = ProxyClass()
print(pc.overrideAttr)
# __get__ is called
# Traceback (most recent call last):
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 14, in <module>
#     print(pc.overrideAttr)
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 9, in __get__
#     return instance.__dict__[self.name]
# KeyError: 'overrideAttr'

这里在没有设置实际属性的情况下,直接通过pc.overrideAttr访问属性描述符,所以会产生一个KeyError异常。

pc.__dict__['overrideAttr'] = 1
print(pc.overrideAttr)
pc.overrideAttr = 2
# __get__ is called
# 1
# __set__ is called

设置了实际存储属性后,无论是赋值还是访问,依然是通过属性描述符。

我们再来看只实现了__set__没有实现__get__的情况:

class OverrideAttr:
    def __init__(self, name) -> None:
        self.name = name
    def __set__(self, instance, value):
        print('__set__ is called')
        instance.__dict__[self.name] = value
class ProxyClass:
    overrideAttr = OverrideAttr('overrideAttr')

pc = ProxyClass()
print(pc.overrideAttr)
pc.__dict__['overrideAttr'] = 1
print(pc.overrideAttr)
pc.overrideAttr = 2
# <__main__.OverrideAttr object at 0x000002206F9D8100>
# 1
# __set__ is called

可以看到,在没有设置实际存储属性的时候,直接调用pc.overrideAttr会返回一个属性描述符__main__.OverrideAttr object,因为这个属性描述符并没有实现__get__,所以这里只是返回属性描述符实例本身,而不会调用其__get__返回具体值。

而如果我们设置了实际存储属性pc.__dict__['overrideAttr'] = 1print(pc.overrideAttr)就会直接访问具体的我们刚设置的属性。而这些都不影响属性描述符的__set__方法,无论如何,在使用pc.overrideAttr进行赋值操作的时候,都会调用属性描述符的__set__方法。

下面我们看一下非覆盖的属性描述符。

非覆盖
class OverrideAttr:
    def __init__(self, name) -> None:
        self.name = name
    def __get__(self, instance, owner):
        print('__get__ is called')
        return instance.__dict__[self.name]
class ProxyClass:
    overrideAttr = OverrideAttr('overrideAttr')

pc = ProxyClass()
# print(pc.overrideAttr)
# __get__ is called
# Traceback (most recent call last):
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 11, in <module>
#     print(pc.overrideAttr)
#   File "D:\workspace\python\python-learning-notes\note37\test.py", line 6, in __get__
#     return instance.__dict__[self.name]
# KeyError: 'overrideAttr'
pc.__dict__['overrideAttr'] = 1
print(pc.overrideAttr)
pc.overrideAttr = 2
# 1

从示例我们可以看到,在没有显示地设置代理类实例的相应属性的时候,通过pc.overrideAttr可以调用__get__,但是如果我们使用pc.__dict__['overrideAttr'] = 1的方式显式地给代理类实例的相应属性赋值,之后再调用pc.overrideAttr就不再会经过属性描述符,就好像实例属性将同名的属性描述符屏蔽了一样。

这就是所谓的__set____get__的地位并不一致,设置了__set__的属性描述符无论怎样,都不会被屏蔽,而只实现了__get__的属性描述符,在某些情况下会被屏蔽掉。所以我们称前者为“覆盖式”,而后者为“非覆盖式”。

函数和方法

按习惯,我们通常会将类之外定义的func为“函数(function)”,而称呼类中定义的func为“方法(method)”。

其最重要的区别是方法会关联一个实例,而函数没有,在Python中,方法声明也体现了这一点,所有方法的第一个参数都是self,指代方法关联的实例。

事实上,方法的本质是属性描述符。

class TestClass:
    def testMethod(self):
        pass

tc = TestClass()
print(tc.testMethod)
print(TestClass.testMethod)
# <bound method TestClass.testMethod of <__main__.TestClass object at 0x0000021954AC9310>>
# <function TestClass.testMethod at 0x0000021954AAD5E0>

从这段示例可看到,实例方法的类型是bound method,而类方法的类型是function,事实上类方法正是实现了__get__方法的属性描述符,其参数中的instanceowner也分别是绑定的实例和所属的类。

类方法显然是不会实现__set____delete__的。

我们在Python学习笔记9:类中介绍过,在Python中,实际上是可以通过TestClass.testMethod(tc)这种类方法的方式调用实例方法的。这是因为无论是何种方式,其根本上都是调用的属性描述符的__get__方法,而__get__方法只需要接收到instance实例即可完成调用,和使用什么样的形式并无关系。

这一点也可以通过直接调用__get__得到验证:

tc = TestClass()
print(tc.testMethod)
print(TestClass.testMethod)
print(TestClass.testMethod.__get__(tc))
print(TestClass.testMethod.__get__(None, TestClass))
print(tc.testMethod.__self__)
print(tc.testMethod.__func__)
# <bound method TestClass.testMethod of <__main__.TestClass object at 0x00000165D2446850>>
# <function TestClass.testMethod at 0x00000165D242D3A0>
# <bound method TestClass.testMethod of <__main__.TestClass object at 0x00000165D2446850>>
# <function TestClass.testMethod at 0x00000165D242D3A0>
# <__main__.TestClass object at 0x00000165D2446850>
# <function TestClass.testMethod at 0x00000165D242D3A0>

这里直接调用属性描述符的__get__方法:TestClass.testMethod.__get__(tc)TestClass.testMethod.__get__(None, TestClass),可以看到得到的是和之前一样的绑定方法、函数实例,其地址都一样,是同一个实例。

此外我们还可通过实例的属性描述符获取到实例方法的引用tc.testMethod.__self__和类方法的引用tc.testMethod.__func__

既然类方法的本质是只实现了__get__的属性描述符,自然也可以像我们之前所说的非覆盖的属性描述符那样,被实例的同名属性“屏蔽”。

class TestClass:
    def testMethod(self):
        pass

tc = TestClass()
print(tc.testMethod)
tc.testMethod = 1
print(tc.testMethod)
# <bound method TestClass.testMethod of <__main__.TestClass object at 0x000001B2CBB68820>>
# 1

这种特性只限于普通方法,不包括特殊方法(魔术方法),比如__getattr__或者__init__

无论是特性还是属性描述符,抑或是类方法,其实质都是依赖于类属性,我们都可以通过修改类属性删除或者修改。

属性描述符注意事项

关于属性描述符,可以总结出以下注意事项和使用原则:

  • 使用特性以保持简单。

    虽然特性其实就是属性描述符,我们也可以使用自定义属性描述符代替特性,但是没必要,而且需要注意非覆盖还是覆盖的区别。在需要访问控制,比如需要设置只读属性的时候我们完全可以简单地创建一个只实现getter的特性,这样更省事,也容易理解。

  • 只读描述符必须实现__set__方法。

    这点容易理解,不实现__set__,仅实现__get__的是非覆盖的属性描述符,会被实例属性屏蔽,所以自然是不行的。

  • 用于验证的描述符可以只实现__set__方法。

    像前边展示的那样,如果实际存储的属性名称与属性描述符一致,则可以只实现__set__方法,这并不会影响到对代理实例的属性的访问。

  • 仅实现__get__的属性描述符可以用于缓存机制。

    这实际上是利用了非覆盖型属性描述符会被同名实例属性“屏蔽”的特性:

    class CachedAttr:
        def __init__(self, name, func) -> None:
            self.name = name
            self.func = func
    
        def __get__(self, instance, owner):
            try:
                return instance.__dict__[self.name]
            except KeyError:
                result = self.func()
                instance.__dict__[self.name] = result
                return result
    
    import time
    def longRunFunction():
        time.sleep(3)
        return "this is a long run result"
    
    class TestClass:
        cachedAttr = CachedAttr('cachedAttr', longRunFunction)
    
    tc = TestClass()
    print(tc.cachedAttr)
    print(tc.cachedAttr)
    # this is a long run result
    # this is a long run result
    

    上面这个示例简单说明了如何使用非覆盖型属性描述符缓存运算结果,在第一次调用的时候会调用longRunFunction,会等待3秒,而第二次调用就直接会返回结果,无需等待,因为此时已经“屏蔽”了属性描述符。

  • 非特殊的方法可以被实例属性屏蔽。

    这点在讨论函数和方法的时候已经讨论过了,这里不再赘述。

以上就是关于属性描述符的全部内容,《Fluent Python》的相关内容仅剩最后一章《元类编程》,在那之后,《Python学习笔记》系列就会告一段落,我会开一个新坑,大概率是设计模式,希望有人能喜欢,就这样吧。

谢谢阅读。

本系列文章的代码都存放在Github项目:python-learning-notes

属性描述符

参考资料:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值