引言
用 Python 的爽点之一就是其一定的元编程和运行时动态生成与改变代码的能力。比较常见的一种操作,是给类动态挂载一些函数。最出名的例子如下:
class A:
pass
def foo(self):
print("hi")
A.foo = foo
a = A()
a.foo()
# "hi"
如果我们只是能以变量的形式拿到需要挂载函数的名字,那么反射机制还是可以很好的解决问题:setattr(A, "foo", foo)
即可。但这并不是问题的全部,当情况变得稍微复杂,我们将面临各种各样的组合,其中有些可以 work,另一些会报错。本文我们通过从中抽象出几组最小对立的案例来分析,从而全面理解实例方法动态绑定的机制,这实际上也是对 Python 描述器机制的温习,关于描述器的具体内容可以参考我之前写的1。
对立一:类属性与实例属性
我们观察引言里最简单的例子
class A:
pass
def foo(self):
print("hi")
A.foo = foo
a = A()
a.foo()
# "hi"
和
class A:
pass
def foo(self):
print("hi")
a = A()
a.foo = foo
a.foo()
# TypeError: foo() missing 1 required positional argument: 'self'
这两者一个成功一个失败,其区别应该是怎么理解呢。对于类属性对应的函数,我们在调用的时候自动实现了”函数“这个类的描述器行为,也即 a.foo() = A.foo(a)
, 注意到描述器行为的定义,只有”函数“这种定义了描述器行为的对象是类(这里是 A)属性时才有这种描述器机制,实例属性是描述器对象,并不会触发描述器机制,也即不会完成将实例作为第一个变量送入函数这种转换。这就是为什么会报错缺少一个变量 self
。
对于后者报错的例子,我们有两种改正可以让其 work (当然个人认为前者直接把方法注册到类上,而非具体实例上,大部分时候更好一点)。但如果有需求注册到具体实例上的函数,两修正方案如下,分别依赖于偏函数和 types 模块,两者方案还有细微差别。对于挂载后的实例的属性 foo
是否是方法的判定是相反的。其原因在对立三会进一步分析。
import types
import functools
class A:
pass
def foo(self):
print("hi")
a = A()
a.foo = functools.partial(foo, a)
isinstance(a.foo, types.MethodType) # False
a.foo()
# "hi"
import types
class A:
pass
def foo(self):
print("hi")
a = A()
a.foo = types.MethodType(foo, a)
isinstance(a.foo, types.MethodType) # True
a.foo()
# "hi"
对立二:类和实例的实例方法
这一对最小对立如下
class A:
def bar(self):
print("hi")
a = A()
a.foo = a.bar
a.foo()
# hi
和
class A:
def bar(self):
print("hi")
a = A()
a.foo = A.bar
a.foo()
# TypeError: bar() missing 1 required positional argument: 'self'
其原因还是在于”函数“这一对象的描述器定义,继续回顾1谈到的内容,函数对应的描述器定义等效为
class Function():
'''
of course, there are many other attrs omitted here
'''
def __get__(self, obj, objtype=None):
"Simulate func_descr_get() in Objects/funcobject.c"
if obj is None:
return self
return types.MethodType(self, obj)
由此可以看出,当从实例拉起实例方法时,也即 a.bar()
由于 a
是实例,相应的根据描述器机制变成了 A.bar(a)
。而从类拉起时 A.bar()
传入 bar
对应的 __get__
时,obj
参数为 None
,由此该方法原样被返回,因此会报错缺少变量传入。
对立三:闭包和偏函数
对于动态绑定到类上的函数,如果有一些变量需要在绑定时指定的话,最简单的两种方案分别是闭包和偏函数,两者恰好也形成一组对立。
class A:
pass
def foo(i):
def fooinside(self):
return i
return fooinside
A.foo = foo(2)
a = A()
isinstance(a.foo, types.MethodType) # True
print(a.foo()) # 2
import functools
class A:
pass
def foo(self, i):
return i
A.foo = functools.partial(foo, i=2)
a = A()
isinstance(a.foo, types.MethodType) # False
print(a.foo()) # TypeError: foo() missing 1 required positional argument: 'self'
嵌套函数和偏函数明明看起来行为完全一致,为何一个成功绑定成了类的实例方法,另一个没有呢,原因还是得从描述器机制说起。闭包返回的函数确实还是一个”函数“对象,其依旧继承了函数对象描述器定义的 __get__
方法,这是实例方法实现的根本。而另一方面,Python 里 partial
的实现,并不是函数闭包,而是一个自定义的,带 __call__
的类,这一个类已经不是”函数“类的子类了,因此其默认不具有 __get__
方法,从而没有相应的描述器机制来自动转化为实例方法。这一点 Python 官方文档里写的很清楚2:
functools.partial(func, /, *args,**keywords)
Return a new partial object
partial
objects are likefunction
objects in that they are callable, weak referencable, and can have attributes. There are some important differences. For instance, the__name__
and__doc__
attributes are not created automatically. Also,partial
objects defined in classes behave like static methods and do not transform into bound methods during instance attribute look-up.
注意最后一句就是我们这里说的意思,所谓 instance attribute look-up 就是我们这里说的方法的描述器机制。
一个可能的 workaround 是用 functools 里提供的 partial
的姐妹方法 partialmethod
,例子如下:
import functools
class A:
pass
def foo(self, i):
return i
A.foo = functools.partialmethod(foo, i=2)
a = A()
print(isinstance(a.foo, types.MethodType)) # False
print(a.foo()) # 2
注意到这时实例方法绑定后可以正常工作了,但是对应的是否是实例方法的判断返回的还是 False。原因在于,本质上 partialmethod
返回的还是一个非函数的对象,只不过这次其妥善处理继承了函数类的 __get__
也即描述器机制而已。
综上,本质上要妥善处理和理解上述的各种情形,都是需要对 Python 的描述器机制有深刻的认识。