【python进阶】描述符协议介绍和应用
一、什么是描述符
描述符(Descriptor)是一种用于管理对象属性访问的特殊协议。描述符对象需要实现特定的方法,这些方法在属性访问时被自动调用。
1.1 描述符的作用
描述符主要用于以下几个方面:
- 属性控制:控制属性的读取、赋值和删除操作。
- 数据封装:通过描述符可以封装属性访问的细节,使得属性访问更加安全和灵活。
- 属性重用:描述符可以在不同的类中重用,减少代码重复。
- 动态属性:描述符可以在运行时动态地定义属性的行为。
1.2 怎么样才算是描述符?
只要类对象实现了__get__、__set__和__delete__三种方法的其中一种,就可以判定为是描述符。
1.3 描述符分类
描述符分为两种:数据描述符和非数据描述符。两者区分的点在于是否只实现__get__方法,如果是,则是非数据描述符。
1.3.1 数据描述符(Data Descriptors)
指的是实现了__get__和__set__方法的类对象。通常用于控制属性的访问和赋值。
注意,数据描述符的__dict__中不存储实际的数据,而是通过__set__方法来设置数据。
数据描述符的作用:
- 控制属性赋值:可以控制如何给属性赋值,例如,进行类型检查或值验证。
- 重写属性值:可以重写属性值的存储方式,例如,存储在实例的__dict__之外的地方。
- 自定义属性行为:可以给属性添加额外的行为,比如调用属性时添加日志。
1.3.2 非数据描述符(Non-Data Descriptors)
指只实现了__get__方法的类。通常用于提供属性的默认值或实现属性的读取功能,但不控制属性的赋值。
非数据描述符的作用:
- 提供默认值
- 读取属性值:控制如何读取属性值
- 避免属性覆盖:防止实例属性覆盖类属性
1.3.3 数据描述符(Data Descriptors)
指的是实现了__get__和__set__方法的类对象。通常用于控制属性的访问和赋值。
注意,数据描述符的__dict__中不存储实际的数据,而是通过__set__方法来设置数据。
二、描述符协议
2.1 描述符协议的三个方法
Python中的描述符协议包括三个方法:包含__get__、__set__和__delete__。
- __get__(self, instance, owner):
当访问属性时调用。instance 是访问属性的对象实例(如果是类属性则为None),owner 是对象的类。
作用:返回属性的值。 - __set__(self, instance, value):
当设置属性值时调用。instance 是对象实例,value 是被设置的值。
作用:实现这个方法可以控制属性赋值。 - __delete__(self, instance):
当删除属性时调用。instance 是对象实例。
作用:实现这个方法可以控制属性的删除。
协议三个方法的示例:
# 定义数据描述符
class DataDescriptor:
def __init__(self):
self.value = None
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
self.value = value
def __delete__(self, instance):
del self.value
# 使用数据描述符
class MyClass:
x = DataDescriptor() # 使用描述符,必须把描述符实例化存储在变量当中
# 触发描述符协议
myClass = MyClass()
print(myClass.x) # 点操作符->触发__get__()
myClass.x = 5 # 点操作符、赋值运算符=->触发__get__()、__set__()
del myClass.x # del操作符->触发__delete__()
在上述代码,描述符需要实例化后存储在MyClass类的变量当中(这是必须步骤)。那么是如何触发描述符协议的呢?
- 当x = DataDescriptor() 这就说明变量x实际上是描述符实例对象。
- 当对x点操作,就是触发__get__()。
- 对x赋值(=等号运算符),就是触发__get__()和__set__()。
- 对x del操作,就是触发__delete__()。
三、描述符应用
3.1 描述符实现动态返回
在普通的属性查找当中,通过点运算符在类字典中找到键值对。在描述符管理属性的属性查找中,点操作符符找到描述符实例,这个实例被它的方法标识,然后调用该方法返回a.y__get__10。
注意,这个值【10】不是预先存储的,而是当需要的时候会计算出来(动态的)。【描述符是动态返回】
示例:描述符返回一个常量
# 简单的举例:描述符返回一个常量
# class Ten:
# def __get__(self, obj, objtype=None):
# return 10
#
# class A:
# x = 5
# y = Ten() # 为了使用描述器,必须在类里面存储在变量当中
#
# a = A()
# print(a.x)
# print(a.y) # Descriptor lookup
示例:描述符实现动态运算
# import os
#
# class DirectorySize:
#
# def __get__(self, obj, objtype=None):
# print(os.listdir(obj.dirname))
# return len(os.listdir(obj.dirname)) # 描述符动态运算结果
#
# class Directory:
#
# size = DirectorySize()
#
# def __init__(self, dirname):
# self.dirname = dirname
#
#
# s = Directory('songs')
# print(s.size)
3.2 管理属性 – 描述符不存储管理属性的值
描述器被赋值了实例化对象的公共属性,并存储到描述符类字典中。然而真正的数据是存储在实例化对象的私有属性字典中。
示例:描述符将管理属性的值存储在其他类当中
# import logging
#
# logging.basicConfig(level=logging.INFO)
#
# 描述符定义
# class LoggedAgeAccess:
#
# def __get__(self, obj, objtpye=None)
# """obj是实例化对象"""
# value = obj._age # # 这里就是描述符不存储管理属性的值,而是从Person obj实例化对象获取私有属性 # 这里存在问题:属性硬编码
# logging.info('Accessing %r giving %r', 'age', value) # 日志记录每次访问私有属性
# return value
#
# def __set__(self, obj, value):
# """obj是实例化对象"""
# logging.info('Updating %r to %r', 'age', value) # 描述符增加其他额外的操作,例如日志记录每次更新私有属性
# obj._age = value # 这里就是描述符不存储管理属性的值,而是将管理属性存进Person obj实例化对象的私有属性,也就是Person instance.__dict__
#
# class Person:
#
# age = LoggedAgeAccess()
#
# def __init__(self, name, age):
# self.name = name # 常规的实例化属性定义
# self.age = age # 由描述符管理的属性 Calls __set__()
#
# def birthday(self):
# # Calls both __get__() and __set()__
# # self.age触发__get__(),获取到私有属性
# # 赋值符号触发__set__(),将age+1后,作为value传入__set__()
# self.age += 1
#
# mary = Person('Mary M', 30)
# print(mary.age)
# mary.birthday()
# print(vars(mary)) # return object.__dict__:{'name': 'Mary M', '_age': 31}
#
# dave = Person('David D', 40)
# print(dave.age)
# dave.birthday()
# print(vars(dave))
3.3 管理属性硬编码问题 - 自定义名称(Customized names)
在3.2的代码里面其实是存在一个问题,就是属性硬编码问题,就是在描述符的__get__和__set__方法中,对实例化的属性获取是硬编码。
其实,当一个类使用多个描述符,这个类可以告诉每个描述符是给哪个变量使用。
当Person类被定义时,它会调用LoggedAccess中的__set_name__()函数,以便记录字段名称,为每个描述符分配自己的public_name和private_name。
注意,使用__set_name__方法,一个属性对应一个描述符!!!
示例:描述符使用__set_name__方法来给多个管理属性分配描述符
import logging
logging.basicConfig(level=logging.INFO)
class LoggedAccess:
# 这段是核心代码,__set_name__为每个描述符定义公共属性和私有属性。一个管理属性一一对应一个描述符。
def __set_name__(self, owner, name):
self.public_name = name # self.name
self.private_name = '_' + name # self._name
def __get__(self, obj, objtype=None):
value = getattr(obj, self.private_name) # 没有硬编码,需要通过getattr来获取__dict__里的数据
logging.info('Accessing %s giving %r', self.public_name, value)
return value
def __set__(self, obj, value):
logging.info('Updating %r to %r', self.public_name, value)
# setattr作用是给对象设置属性(setattr(x, 'y', v) is equivalent to ``x.y = v'')
# self.private_name是获取变量名
setattr(obj, self.private_name, value) # 没有硬编码,需要通过setattr来设置__dict__里的数据
class Person:
name = LoggedAccess() # First descriptor instance
age = LoggedAccess() # Second descriptor instance
def __init__(self, name, age):
self.name = name # Calls the first descriptor # 在定义阶段,将name字段传给第一个描述符,声明name字段对应第一个描述符
self.age = age # Calls the second descriptor # 在定义阶段,将age字段传给第二个描述符,声明age字段对应第二个描述符
def birthday(self):
self.age += 1
pete = Person('Peter P', 10)
print(vars(vars(Person)['name'])) # LoggedAccess: {'public_name': 'name', 'private_name': '_name'}
print(vars(pete)) # pete: {'_name': 'Peter P', '_age': 10}
四、基于描述符实现的内置函数
描述符只能在类变量工作,如果放进实例里面,就失效。描述符的主要动机是提供一个钩子,使存储在类变量中的对象能够控制在进行属性查找时发生的操作。
描述符解释了一个函数如何成为绑定函数。classmethod(), staticmethod(), property(), and functools.cached_property(),__slots__都是基于描述符实现。
五、基于描述符实现-Property
调用property()是一个比较简洁的途径去创建一个数据描述符。当访问属性时候会触发这个property()函数。
5.1 property()函数详解
5.1.1 property()签名
property(fget=None, fset=None, fdel=None, doc=None) -> property
示例:官方文档展示一个经典的用法,关于定义一个管理属性x
class C:
def getx(self): # 外部定义的获取属性值的函数
return self.__x
def setx(self): # 外部定义的设置属性值的函数
return self.__x = value
def delx(self): # # 外部定义的删除属性的函数
del self.__x
# 这里就是把三个外部定义好的函数传入property类并初始化。并将变量x设置为管理属性。
x = property(getx, setx, delx, "i'm the 'x' property")
c = C()
c.x = 1
print(c.x)
del c.x
如果c是实例对象,c.x会调用getter方法,c.x=value会调用setter方法,del c.x会调用deleter方法。
上述代码的解析:
- 先定义一个普通的类C,然后分别定义getx、setx、delx方法,此时这三个方法仍然是普通方法。
- 当把这三个方法作为参数传进property类,并把x变量作为管理属性(也就是x是Property类对象)后,
将C实例化c=C(),c.x 等同于 Property().x,触发__get__()方法(触发数据描述符协议),进而返回外界函数处理结果(return self.fget(obj))
class Property: # property()的内部__get__()
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError('unreadale attribute')
# 在property()内部__get__()方法中,用外部定义好的fget()方法对实例对象进行操作
# 既达到装饰效果,又不改变原代码逻辑,并沿用原代码逻辑
return self.fget(obj)
所以执行顺序如下!!!:
- c=C()->c.x->__get__()->return self.fget(obj)
- c=C()->c.x=1->__set__()->self.fset(obj, value)
- c=C()->del c.x->__delete__()->self.fdel(obj)
5.2 Property类(property())的纯Python的等效函数
# 要了解property()是如何根据描述符协议实现的,以下有一个纯Python的等效函数
class Property:
"Emulate PyProperty_Type() in Objects/descrobject.c"
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
"""
:param fget:外界定义好的获取属性的函数
:param fset:外界定义好的设置属性的函数
:param fdel:外界定义好的删除属性的函数
:param doc:
"""
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __get__(self, obj, objtype=None):
"""
把变量x赋值为管理属性(x=Property()),类.x 会因为点操作符触发__get__方法(数据描述符)。
在__get__()内,返回由外界定义的获取属性的函数处理结果,从而达到效果
:param obj:
:param objtype:
:return:
"""
if obj is None:
return self
if self.fget is None:
raise AttributeError('unreadale attribute')
return self.fget(obj)
def __set__(self, obj, value):
"""
把变量x赋值为管理属性(x=Property()),类.x 会因为赋值操作符(=)触发__set__方法(数据描述符)。
在__set__()内,返回由外界定义的设置属性的函数处理结果,从而达到效果
:param obj:
:param value:
:return:
"""
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
"""
把变量x赋值为管理属性(x=Property()),类.x 会因为del操作符(=)触发__delete__方法(数据描述符)。
在__delete__()内,返回由外界定义的删除属性的函数处理结果,从而达到效果
:param obj:
:return:
"""
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
# setter、setter、deleter是Property类用作装饰器的时候,在定义阶段用于返回Property类的作用,并将外部定义的函数作为property方法的参数入参。
def getter(self, fget):
# fget外部函数
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
# fget外部函数
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
# fget外部函数
return type(self)(self.fget, self.fset, fdel, self.__doc__)
5.2.1 额外讲解:type()
type除了验证数据类型外,type(self)还可以被用来动态创建一个新的描述符对象。这里的 self 是一个描述符实例,type(self) 获取了这个实例的类型,也就是描述符类本身。
然后,使用这个类型(描述符类)创建了一个新的实例,将 fget、self.fset、self.fdel 和 self.__doc__ 作为参数传递给这个类型的构造函数。
这种做法允许你在运行时动态地创建新的描述符对象.这是一种元编程技术,允许在运行时动态地创建类实例.
5.3 @property装饰器讲解
@property装饰器和上面的property()的目的都是一致,@property装饰器是property()的更简化版。
5.3.1 @property装饰器的使用
class C:
def __init__(self):
self._x = None # property装饰的属性,要用私有属性来定义
self.count = 0
# property装饰器装饰的函数是以私有属性去掉前下划线"_"来命名函数名
@property
def x(self):
return self._x # 对私有属性操作
@x.setter # 上面先定义好管理属性,才能使用setter
def x(self, value):
self.count += 1 # 触发描述符协议时候,统计管理属性被赋值次数
self._x = value # 对私有属性操作
@x.deleter # # 先定义好管理属性,才能使用deleter
def x(self):
del self._x # 对私有属性操作
# 和上面使用property()一样的使用操作
c = C()
c.x = 10
c.x = 20
print(c.x)
print(c.count)
5.4 property()的作用
property()是一个更简洁的数据描述符。
- 封装属性访问:property 允许你控制对属性的访问,可以在属性被读取、赋值或删除时执行代码。
- 实现计算属性:你可以创建动态计算的属性,而不是存储在实例中的值。
- 输入验证:在设置属性值时,property 允许你验证输入值,确保它们满足特定的条件。
- 只读属性:property 可以用来创建只读属性,不允许外部代码修改属性值。
六、基于描述符实现-常规函数与实例方法
python面向对象特性是基于函数环境。使用非数据描述符。
存储在类字典的函数,当被调用的时候,会转变成方法。
方法和常规函数的区别在于对象实例会在其他参数前面。
这里揭示了函数是如何变成方法。
6.1 示例:MethodType底层逻辑的Python语言等效代码
class MethodType:
"Emulate PyMethod_Type in Objects/classobject.c"
# 这个是MethodType的内部实现的核心代码
# 这段代码的作用是将函数和实例对象绑定一起,返回实例方法
def __init__(self, func, obj):
"""
func: 函数对象
obj: 实例对象
"""
self.__func__ = func
self.__self__ = obj
def __call__(self, *args, **kwargs):
func = self.__func__
obj = self.__self__
# 将函数和实例绑定一起,转成方法对象
# func(self, *args, **kwargs)-> self=obj,也就是实例对象
return func(obj, *args, **kwargs)
6.2 示例:通过MethodType手动创建实例方法
# 定义非数据描述符
# Function对象就是函数对象本身,Function.__name__就是函数的名称
class Function:
# 该函数只实现__get__(),是非数据描述符
...
def __get__(self, obj, objtype=None):
# "Simulate func_descr_get() in Objects/funcobject.c"
if obj is None: # 如果实例对象为None,返回Function对象本身
return self
# 如果实例对象存在,则将函数和实例对象绑定后返回方法
# self=Function=函数本身。所以这里传入了函数对象和实例对象。
return MethodType(self, obj)
6.3 实例方法的实战
class D:
def f(self, x):
return x
print(D.f.__qualname__) # D.f
# 通过类字典去访问函数不会触发__get__(),只会返回底层函数对象
print(D.__dict__['f']) # <function D.f at 0x000001B7EBA0CB80>
# 就算通过类对象去点查询函数,__get__()也只会返回底层函数对象
print(D.f) # <function D.f at 0x000002723B05CB80>
# 神奇的是,如果通过实例对象去点查询函数,__get__()会返回实例方法
d = D()
print(d.f) # <bound method D.f of <__main__.D object at 0x0000021C43DF8E80>>
print(d.f.__func__) # <function D.f at 0x0000021C43DECB80>
print(d.f.__self__) # <__main__.D object at 0x0000021C43DF8E80>
print(d.__dict__) # {} #为空说明了无论是函数还是方法,都是存储在类字典内,而非实例对象的字典内。
6.3.1 额外介绍:qualname
__qualname__的作用:用于表示对象的“限定名称”(qualified name),即对象的完整路径名,包括其在模块和类中的嵌套路径。
这个属性主要用于提供更清晰的名称解析,尤其是在处理嵌套函数和类时。
示例:
def outer_function():
def inner_function():
pass
return inner_function
# 获取嵌套函数的限定名称
inner = outer_function()
print(inner.__qualname__) # 输出: outer_function.inner_function
七 基于描述符实现-静态方法
静态方法返回未变动的底层函数。
7.1 静态方法特性
- 无论是实例对象或类对象都可以直接访问这个函数。调用像c.f或C.f都是直接查询的,object.__getattribute__(c,‘f’)或
object.__getattribute__(C,‘f’)一样的效果。 - 静态方法的适用对象:不需要引用self变量,也就是不需要引用类对象本身。相当于把一个普通函数强行和一个类关联起来,
但是这个普通函数也不需要依赖或引用这个类。 - 外部调用被静态方法装饰的方法和类方法装饰的方法的操作一致,真正区别在于内部静态方法不需要依赖类本身就可以实现函数。
示例:静态方法的调用,无论实例对象或类对象,结果都一样
class E:
@staticmethod # 经过静态方法装饰后,被装饰函数不需要传入self
def normarl_func(x):
return x * 10
def e(self):
return self.normarl_func(3) # 类内部可以引用静态方法修饰的方法
# print(E.normarl_func(3)) # 30
# print(E().normarl_func(3)) # 30
# print(E().e()) # 30
7.2 示例:静态方法-纯python代码解释静态方法底层代码
# Using the non-data descriptor protocol, a pure Python version of staticmethod() would look like this:
# 以下是python版的基于非数据描述符协议实现的staticmethod()
class StaticMethod:
def __init__(self, f):
"""
:param f: 被静态方法装饰的函数对象
"""
self.f = f
# 非数据描述符,只有__get__()
def __get__(self, obj, objtype=None):
# 直接返回类对象本身的函数,而非实例对象的函数。所以静态函数可以不需要传实例对象self
return self.f
八、基于描述符实现-类方法Class methods
8.1 类方法特性
- 适用对象:当方法只需要引用类本身,而不依赖于存储在实例中的数据时,这种行为就很有用.(也就是不需要引用实例,只需要类本身即可)
- 类方法装饰的方法的引用,对于类对象或实例对象引用都是一样的,没区别。
- 类方法装饰器的目的是可以让类内部的方法可以调用类对象本身,而不需要实例化。(而静态方法是不需要依赖实例化,也不需要依赖类自身本身)
示例:被类方法装饰器装饰过的函数,可以引用类本身的数据,而不需要实例化
class F:
@classmethod # 被classmethod装饰过的函数,可以直接引用类本身的东西,不需要依赖实例
def f(cls, x):
return cls.__name__, x # cls.__name__ 返回F类自身的name
def e(self):
return self.f(3)
# 类方法的引用,对于类对象或实例对象引用都是一样的,没区别。
# print(F.f(3)) # ('F', 3)
# print(F().f(3)) # ('F', 3)
print(F().e())
8.2 类方法的其中一个用途 – 创建替代的类构造方法
例如,字典对象的fromkeys()方法,利用键的列表来创建新的字典,值都是一样。
示例:原fromkeys()的使用实战
keys = ['a', 'b', 'c']
new_dict = dict.fromkeys(keys)
print(new_dict) # {'a': None, 'b': None, 'c': None}
示例:现在用类方法来重写实现字典对象的fromkeys()
# The pure Python equivalent is:
class Dict(dict):
@classmethod
def fromkeys(cls, iterable, value=None):
print(cls) # <class '__main__.Dict'> -> cls()就是类本身
d = cls() # 为什么cls()是dict对象,因为Dict继承了dict对象,所以cls()等价于d = dict(),相当于创建空字典
for key in iterable:
d[key] = value
return d
# 通过类方法装饰fromkeys方法,让fromkeys方法内部可以使用dict类对象本身,从而创建一个新的空字典对象
d = Dict.fromkeys('abracadabra')
print(type(d) is Dict)
print(d) # {'a': None, 'b': None, 'r': None, 'c': None, 'd': None}
8.3 示例:类方法-纯python代码解释类方法底层代码
# Using the non-data descriptor protocol, a pure Python version of classmethod() would look like this:
class ClassMethod:
def __init__(self, f):
self.f = f
def __get__(self, obj, cls=None):
"""
obj:实例对象
cls:类对象
"""
if cls is None:
cls = type(obj)
# 该代码在python3.9增加,用于支持装饰器链
if hasattr(type(self.f), '__get__'):
return self.f.__get__(cls)
return MethodType(self.f, cls)
8.3.1 额外讲解 – classmethod装饰器链介绍
示例:
class G:
@classmethod
@property
def __doc__(cls):
return f'A doc for {cls.__name__!r}'
print(G.__doc__) # A doc for 'G'
九、总结
以上代码都是根据官方文档给出的代码进行学习和翻译。