PSJay Blog

#FIXME, seriously

Tornado 模板中变量与参数的命名冲突

| Comments

重现

这个问题是一个多月前遇到的,当时正在为知乎的新版话题编写模板。

我在 Handler 中编写了类似于这样的代码:

1
2
3
def get(self):
    # prepare data
    self.render("new_topic.html", topic = topic, topics = topics, ...)

然后,在模板中写了这样的代码:

1
2
3
4
5
{{topic.name}} <!-- exception raised: UnboundLocalError: local variable 'topic' referenced before assignment -->
<!-- a lot of codes omited -->
{% for topic in topics %}
    <!-- omited -->
{% end %}

正如代码中的注释那样,在注释所在的那一行,会抛出一个 UnboundLocalError 错误,说是 topic 在「声明」之前就被引用了。

当时被这个问题折腾了半个下午,由于模板文件太大,压根就没有注意到是命名引发的冲突,还以为是遇到了什么诡异的 bug。后来大家一起找出了问题所在,一直觉得可以借这个问题看看 Tornado 的模板实现方法,今天刚好有闲情刨根问底。

命名空间(Namespace)

和其他编程语言一样,命名空间的主要作用是用来避免命名冲突。因此,不同命名空间里,可以存在相同的命名。

Python 里存在这样几类命名空间:

  • 模块(module)的全局(global)命名空间;
  • 函数的本地(local)命名空间,它在函数被调用的时候创建,返回或者抛出异常之后被删除。
  • built-in 命名空间,它在 Python 解释器开始运行的时候被创建,并且不能被删除(本质上这是一个叫做 __builtin__ 的模块);
  • 顶级语句(直接从脚本文件或者交互控制台读入的语句)所在的命名空间(本质上这是一个叫做 __main__ 的模块)。

Python 会按照这样的顺序来寻找变量:

  1. 本地命名空间;
  2. 能找到相应变量的最近的外部函数,这里面的变量既不是本地变量,也不是全局变量;
  3. 当前模块的全局命名空间;
  4. built-in 命名空间。

官方的这篇教程中提到:

If a name is declared global, then all references and assignments go directly to the middle scope containing the module’s global names. Otherwise, all variables found outside of the innermost scope are read-only (an attempt to write to such a variable will simply create a new local variable in the innermost scope, leaving the identically named outer variable unchanged).

因此可以知道,在最内层作用域(通常是某个函数内部)访问外部的变量时,这个变量是只读的,如果你尝试去写这个变量,那么 Python 解释器会为你创建一个同名的本地变量,这样做是为了保护那个外部变量的值不会被某个函数轻易改变。(如果你确实需要在函数内部改变某个全局变量,你可以使用 global 关键字。)

因此,毫无疑问地,这段代码会出错:

1
2
3
4
5
6
7
a = 1

def func():
    print a
    a = 2

func()

一个 UnboundLocalError 将会被抛出,因为 func 内部试图对 a 进行写操作,从而 Python 解释器创建了一个名为 a 的本地变量。根据上文提到的变量寻找规则,在 func 内部试图打印 a 时,找到的是本地变量 a,而在这个时候,它还没有和任何对象绑定,因此抛出了这个错误。

Tornado 模板是怎样做的

为了找出问题所在,我阅读了 Tornado 源代码的相关部分。

Tornado 会把模板文件编译成原生的 Python 脚本,然后在渲染视图的时候执行它。关键代码是 tornado.template.Template 类中 generate(self, **kwargs) 方法的这几行:

1
2
3
4
5
namespace.update(kwargs)
exec self.compiled in namespace
execute = namespace["_execute"]
# ...
return execute()

这段代码很容易理解:Tornado 先将 handler 中传递过来的参数保存在一个名为 namespace 的字典中,将其作为命名空间使用,然后把编译好的模板文件(内容就是一个名为 _execute 的函数)在这个命名空间中运行并返回。

根据 Python 官方文档对 exec 语句的描述可以知道:这个名为 namespace 的字典,就是模块运行时刻的「全局命名空间」。因此,我们很容易用 Python 代码来模拟最初的那段错误代码:

1
2
3
4
5
6
7
8
9
10
code = """
def _execute():
    print topic
    topic = 'bar'
"""

namespace = {"topic": "foo"}
exec code in namespace

namespace['_execute']()

Python 解释器为 _execute() 函数创建了 topic 本地变量,而读 topic 的时候,它还没有和任何对象绑定,这就是出错的原因。当然,如果 Python 有块级作用域的话,这个问题就不会存在。

Comments