Python类机制-Bound Method与Unbound Method

Bound Method和Unbound Method是Python中访问作为属性的函数的两种形式。简单的说,Bound Method是通过类的实例对象进行属性引用,Unbound Method则是直接通过类名进行属性引用。用以下的小程序组合展现一下它们的样子:

##############################################
# [A.py]
class A:
    def f(self):
        pass

##############################################
# [test1.py]
import timeit

test_str = """
    a = A()
    for i in range(10000000):
        a.f() # Bound Method
    """

t = timeit.Timer(test_str, "from A import A")
print min(t.repeat(3, 1)) # 1

##############################################
# [test2.py]
import timeit

test_str = """
    a = A()
    for i in range(10000000):
        A.f(a) # Unbound Method
    """

t = timeit.Timer(test_str, "from A import A")
print min(t.repeat(3, 1)) # 2
        

显然,我们不会简单的为了看这两种引用形式的外延,所以在test1.py和test2.py中都分别加上了计时代码,看两者的效率差距,并从中找到它们的本质区别。对于Bound Method和Unbound Method,它们某次测试的耗时分别是2.60687589645和7.64465117455(注:单位为秒,笔者进行过多次测试,结果相差不大。两个文件中“min(t.repeat(3, 1))”表示运行该程序3次并取最少值,取最少值而不取平均值是因为尽量让结果受系统调度的影响最少)。可以看出,两者的效率差距是比较大的,究竟为什么,我们看看字节码:

a.f() # Bound Method
LOAD_NAME                1 (a)
LOAD_ATTR                2 (f)
CALL_FUNCTION            0

A.f(a) # Unbound Method
LOAD_NAME                0 (A)
LOAD_ATTR                2 (f)
LOAD_NAME                1 (a)
CALL_FUNCTION            1
        

可以看出,在关键的一千万次循环里,Bound Method和Unbound Method的字节码是有差异的。粗略地看,Unbound Method比Bound Method多了一条字节码“LOAD_NAME”,是不是多了这一千万次“LOAD_NAME”的循环而造成7秒多跟2秒多的差距呢?现在还言之过早,可能不单单是这个原因,让我们先看看这里面比较陌生的字节码——“LOAD_ATTR”的实现:

// LOAD_ATTR
w = GETITEM(names, oparg);
v = TOP();
x = PyObject_GetAttr(v, w);
Py_DECREF(v);
SET_TOP(x);
        

可以告诉大家,最后被LOAD_ATTR放到栈顶的“x”是一个PyMethodObject对象,它实际上是PyFunctionObject和instance对象绑定在一起的结果。对于“a.f()”,“f”实际上就是那个PyFunctionObject,而“a”就是instance对象,也就是我们经常说的“self”。而对于“A.f(a)”,虽然也是得到一个PyMethodObject对象,但它里面绑定的instance对象却是为空的,需要我们显式地传进一个instance对象“a”。

知道这个区别,那么就好办了,因为下一个字节码是CALL_FUNCTION(属性为函数的引用,当然是CALL_FUNCTION啦)。还记得CALL_FUNCTION里经常折腾的call_function吗:

static PyObject* call_function(Pyobject ***pp_stack, int oparg)
{
    /* 关于参数、函数对象指针的一些处理 */
    ...
    PyObject *x;

    if (PyCFunction_check(func) && nk == 0) {
        ...
    } else {
        // [1]检查是否PyMethodObject对象和是否有self参数(也就是Bound Method)
        if (PyMethod_check(func) && PyMethod_GET_SELF(func) != NULL) {
            PyObject *self = PyMethod_GET_SELF(func);
            func = PyMethod_GET_FUNCTION(func);
            // [2]self参数入栈,调整参数信息变量
            *pfunc = self;
            na++;
            n++;
        }
        // [3]快速函数入口
        if (PyFunction_Check(func))
            x = fast_function(func, pp_stack, n, na, nk);
        else
            // [4]效率较低的函数入口
            x = do_call(func, pp_stack, na, nk);
    }
    ...
    return x;
}
        

根据绑定的结果,“a.f()”在[1]处的判断是为真,于是进入里面进行相应的处理。这里要做的是将PyMethodObject拆分,得到一个PyFunctionObject和一个“self”(instance对象),分别赋给func和pfunc。到了下面[3]的判断,PyFunction_check(func)为真,于是进入快速函数入口“fast_function”,执行效率较高的处理。而对于“A.f(a)”,虽然它得到的也是一个PyMethodObject,但其指向self对象的域是为空的,也就是说[1]的判断中“PyMethod_GET_SELF(func) != NULL”为假,直接跑到[3]。而由于没有经过拆分,func还是指向PyMethodObject这样一个对象,所以[3]的PyFunction_Check(func)判断为假,只好进入[4]的do_call调用了,这是一个比fast_function效率要低很多的调用。

到了这里,我们大概明白“Unbound Method”和“Bound Method”为何有这样的效率差距了。不过,我们还不满足,因为就算是Bound Method,在一千万次的循环里,我们就做了一千万次的PyMethodObject绑定,这会不会太傻了点呢?答案是肯定的,于是,我们改变了一下绑定的位置,得到下面这个程序:

# [test3.py]
import timeit

test_str = """
    a = A()
    func = a.f # bound here, outside the loop
    for i in range(10000000):
        func()
    """

t = timeit.Timer(test_str, "from A import A")
print min(t.repeat(3, 1))
        

绑定放到循环外面,循环里面只剩下“func()”,它对应的字节码也就短短两个:

LOAD_NAME                3 (func)
CALL_FUNCTION            0
        

运行一下,得到的结果是1.71142983437,又比循环里进行绑定快了一点,这次心满意足地结束了。