函数为什么也是描述符?从 `obj.method()` 彻底理解 Python 方法绑定机制
函数为什么也是描述符从obj.method()彻底理解 Python 方法绑定机制很多 Python 初学者第一次看到这句话时都会惊讶Python 中的函数也是描述符。函数不就是一段可以被调用的代码吗为什么会和“描述符”这种听起来很底层的机制扯上关系更有意思的是当我们写classUser:defsay_hello(self):print(hello)uUser()u.say_hello()我们明明只传了零个参数但say_hello(self)却需要一个self。这个self到底是谁帮我们传进去的答案就是函数对象实现了描述符协议Python 在访问实例方法时会自动把函数绑定到实例上生成一个绑定方法。这篇文章就围绕一个核心问题展开为什么 Python 中函数也是描述符它到底解决了什么问题一、先理解描述符它是 Python 属性访问的“拦截器”在 Python 中只要一个对象实现了下面三个方法中的任意一个它就可以被称为描述符__get__(self,instance,owner)__set__(self,instance,value)__delete__(self,instance)最常见的是__get__。我们先写一个最小描述符classMyDescriptor:def__get__(self,instance,owner):print(触发 __get__)print(instance:,instance)print(owner:,owner)return这是描述符返回的值classDemo:valueMyDescriptor()dDemo()print(d.value)输出类似触发 __get__ instance:__main__.Demoobjectat 0x...owner:class__main__.Demo这是描述符返回的值当你访问d.valuePython 并不是简单地从对象字典里拿值而是发现Demo.value是一个描述符于是自动调用Demo.__dict__[value].__get__(d,Demo)这就是描述符的本质它可以接管属性访问过程。二、函数为什么也是描述符现在我们看一个普通类方法classUser:defgreet(self):returnhello很多人以为greet在类里就是一个“方法”。其实在类对象里它首先是一个函数对象。我们可以验证classUser:defgreet(self):returnhelloprint(User.__dict__[greet])print(type(User.__dict__[greet]))输出类似function User.greet at 0x...classfunction也就是说类体中的def greet(self)创建的是一个函数对象并把它放进了类的命名空间中。关键来了函数对象本身实现了__get__方法。funcUser.__dict__[greet]print(hasattr(func,__get__))print(func.__get__)输出Truemethod-wrapper__get__of functionobjectat 0x...所以函数是描述符。准确地说普通函数实现了非数据描述符协议function.__get__(instance,owner)当我们访问uUser()u.greetPython 实际上会调用User.__dict__[greet].__get__(u,User)它返回的不是原始函数而是一个绑定方法 bound method。三、obj.method()背后发生了什么看这段代码classUser:defgreet(self,name):returnfhello,{name}uUser()print(User.greet)print(u.greet)输出类似function User.greet at 0x...bound method User.greet of__main__.Userobjectat 0x...两者不同User.greet拿到的是函数对象。u.greet拿到的是绑定方法。绑定方法内部保存了两样东西methodu.greetprint(method.__func__)# 原始函数print(method.__self__)# 被绑定的实例输出类似function User.greet at 0x...__main__.Userobjectat 0x...所以u.greet(Tom)本质上等价于User.greet(u,Tom)也就是说self并不神秘它只是 Python 在函数描述符的__get__阶段帮我们绑定进去的实例对象。可以用下面的流程理解u.greet ↓ 在 User 类中找到 greet 函数对象 ↓ 发现 greet 实现了 __get__ ↓ 调用 greet.__get__(u, User) ↓ 返回 bound method ↓ 调用 bound method(Tom) ↓ 实际执行 User.greet(u, Tom)四、手写一个“函数描述符”模拟方法绑定为了彻底理解函数为什么是描述符我们可以自己模拟一个简化版方法绑定机制。classBoundMethod:def__init__(self,func,instance):self.funcfunc self.instanceinstancedef__call__(self,*args,**kwargs):returnself.func(self.instance,*args,**kwargs)classFunctionLikeDescriptor:def__init__(self,func):self.funcfuncdef__get__(self,instance,owner):ifinstanceisNone:returnself.funcreturnBoundMethod(self.func,instance)defsay(self,message):returnf{self.name}:{message}classUser:def__init__(self,name):self.namename speakFunctionLikeDescriptor(say)uUser(Alice)print(u.speak(hello))输出Alice:hello这里的FunctionLikeDescriptor做的事情本质上就是普通函数在类中作为方法时做的事情如果通过类访问返回原始函数如果通过实例访问返回绑定了实例的方法对象调用绑定方法时自动把实例作为第一个参数传入。这就是self自动注入的底层逻辑。五、为什么 Python 要这样设计因为 Python 的类模型非常统一。在 Python 中类也是对象函数也是对象方法绑定也不是语法魔法而是建立在对象协议之上的行为。这种设计带来了几个好处。1. 类中的函数可以被直接访问classCalculator:defadd(self,x,y):returnxyprint(Calculator.add)这允许我们显式传入实例cCalculator()print(Calculator.add(c,1,2))输出3这说明方法并不是特殊语法它本质上仍然是函数。2. 实例方法绑定是动态完成的每次访问实例方法时Python 都会动态创建一个绑定方法对象。classDemo:defrun(self):passdDemo()print(d.runisd.run)通常输出False为什么因为每次执行d.run都会触发一次函数描述符的__get__返回一个新的绑定方法对象。但它们背后的函数和实例是相同的m1d.run m2d.runprint(m1.__func__ism2.__func__)print(m1.__self__ism2.__self__)输出TrueTrue这对于调试非常有帮助。3.staticmethod和classmethod本质也是描述符我们平时写的这些装饰器其实也依赖描述符机制。classDemo:definstance_method(self):print(实例方法,self)staticmethoddefstatic_method():print(静态方法)classmethoddefclass_method(cls):print(类方法,cls)访问它们dDemo()d.instance_method()d.static_method()d.class_method()三者行为不同实例方法自动绑定实例 self 静态方法不绑定任何对象 类方法自动绑定类 cls背后原因是普通函数 - __get__ 返回绑定实例的方法 staticmethod - __get__ 返回原始函数 classmethod - __get__ 返回绑定类的方法可以简单模拟一下staticmethodclassMyStaticMethod:def__init__(self,func):self.funcfuncdef__get__(self,instance,owner):returnself.func再模拟classmethodclassMyClassMethod:def__init__(self,func):self.funcfuncdef__get__(self,instance,owner):defwrapper(*args,**kwargs):returnself.func(owner,*args,**kwargs)returnwrapper这说明 Python 的方法体系不是零散设计而是统一建立在描述符协议之上。六、函数是“非数据描述符”描述符分两类数据描述符实现了 __set__ 或 __delete__ 非数据描述符只实现了 __get__普通函数只实现了__get__所以它是非数据描述符。这会影响属性查找优先级。Python 查找属性时大致顺序是1. 类中的数据描述符 2. 实例自身的 __dict__ 3. 类中的非数据描述符 4. 类中的普通属性 5. __getattr__因为函数是非数据描述符所以实例属性可以覆盖同名方法。例如classUser:defgreet(self):returnhellouUser()print(u.greet())输出hello现在给实例添加同名属性u.greet我覆盖了 greet 方法print(u.greet)输出我覆盖了 greet 方法此时再调用u.greet()会报错TypeError:strobjectisnotcallable这就是为什么在项目中不建议把实例属性命名成和方法一样的名字。例如下面的写法很危险classTask:defstatus(self):returnrunningdef__init__(self):self.statuspending这里self.status会覆盖status()方法。更好的写法是classTask:defget_status(self):returnrunningdef__init__(self):self.statuspending或者使用属性描述符classTask:def__init__(self):self._statuspendingpropertydefstatus(self):returnself._status七、实践案例用描述符构建一个字段校验器理解函数是描述符之后我们就能更自然地理解property、ORM 字段、表单校验器等机制。比如我们写一个简单的字段校验描述符classPositiveNumber:def__init__(self,name):self.namenamedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name)def__set__(self,instance,value):ifvalue0:raiseValueError(f{self.name}必须是正数)instance.__dict__[self.name]valueclassProduct:pricePositiveNumber(price)stockPositiveNumber(stock)def__init__(self,price,stock):self.priceprice self.stockstock pProduct(99,10)print(p.price)print(p.stock)输出9910如果赋值非法p.price-1会抛出ValueError:price 必须是正数这和函数描述符的思想是一致的把属性访问行为封装起来让对象在读取、写入、删除属性时具备可控逻辑。只不过普通函数使用描述符是为了完成“方法绑定”而我们自定义描述符通常用于数据校验 类型约束 延迟加载 缓存计算 权限控制 ORM 字段映射八、property也是描述符很多人用过property但不知道它也是描述符。classUser:def__init__(self,birth_year):self.birth_yearbirth_yearpropertydefage(self):return2026-self.birth_year uUser(2000)print(u.age)输出26看起来像访问属性u.age实际上会触发property.__get__。如果添加 setterclassUser:def__init__(self):self._namepropertydefname(self):returnself._namename.setterdefname(self,value):ifnotvalue:raiseValueError(name 不能为空)self._namevalue uUser()u.nameAliceprint(u.name)这里的property就是典型的数据描述符因为它不只控制读取还能控制写入。九、常见误区函数、方法、绑定方法不是一回事在 Python 中这三个概念要分清楚。1. 函数类中原始定义的是函数classA:deffoo(self):passprint(A.__dict__[foo])这是function2. 未绑定访问通过类访问print(A.foo)得到的仍然主要是函数对象。调用时需要手动传实例aA()A.foo(a)3. 绑定方法通过实例访问a.foo得到的是绑定方法bound method A.foo of__main__.Aobjectat...此时实例已经被绑定到__self__上。调用时不需要再传selfa.foo()十、调试技巧如何观察方法绑定在排查复杂面向对象问题时可以用下面几个属性classService:defprocess(self,data):returndata.upper()sService()methods.processprint(method.__self__)print(method.__func__)print(method.__name__)print(method.__qualname__)输出类似__main__.Serviceobjectat 0x...function Service.process at 0x...process Service.process如果你怀疑某个实例方法被覆盖可以检查print(s.__dict__)print(Service.__dict__)例如s.processbrokenprint(s.__dict__)print(Service.__dict__[process])你会发现实例字典中出现了同名属性导致方法访问被覆盖。十一、最佳实践如何避免描述符相关问题1. 不要让实例属性和方法同名坏例子classJob:defresult(self):returnsuccessdef__init__(self):self.resultNone好例子classJob:defget_result(self):returnsuccessdef__init__(self):self.resultNone或者classJob:def__init__(self):self._resultNonepropertydefresult(self):returnself._result2. 装饰器中保留函数元信息写装饰器时建议使用functools.wraps。importtimefromfunctoolsimportwrapsdeftimer(func):wraps(func)defwrapper(*args,**kwargs):starttime.perf_counter()resultfunc(*args,**kwargs)endtime.perf_counter()print(f{func.__name__}耗时{end-start:.6f}秒)returnresultreturnwrapperclassCalculator:timerdefcompute_sum(self,n):returnsum(range(n))cCalculator()print(c.compute_sum(1_000_000))print(c.compute_sum.__name__)没有wraps时__name__可能会变成wrapper这会影响调试、日志、文档生成和某些框架行为。3. 写自定义描述符时处理类访问描述符的__get__中一定要考虑ifinstanceisNone:returnself例如classField:def__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(value)否则通过类访问描述符时可能出现意料之外的结果。十二、总结函数是描述符是 Python 优雅对象模型的关键拼图现在我们可以回答标题中的问题了。Python 中函数为什么也是描述符因为 Python 需要一种统一、灵活、可扩展的机制把类中的普通函数在实例访问时自动转换成绑定方法。换句话说classUser:defgreet(self):pass这里的greet首先是函数对象。当你访问u.greet函数对象的__get__被触发返回一个绑定了u的方法对象。于是u.greet()等价于User.greet(u)这就是self自动传入的真正原因。描述符不仅解释了实例方法还解释了property staticmethod classmethod ORM 字段 校验器 缓存属性 框架中的依赖注入当你理解“函数也是描述符”之后你会发现 Python 的很多高级特性不再神秘。它们不是魔法而是一组优雅协议的组合。Python 的美正在于此表面简单底层深邃。初学者可以用它快速写出清晰代码资深开发者也可以沿着这些协议深入语言内核构建强大、灵活、可维护的系统。最后留一个问题给你你在项目中有没有遇到过“方法突然不能调用”或者“属性覆盖方法”的问题下次遇到它也许就该从描述符和属性查找顺序开始排查了。