理解 Python 中的描述符

目录
描述符的定义
描述符的作用
描述符的类型
描述符的实现
对象属性如何保存
对象属性如何访问
getattr 方法
Python 内部的描述符
property
函数
classmethod
staticmethod
参考链接
描述符是 Python 中的一个进阶概念,也是许多 Python 内部机制的实现基础,本文将对其做适当深入的介绍。

描述符的定义
描述符的定义很简单,实现了下列任意一个方法的 Python 对象就是一个描述符(descriptor):

get(self, obj, type=None)
set(self, obj, value)
delete(self, obj)
这些方法的参数含义如下:

self 是当前定义的描述符对象实例。
obj 是该描述符将作用的对象实例。
type 是该描述符作用的对象的类型(即所属的类)。
上述方法也被称为描述符协议,Python 会在特定的时机按协议传入参数调用某一方法,如果我们未按协议约定的参数定义方法,调用可能会出错。

描述符的作用
描述符可以用来控制对属性的访问行为,实现计算属性、懒加载属性、属性访问控制等功能,我们先来举个简单的例子:

class Descriptor:

def __get__(self, instance, owner):
    if instance is None:
        print('__get__(): Accessing x from the class', owner)
        return self
    
    print('__get__(): Accessing x from the object', instance)
    return 'X from descriptor'

def __set__(self, instance, value):
    print('__set__(): Setting x on the object', instance)
    instance.__dict__['_x'] = value

class Foo:
x = Descriptor()
在示例中我们创建了一个描述符实例,并将其赋值给 Foo 类的 x 属性变量。现在访问 Foo.x ,会发现 Python 自动调用了该属性所绑定的描述符实例的 get() 方法:

print(Foo.x)
get(): Accessing x from the class <class ‘main.Foo’>
<main.Descriptor object at 0x106e138e0>
接下来实例化一个对象 foo,并通过 foo 对象访问 x 属性:

foo = Foo()
print(foo.x)
get(): Accessing x from the object <main.Foo object at 0x105dc9340>
X from descriptor
同样执行了描述符所定义的相应方法。

如果我们尝试对 foo 对象的 x 进行赋值,也会调用描述符的 set() 方法:

foo.x = 1
set(): Setting x on the object <main.Foo object at 0x105dc9340>
print(foo.x)
get(): Accessing x from the object <main.Foo object at 0x105dc9340>
X from descriptor
print(foo.dict)
{‘_x’: 1}
同理,如果我们在描述符中定义了 delete() 方法,该方法将在执行 del foo.x 时被调用。

描述符在属性查找过程中会被 . 点操作符调用,且只有在作为类变量使用时才有效。

如果直接赋值给实例属性,描述符不会生效。

foo.dict[‘y’] = Descriptor()
print(foo.y)
<main.Descriptor object at 0x100f0d130>
如果用 some_class.dict[descriptor_name] 的方式间接访问描述符,也不会调用描述符的协议方法,而是返回描述符实例本身。

print(Foo.dict[‘x’])
<main.Descriptor object at 0x10b66d8e0>
描述符的类型
根据所实现的协议方法不同,描述符又可分为两类:

若实现了 set() 或 delete() 任一方法,该描述符是一个数据描述符(data descriptor)。
若仅实现 get() 方法,该描述符是一个非数据描述符(non-data descriptor)。
两者的在表现行为上存在差异:

数据描述符总是会覆盖实例字典 dict 中的属性。
而非数据描述可能会被实例字典 dict 中定义的属性所覆盖。
在上面的示例中我们已经展示数据描述符的效果,接下来去掉 set() 方法实现一个非数据描述符:

class NonDataDescriptor:

def __get__(self, instance, owner):
    if instance is None:
        print('__get__(): Accessing y from the class', owner)
        return self

    print('__get__(): Accessing y from the object', instance)
    return 'Y from non-data descriptor'

class Bar:
y = NonDataDescriptor()

bar = Bar()
当 bar.dict 不存在键为 y 的属性时,访问 bar.y 和 foo.x 的行为是一致的:

print(bar.y)
Y from non-data descriptor
但如果我们直接修改 bar 对象的 dict,向其中添加 y 属性,则该对象属性将覆盖在 Bar 类中定义的 y 描述符,访问 bar.y 将不再调用描述符的 get() 方法:

bar.dict[‘y’] = 2
print(bar.y)
2
而在上文的数据描述符示例中,即使我们修改 foo.dict,对 x 属性的访问始终都由描述符所控制:

foo.dict[‘x’] = 1
print(foo.x)
get(): Accessing x from the object <main.Foo object at 0x102b40340>
在下文中我们会介绍这两者的差异是如何实现的。

描述符的实现
描述符控制属性访问的关键,在于从执行 foo.x 到 get() 方法被调用这中间所发生的过程。

对象属性如何保存
一般来说,对象的属性保存在 dict 属性中:

根据 Python 文档介绍,object.dict 是一个字典或其他的映射类型对象,用于存储一个对象的(可写)属性。
除了一些 Python 的内置对象以外,大部分自定义的对象都会有一个 dict 属性。
这个属性包含了所有为该对象定义的属性,dict 也被称为 mappingproxy 对象。
我们从之前的示例继续:

print(foo.dict)
{‘_x’: 1}
foo.x
1
当我们访问 foo.x ,Python 是如何判断应该调用描述符方法还是从 dict 中获取对应值的呢?其中起关键作用的是 . 这个点操作符。

对象属性如何访问
点操作符的查找逻辑位于 object.getattribute() 方法中,每一次向对象执行点操作符都会调用对象的该方法。CPython 中该方法由 C 实现,我们来看一下它的等价 Python 版本:

def object_getattribute(obj, name):
“Emulate PyObject_GenericGetAttr() in Objects/object.c”
null = object()
objtype = type(obj)
cls_var = getattr(objtype, name, null)
descr_get = getattr(type(cls_var), ‘get’, null)
if descr_get is not null:
if (hasattr(type(cls_var), ‘set’)
or hasattr(type(cls_var), ‘delete’)):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, ‘dict’) and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
理解以上代码可知,当我们访问 object.name 时会依次执行下列过程:

首先从 obj 所属的类 objtype 中查找 name 属性,如果对应的类变量 cls_var 存在,尝试获取 cls_var 所属的类的 get 属性。
如果 get 属性存在,即说明 cls_var (至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 setdelete 属性),如果是,则调用在描述符中定义的 get 方法,并传入当前对象 obj 和当前对象所属类 objtype 作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 dict 的访问。
如果 cls_var 为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 dict 中查找 name 属性,若有则返回该属性对应的值。
如果在 obj 的 dict 中未找到 name 属性,且 cls_var 为非数据描述符,则调用在描述符中定义的 get 方法,和上文一样传入相应参数并返回调用结果。
如果 cls_var 不是描述符,则将其直接返回。
如果最后还没找到,唤起 AttributeError 异常。
在以上过程中,当我们从 obj 所属的类 objtype 中获取 name 属性时,若 objtype 中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.mro 类方法的返回结果:

print(Foo.mro)
(<class ‘main.Foo’>, <class ‘object’>)
现在我们知道,描述符在 object.getattribute() 方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.getattribute() 方法,甚至可以取消所有的描述符调用。

getattr 方法
实际上,属性查找并不会直接调用 object.getattribute() ,点操作符会通过一个辅助函数来执行属性查找:

def getattr_hook(obj, name):
“Emulate slot_tp_getattr_hook() in Objects/typeobject.c”
try:
return obj.getattribute(name)
except AttributeError:
if not hasattr(type(obj), ‘getattr’):
raise
return type(obj).getattr(obj, name) # getattr
因此,如果 obj.getattribute() 的结果引发异常,且存在 obj.getattr()方法,该方法将被执行。如果用户直接调用 obj.getattribute(),getattr() 的补充查找机制就会被绕过。

假如为 Foo 类添加该方法:

class Foo:
x = Descriptor()

def __getattr__(self, item):
    print(f'{item} is indeed not found')

foo = Foo()
然后分别调用 foo.z 和 bar.z:

foo.z
z is indeed not found
bar.z
AttributeError: ‘Bar’ object has no attribute ‘z’
该行为仅在对象所属的类定义了 getattr()方法时才生效,在对象中定义 getattr 方法,即在 obj.dict 中添加该属性是无效的,这一点同样适用于 getattribute() 方法:

bar.getattr = lambda item:print(f’{item} is indeed not found’)
print(bar.dict)
{‘getattr’: <function at 0x1086e1430>}
bar.z
AttributeError: ‘Bar’ object has no attribute ‘z’
Python 内部的描述符
除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。

property
property 的具体效果我们不再赘述,下面是其常见的语法糖用法:

class C:
def init(self):
self._x = None

@property
def x(self):
    """I'm the 'x' property."""
    return self._x

@x.setter
def x(self, value):
    self._x = value

@x.deleter
def x(self):
    del self._x

property 本身是一个实现了描述符协议的类,它还可以通过以下等价方式使用:

class C:
def init(self):
self._x = None

def getx(self):
    return self._x

def setx(self, value):
    self._x = value

def delx(self):
    del self._x

x = property(getx, setx, delx, "I'm the 'x' property.")

在上面例子中 property(getx, setx, delx, “I’m the ‘x’ property.”) 创建了一个描述符实例,并赋值给了 x。property 类的实现与下面的 Python 代码等价:

class Property:
“Emulate PyProperty_Type() in Objects/descrobject.c”

def __init__(self, fget=None, fset=None, fdel=None, doc=None):
    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):  # 描述符协议方法
    if obj is None:
        return self
    if self.fget is None:
        raise AttributeError("unreadable attribute")
    return self.fget(obj)

def __set__(self, obj, value):  # 描述符协议方法
    if self.fset is None:
        raise AttributeError("can't set attribute")
    self.fset(obj, value)

def __delete__(self, obj):  # 描述符协议方法
    if self.fdel is None:
        raise AttributeError("can't delete attribute")
    self.fdel(obj)

def getter(self, fget):  # 实例化一个拥有 fget 属性的描述符对象
    return type(self)(fget, self.fset, self.fdel, self.__doc__)

def setter(self, fset):  # 实例化一个拥有 fset 属性的描述符对象
    return type(self)(self.fget, fset, self.fdel, self.__doc__)

def deleter(self, fdel):  # 实例化一个拥有 fdel 属性的描述符对象
    return type(self)(self.fget, self.fset, fdel, self.__doc__)

property 在描述符实例的字典内保存读、写、删除函数,然后在协议方法被调用时判断是否存在相应函数,实现对属性的读、写与删除的控制。

函数
没错,每一个我们定义的函数对象都是一个非数据描述符实例。

这里使用描述符的目的,是让在类定义中所定义的函数在通过对象调用时成为绑定方法(bound method)。

方法在调用时会自动传入对象实例作为第一个参数,这是方法和普通函数的唯一区别。通常我们会在定义方法时,将这个形参指定为 self。方法对象的类定义与下面的代码等价:

class MethodType:
“Emulate PyMethod_Type in Objects/classobject.c”

def __init__(self, func, obj):
    self.__func__ = func
    self.__self__ = obj

def __call__(self, *args, **kwargs):
    func = self.__func__
    obj = self.__self__
    return func(obj, *args, **kwargs)

它在初始化方法中接收一个函数 func 和一个对象 obj,并在调用时将 obj 传入 func 中。

我们举一个实际的例子:

class D:
… def f(self, x):
… return x


d = D()
D.f(None, 2)
2
d.f(2)
2
可以看到,当通过类属性调用 f 时,其行为就是一个正常的函数,可以将任意对象作为 self 参数传入;当通过实例属性访问 f 时,其效果变成了绑定方法调用,因此在调用时会自动将绑定的对象作为第一个参数。 显然在通过实例访问属性时创建一个 MethodType 对象,这正是我们可以通过描述符实现的效果。

函数的具体实现如下:

class Function:

def __get__(self, obj, objtype=None):
    "Simulate func_descr_get() in Objects/funcobject.c"
    if obj is None:
        return self
    return MethodType(self, obj)

通过 def f() 定义函数时,等价于 f = Function() ,即创建一个非数据描述符实例并赋值给 f 变量。

当我们通过类方法访问该属性时,调用 get() 方法返回了函数对象本身:

D.f
<function D.f at 0x10f1903a0>
当我们通过对象实例访问该属性时, 调用 get() 方法创建一个使用以上函数和对象所初始化的 MethodType 对象:

d.f
<bound method D.f of <main.D object at 0x10eb6fb50>>
概括地说,函数作为对象有一个 get() 方法,使其成为一个非数据描述符实例,这样当它们作为属性访问时就可以转换为绑定方法。非数据描述符将通过实例调用 obj.f(*args) 转换为 f(obj, *args),通过类调用 cls.f(*args) 转换成 f(*args)。

classmethod
classmethod 是在函数描述符基础上实现的变种,其用法如下:

class F:
@classmethod
def f(cls, x):
return cls.name, x

F.f(3)
(‘F’, 3)
F().f(3)
(‘F’, 3)
其等价 Python 实现如下,有了上面的铺垫会很容易理解:

class ClassMethod:
“Emulate PyClassMethod_Type() in Objects/funcobject.c”

def __init__(self, f):
    self.f = f

def __get__(self, obj, cls=None):
    if cls is None:
        cls = type(obj)
    if hasattr(obj, '__get__'):
        return self.f.__get__(cls)
    return MethodType(self.f, cls)

@classmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(type(obj), *args),通过类调用 cls.f(*args) 转换成 f(*args)。

staticmethod
staticmethod 实现的效果是,不管我们通过实例调用还是通过类调用,最终都会调用原始的函数:

class E:
@staticmethod
def f(x):
return x * 10

E.f(3)
30
E().f(3)
30
其等价 Python 实现如下:

class StaticMethod:
“Emulate PyStaticMethod_Type() in Objects/funcobject.c”

def __init__(self, f):
    self.f = f

def __get__(self, obj, objtype=None):
    return self.f

调用 get() 方法时返回了保存在 dict 中的函数对象本身,因此不会进一步触发函数的描述符行为。

@staticmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(*args),通过类调用 cls.f(*args) 也转换成 f(*args)。

讲得很不错,收藏下: https://waynerv.com/posts/python-descriptor-in-detail/

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值