概览
在中我们介绍了如何使用特性来“代理”对实例属性的访问,事实上特性是一种特殊的属性描述符。
所谓的属性描述符,是一种实现了描述符协议的特殊类,这个关于属性访问的协议包括__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)必须是大于零的数,当然我们可以像中那样使用特性,但这里我们使用标准的属性描述符。
其实现也很简单,只要实现前面提到的描述符协议,或者部分协议即可。
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__
并不常用。
这里是实现协议,并非是基类的抽象方法,所以不需要继承,更不需要强制实现所有协议方法。有关协议的灵活性我们在中有过讨论。
协议方法中的self
和普通的类没有区别,就是指属性描述符实例本身。instance
为属性描述符绑定的目标类的实例,owner
为属性描述符绑定的类的引用。
这里或许会觉得
__get__
中会持有一个绑定类引用owner
很突兀,而且好像没什么用,但后面会说明其用途。
这里我们创建了一个属性描述符PositiveNumber
,其用途和我们在中创建的特性工厂函数极为相似,后者是创建一个只能设置为正数的特性,这里是创建一个只能设置为正数的属性描述符实例。
属性描述符的内部定义很简单,这里不过多赘述。
需要注意的是,在读和写的时候,我们都是将真实的数据存储在
instance.__dict__
中,而非是属性描述符实例self.__dict__
中,这是因为虽然在这个具体示例中我们的属性描述符PositiveNumber
的两个实例PositiveNumber('quantity')
和PositiveNumber('price')
仅仅绑定到了Order
类,但PositiveNumber
这个属性描述符创建后其实是可以绑定到任意需要使用类似的访问控制的类中的,所以从这点来说,属性描述符只是用于对绑定到的具体类实例的具体属性的访问控制,当然具体读写的属性存储在绑定的目标实例中,而非属性描述符自己的实例。这里使用
instance.__dict__
而非getattr(instance,name)
是因为后者会再次触发属性描述符,陷入无限递归。
将属性描述符绑定到目标类和在类中用特性工厂函数创建特性也没有区别,都是在类定义中给相应的类属性进行赋值。
绑定好属性描述符以后,所有对Order
类实例的quantity
和price
属性的读写操作都将由绑定的属性描述符处理,这点和特性没有区别,因为特性就是特殊的属性描述符。
既然特性是特殊的属性描述符,那属性描述符和特性有什么区别?
和特性的区别
总的来说,特性可以看做是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):
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
。
因为这里的实际存储属性名已经不再与属性描述符名称一致,所以可以使用
setattr
和getattr
,必须要担心会触发属性描述符而陷入无限递归。
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'] = 1
,print(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__
方法的属性描述符,其参数中的instance
和owner
也分别是绑定的实例和所属的类。
类方法显然是不会实现
__set__
和__delete__
的。
我们在中介绍过,在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项目:。
参考资料:
文章评论