优化Python代码使其加快作用域内的查找

我将示范微优化(micro optimization)如何提升python代码5%的执行速度。5%!同时也会触怒任何维护你代码的人。

但实际上,这篇文章只是解释一下你偶尔会在标准库或者其他人的代码中碰到的代码。我们先看一个标准库的例子,collections.OrderedDict类:

def __setitem__(self, key, value, dict_setitem=dict.__setitem__):
 if key not in self:
  root = self.__root
  last = root[0]
  last[1] = root[0] = self.__map[key] = [last, root, key]
 return dict_setitem(self, key, value)

注意最后一个参数:dict_setitem=dict.__setitem__。如果你仔细想就会感觉有道理。将值关联到键上,你只需要给__setitem__传递三个参数:要设置的键,与键关联的值,传递给内建dict类的__setitem__类方法。等会,好吧,也许最后一个参数没什么意义。
作用域查询

为了理解到底发生了什么,我们看下作用域。从一个简单问题开始:在一个python函数中,如果遇到了一个名为open的东西,python如何找出open的值?

# <GLOBAL: bunch of code here>

def myfunc():
 # <LOCAL: bunch of code here>
 with open('foo.txt', 'w') as f:
  pass

简单作答:如果不知道GLOBAL和LOCAL的内容,你不可能确定open的值。概念上,python查找名称时会检查3个命名空间(简单起见忽略嵌套作用域):

局部命名空间
    全局命名空间
    内建命名空间

所以在myfunc函数中,如果尝试查找open的值时,我们首先会检查本地命名空间,然后是全局命名空间,接着内建命名空间。如果在这3个命名空间中都找不到open的定义,就会引发NameError异常。
作用域查找的实现

上面的查找过程只是概念上的。这个查找过程的实现给予了我们探索实现的空间。

def foo():
 a = 1
 return a

def bar():
 return a

def baz(a=1):
 return a

我们看下每个函数的字节码:

>>> import dis
>>> dis.dis(foo)
 2   0 LOAD_CONST    1 (1)
    3 STORE_FAST    0 (a)

 3   6 LOAD_FAST    0 (a)
    9 RETURN_VALUE

>>> dis.dis(bar)
 2   0 LOAD_GLOBAL    0 (a)
    3 RETURN_VALUE

>>> dis.dis(baz)
 2   0 LOAD_FAST    0 (a)
    3 RETURN_VALUE

注意foo和bar的区别。我们立即就可以看到,在字节码层面,python已经判断了什么是局部变量、什么不是,因为foo使用LOAD_FAST,而bar使用LOAD_GLOBAL。

我们不会具体阐述python的编译器如何知道何时生成何种字节码(也许那是另一篇文章的范畴了),但足以理解,python在执行函数时已经知道进行何种类型的查找。

另一个容易混淆的是,LOAD_GLOBAL既可以用于全局,也可以用于内建命名空间的查找。忽略嵌套作用域的问题,你可以认为这是“非局部的”。对应的C代码大概是[1]:

case LOAD_GLOBAL:
 v = PyObject_GetItem(f->f_globals, name);
 if (v == NULL) {
  v = PyObject_GetItem(f->f_builtins, name);
  if (v == NULL) {
   if (PyErr_ExceptionMatches(PyExc_KeyError))
    format_exc_check_arg(
       PyExc_NameError,
       NAME_ERROR_MSG, name);
   goto error;
  }
 }
 PUSH(v);

即使你从来没有看过CPython的C代码,上面的代码已经相当直白了。首先,检查我们查找的键名是否在f->f_globals(全局字典)中,然后检查名称是否在f->f_builtins(内建字典)中,最后,如果上面两个位置都没找到,就会抛出NameError异常。
将常量绑定到局部作用域

现在我们再看最开始的代码例子,就会理解最后一个参数其实是将一个函数绑定到局部作用域中的一个函数上。具体是通过将dict.__setitem__赋值为参数的默认值。这里还有另一个例子:

def not_list_or_dict(value):
 return not (isinstance(value, dict) or isinstance(value, list))

def not_list_or_dict(value, _isinstance=isinstance, _dict=dict, _list=list):
 return not (_isinstance(value, _dict) or _isinstance(value, _list))

这里我们做同样的事情,把本来将会在内建命名空间中的对象绑定到局部作用域中去。因此,python将会使用LOCAL_FAST而不是LOAD_GLOBAL(全局查找)。那么这到底有多快呢?我们做个简单的测试:

$ python -m timeit -s 'def not_list_or_dict(value): return not (isinstance(value, dict) or isinstance(value, list))' 'not_list_or_dict(50)'
1000000 loops, best of 3: 0.48 usec per loop
$ python -m timeit -s 'def not_list_or_dict(value, _isinstance=isinstance, _dict=dict, _list=list): return not (_isinstance(value, _dict) or _isinstance(value, _list))' 'not_list_or_dict(50)'
1000000 loops, best of 3: 0.423 usec per loop

换句话说,大概有11.9%的提升 [2]。比我在文章开始处承诺的5%还多!
还有更多内涵

可以合理地认为,速度提升在于LOAD_FAST读取局部作用域,而LOAD_GLOBAL在检查内建作用域之前会先首先检查全局作用域。上面那个示例函数中,isinstance、dict、list都位于内建命名空间。

但是,还有更多。我们不仅可以使用LOAD_FAST跳过多余的查找,它也是一种不同类型的查找。

上面C代码片段给出了LOAD_GLOBAL的代码,下面是LOAD_FAST的:

case LOAD_FAST:
 PyObject *value = fastlocal[oparg];
 if (value == NULL) {
  format_exc_check_arg(PyExc_UnboundLocalError,
        UNBOUNDLOCAL_ERROR_MSG,
        PyTuple_GetItem(co->co_varnames, oparg));
  goto error;
 }
 Py_INCREF(value);
 PUSH(value);
 FAST_DISPATCH()

我们通过索引一个数组获取局部值。虽然没有直接出现,但是oparg只是那个数组的一个索引。

现在听起来才合理。我们第一个版本的not_list_or_dict要进行4个查询,每个名称都位于内建命名空间,它们只有在查找全局命名空间之后才会查询。这就是8个字典键的查询操作了。相比之下,not_list_or_dict的第二版中,直接索引C数组4次,底层全部使用LOAD_FAST。这就是为什么局部查询更快的原因。
总结

现在当下次你在其他人代码中看到这种例子,就会明白了。

最后,除非确实需要,请不要在具体应用中进行这类优化。而且大部分时间你都没必要做。但是如果时候到了,你需要挤出最后一点性能,就需要搞懂这点。
脚注

[1]注意,为了更易读,上面的代码中我去掉了一些性能优化。真正的代码稍微有点复杂。

[2]示例函数事实上没有做什么有价值的东西,也没进行IO操作,大部分是受python VM循环的限制。

(0)

相关推荐

  • Python作用域用法实例详解

    本文实例分析了Python作用域用法.分享给大家供大家参考,具体如下: 每一个编程语言都有变量的作用域的概念,Python也不例外,以下是Python作用域的代码演示: def scope_test(): def do_local(): spam = "local spam" def do_nonlocal(): nonlocal spam spam = "nonlocal spam" def do_global(): global spam spam = &quo

  • Python新手在作用域方面经常容易碰到的问题

    通常,当我们定义了一个全局变量(好吧,我这样说是因为讲解的需要--全局变量是不好的),我们用一个函数访问它们是能被Python理解的: bar = 42 def foo(): print bar 在这里,我们在foo函数里使用了全局变量bar,然后它也如预想的能够正常运行: >>> foo() 42 这样做很酷.通常,我们在使用了这个特性之后就想在所有的代码里用上它.如果像以下的例子中使用的话还是能够正常运行的: bar = [42] def foo(): bar.append(0) f

  • Python中的作用域规则详解

    Python是静态作用域语言,尽管它自身是一个动态语言.也就是说,在Python中变量的作用域是由它在源代码中的位置决定的,这与C有些相似,但是Python与C在作用域方面的差异还是非常明显的. 接下来会谈论Python的作用域规则,在这中间也会说明一下Python与C在作用域方面的不同. 在Python 2.0及之前的版本中,Python只支持3种作用域,即局部作用域,全局作用域,内置作用域:在Python 2.2中,Python正式引入了一种新的作用域 --- 嵌套作用域:在Python 2

  • 深入解析Python中函数的参数与作用域

    传递参数 函数传递参数时的一些简要的关键点: 参数的传递是通过自动将对象赋值给本地变量名来实现的.所有的参数实际上都是通过指针进行传递的,作为参数被传递的对象从来不自动拷贝. 在函数内部的参数名的赋值不会影响调用者. 改变函数的可变对象参数的值会对调用者有影响. 实际上,Python的参数传递模型和C语言的相当相似: 不可变参数"通过值"进行传递.像整数和字符串这样的对象是通过对象引用而不是拷贝进行的,但是因为不论怎么样都不可能在原处改变不可变对象,实际的效果就很像创建了一份拷贝. 可

  • 解析Python中的变量、引用、拷贝和作用域的问题

    在Python中,变量是没有类型的,这和以往看到的大部分编辑语言都不一样.在使用变量的时候,不需要提前声明,只需要给这个变量赋值即可.但是,当用变量的时候,必须要给这个变量赋值:如果只写一个变量,而没有赋值,那么Python认为这个变量没有定义.如下: >>> a Traceback (most recent call last): File "<stdin>", line 1, in <module> NameError: name 'a'

  • 讲解python参数和作用域的使用

    本文会介绍如何将语句组织成函数,还会详细介绍参数和作用域的概念,以及递归的概念及其在程序中的用途.一. 创建函数函数是可以调用,它执行某种行为并且返回一个值.用def语句即可定义一个函数:(并非所有的函数都会返回一些东西) 复制代码 代码如下: def fibs(num):    result = [0,1]    for i in range(num-2):        result.append(result[-2]+result[-1])    return result 记录函数如果想

  • 通过5个知识点轻松搞定Python的作用域

    1.块级作用域 想想此时运行下面的程序会有输出吗?执行会成功吗? #块级作用域 if 1 == 1: name = "lzl" print(name) for i in range(10): age = i print(age) 我们先看下执行结果 C:/Users/L/PycharmProjects/s14/preview/Day8/作用域/main.py lzl 9 Process finished with exit code 0 代码执行成功,没有问题:在Java/C#中,执行

  • 解读Python编程中的命名空间与作用域

    变量是拥有匹配对象的名字(标识符).命名空间是一个包含了变量名称们(键)和它们各自相应的对象们(值)的字典. 一个Python表达式可以访问局部命名空间和全局命名空间里的变量.如果一个局部变量和一个全局变量重名,则局部变量会覆盖全局变量. 每个函数都有自己的命名空间.类的方法的作用域规则和通常函数的一样. Python会智能地猜测一个变量是局部的还是全局的,它假设任何在函数内赋值的变量都是局部的. 因此,如果要给全局变量在一个函数里赋值,必须使用global语句. global VarName的

  • 从局部变量和全局变量开始全面解析Python中变量的作用域

    理解全局变量和局部变量 1.定义的函数内部的变量名如果是第一次出现, 且在=符号前,那么就可以认为是被定义为局部变量.在这种情况下,不论全局变量中是否用到该变量名,函数中使用的都是局部变量.例如: num = 100 def func(): num = 123 print num func() 输出结果是123.说明函数中定义的变量名num是一个局部变量,覆盖全局变量.再例如: num = 100 def func(): num += 100 print num func() 输出结果是:Unb

  • 讲解Python中for循环下的索引变量的作用域

    我们从一个测试开始.下面这个函数的功能是什么? def foo(lst): a = 0 for i in lst: a += i b = 1 for t in lst: b *= i return a, b 如果你觉得它的功能是"计算lst中所有元素的和与积",不要沮丧.通常很难发现这里的错误.如果在大堆真实的代码中发现了这个错误就非常厉害了.--当你不知道这是一个测试时,很难发现这个错误. 这里的错误是在第二个循环体中使用了i而不是t.等下,这到底是怎么工作的?i在第一个循环外应该是

随机推荐