060、描述符协议get、set、delete——property 的底层实现上周帮同事排查一个诡异的bug某个类属性在并发环境下偶尔返回None但明明构造函数里已经赋值了。翻代码看了半小时发现他用property装饰器包装了一个属性但装饰器内部逻辑有分支没覆盖到。我盯着那段代码突然意识到——很多人用property用得飞起但真问起描述符协议十个有九个答不上来。从一次调试说起那个bug的简化版长这样classUser:def__init__(self,name):self._namenamepropertydefname(self):ifself._nameisNone:returndefaultreturnself._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:attrReadOnlyDescriptor()objMyClass()print(obj.attr)# 42obj.attr100# 这里不会报错直接覆盖了描述符print(obj.attr)# 100描述符被实例属性覆盖了这里踩过坑想实现只读属性必须同时实现__set__并抛出AttributeError。property的底层实现property本质上是一个用C实现的数据描述符。我们可以用纯Python模拟一个简化版classMyProperty:def__init__(self,fgetNone,fsetNone,fdelNone,docNone):self.fgetfget self.fsetfset self.fdelfdelifdocisNoneandfgetisnotNone:docfget.__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(cant set attribute)self.fset(instance,value)def__delete__(self,instance):ifself.fdelisNone:raiseAttributeError(cant 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):returnoriginalobjMyClass()obj.methodoverwritten# 这里不会报错print(obj.method)# overwritten方法被实例属性覆盖了因为函数是非数据描述符只实现了__get__实例属性的优先级更高。这就是为什么有时候你给实例绑了个同名属性方法就调不到了。实战用描述符实现类型检查我在实际项目中用描述符做类型检查比用property写一堆重复代码优雅得多classTyped:def__init__(self,name,expected_type):self.namename self.expected_typeexpected_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:nameTyped(name,str)ageTyped(age,int)def__init__(self,name,age):self.namename# 触发__set__self.ageage# 触发__set__pPerson(Alice,30)p.agethirty# TypeError: age must be class int这里有个设计细节描述符里用instance.__dict__存储实际值而不是在描述符对象内部存。这样每个实例都有自己的值不会互相干扰。描述符的陷阱与最佳实践陷阱1描述符是类属性不是实例属性classDescriptor:def__get__(self,instance,owner):return42classMyClass:attrDescriptor()obj1MyClass()obj2MyClass()# obj1.attr和obj2.attr共享同一个描述符实例别这样写——如果你在描述符内部存储状态所有实例都会共享这个状态除非你通过instance.__dict__来区分。陷阱2描述符的__set_name__Python 3.6引入了__set_name__可以在类创建时自动获取属性名classTyped:def__set_name__(self,owner,name):self.namenamedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name)def__set__(self,instance,value):instance.__dict__[self.name]valueclassPerson:nameTyped()# 自动获取属性名为nameageTyped()# 自动获取属性名为age这里踩过坑__set_name__只在类创建时调用一次如果你动态修改类属性它不会重新触发。个人经验建议能用property就别手写描述符。property已经覆盖了90%的场景手写描述符容易引入bug。我只有在需要复用逻辑比如类型检查、日志记录时才用描述符。调试描述符问题时先确认它是数据描述符还是非数据描述符。这个区别决定了属性查找顺序很多诡异问题都出在这里。描述符里不要缓存值在描述符对象上。除非你明确知道自己在做单例或类级别共享否则永远用instance.__dict__存储实例数据。__set_name__是你的好朋友。在Python 3.6的项目里用它自动获取属性名避免手动传参的重复劳动。小心描述符的继承。子类继承父类的描述符时描述符的__get__方法接收的owner参数是子类不是父类。这个细节在实现某些框架功能时特别重要。最后说一句描述符是Python元编程的基石理解了它你才能真正理解property、classmethod、staticmethod这些装饰器的底层原理。下次遇到属性访问的诡异bug先想想是不是描述符在搞鬼。