060、描述符协议:__get__、__set__、__delete__——property 的底层实现
060、描述符协议:get、set、delete——property 的底层实现
上周帮同事排查一个诡异的bug:某个类属性在并发环境下偶尔返回None,但明明构造函数里已经赋值了。翻代码看了半小时,发现他用@property装饰器包装了一个属性,但装饰器内部逻辑有分支没覆盖到。我盯着那段代码突然意识到——很多人用property用得飞起,但真问起描述符协议,十个有九个答不上来。
从一次调试说起
那个bug的简化版长这样:
classUser:def__init__(self,name):self._name=name@propertydefname(self):ifself._nameisNone:return"default"returnself._name同事在某个线程里调了user.name = None,然后另一个线程读user.name,触发了property的getter,返回了"default"。他以为property会像普通属性一样直接返回None,但property的getter被触发了,逻辑走了默认分支。
这个问题的本质是:property不是普通属性,它是一个描述符。描述符协议决定了Python如何拦截属性的访问、赋值和删除操作。
描述符协议三剑客
描述符协议定义了三个魔法方法:
__get__(self, instance, owner):访问属性时触发__set__(self, instance, value):赋值属性时触发__delete__(self, instance):删除属性时触发
这里有个坑:只有实现了__set__或__delete__的描述符才是数据描述符,只实现__get__的是非数据描述符。数据描述符的优先级高于实例属性,非数据描述符的优先级低于实例属性。
别这样写——我曾经见过有人只实现了__get__,然后以为它能拦截赋值操作,结果赋值直接覆盖了描述符:
classReadOnlyDescriptor:def__get__(self,instance,owner):return42classMyClass:attr=ReadOnlyDescriptor()obj=MyClass()print(obj.attr)# 42obj.attr=100# 这里不会报错,直接覆盖了描述符print(obj.attr)# 100,描述符被实例属性覆盖了这里踩过坑:想实现只读属性,必须同时实现__set__并抛出AttributeError。
property的底层实现
property本质上是一个用C实现的数据描述符。我们可以用纯Python模拟一个简化版:
classMyProperty:def__init__(self,fget=None,fset=None,fdel=None,doc=None):self.fget=fget self.fset=fset self.fdel=fdelifdocisNoneandfgetisnotNone:doc=fget.__doc__ self.__doc__=docdef__get__(self,instance,owner):ifinstanceisNone:# 通过类访问时返回描述符本身returnselfifself.fgetisNone:raiseAttributeError("unreadable attribute")returnself.fget(instance)def__set__(self,instance,value):ifself.fsetisNone:raiseAttributeError("can't set attribute")self.fset(instance,value)def__delete__(self,instance):ifself.fdelisNone:raiseAttributeError("can't delete attribute")self.fdel(instance)defgetter(self,fget):returntype(self)(fget,self.fset,self.fdel,self.__doc__)defsetter(self,fset):returntype(self)(self.fget,fset,self.fdel,self.__doc__)defdeleter(self,fdel):returntype(self)(self.fget,self.fset,fdel,self.__doc__)注意看__get__里的那个if instance is None判断——这里踩过坑:通过类访问属性时,instance是None,返回描述符对象本身。很多人不知道这个细节,导致调试时一脸懵逼。
描述符的查找顺序
Python的属性查找顺序是面试常考题,也是调试时最容易出问题的地方:
- 数据描述符(实现了
__set__或__delete__) - 实例属性(
__dict__) - 非数据描述符(只实现了
__get__) - 类属性
这个顺序决定了为什么property能覆盖实例属性,而普通方法(非数据描述符)会被实例属性覆盖。
举个例子,你可能会遇到这种诡异情况:
classMyClass:defmethod(self):return"original"obj=MyClass()obj.method="overwritten"# 这里不会报错print(obj.method)# "overwritten",方法被实例属性覆盖了因为函数是非数据描述符(只实现了__get__),实例属性的优先级更高。这就是为什么有时候你给实例绑了个同名属性,方法就调不到了。
实战:用描述符实现类型检查
我在实际项目中用描述符做类型检查,比用property写一堆重复代码优雅得多:
classTyped:def__init__(self,name,expected_type):self.name=name self.expected_type=expected_typedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name)def__set__(self,instance,value):ifnotisinstance(value,self.expected_type):raiseTypeError(f"{self.name}must be{self.expected_type}")instance.__dict__[self.name]=valuedef__delete__(self,instance):delinstance.__dict__[self.name]classPerson:name=Typed("name",str)age=Typed("age",int)def__init__(self,name,age):self.name=name# 触发__set__self.age=age# 触发__set__p=Person("Alice",30)p.age="thirty"# TypeError: age must be <class 'int'>这里有个设计细节:描述符里用instance.__dict__存储实际值,而不是在描述符对象内部存。这样每个实例都有自己的值,不会互相干扰。
描述符的陷阱与最佳实践
陷阱1:描述符是类属性,不是实例属性
classDescriptor:def__get__(self,instance,owner):return42classMyClass:attr=Descriptor()obj1=MyClass()obj2=MyClass()# obj1.attr和obj2.attr共享同一个描述符实例别这样写——如果你在描述符内部存储状态,所有实例都会共享这个状态,除非你通过instance.__dict__来区分。
陷阱2:描述符的__set_name__
Python 3.6引入了__set_name__,可以在类创建时自动获取属性名:
classTyped:def__set_name__(self,owner,name):self.name=namedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name)def__set__(self,instance,value):instance.__dict__[self.name]=valueclassPerson:name=Typed()# 自动获取属性名为"name"age=Typed()# 自动获取属性名为"age"这里踩过坑:__set_name__只在类创建时调用一次,如果你动态修改类属性,它不会重新触发。
个人经验建议
能用property就别手写描述符。property已经覆盖了90%的场景,手写描述符容易引入bug。我只有在需要复用逻辑(比如类型检查、日志记录)时才用描述符。
调试描述符问题时,先确认它是数据描述符还是非数据描述符。这个区别决定了属性查找顺序,很多诡异问题都出在这里。
描述符里不要缓存值在描述符对象上。除非你明确知道自己在做单例或类级别共享,否则永远用
instance.__dict__存储实例数据。__set_name__是你的好朋友。在Python 3.6+的项目里,用它自动获取属性名,避免手动传参的重复劳动。小心描述符的继承。子类继承父类的描述符时,描述符的
__get__方法接收的owner参数是子类,不是父类。这个细节在实现某些框架功能时特别重要。
最后说一句:描述符是Python元编程的基石,理解了它,你才能真正理解property、classmethod、staticmethod这些装饰器的底层原理。下次遇到属性访问的诡异bug,先想想是不是描述符在搞鬼。
