image.png
作为 Python 面试官,每次面试中我几乎都会和候选人聊起 作用域 以及 名字空间 等基本概念。但就算这么基础的内容,也有不少人没有完全掌握,也因此与工作机会失之交臂。

  1. PI = 3.14
  2. def circle_area(r):
  3. return PI * r ** 2
  4. class Dog(object):
  5. def __init__(self, name):
  6. self.name = name
  7. def yelp(self):
  8. print('woof, i am', self.name)
  • 以这个程序为例,代码中出现的每个变量的作用域分别是什么?
  • 程序中总共涉及几个名字空间?
  • Python 以怎样的顺序查找一个变量呢?

为了解答这些问题,需要对 Python 变量的作用域以及名字空间有准确的认识。

名字绑定

赋值

在 Python 中,变量只是一个与实际对象绑定起来的名字,变量定义本质上就是建立名字与对象的约束关系。因此,赋值语句本质上就是建立这样的约束关系,将右边的对象与左边的名字绑定在一起:

  1. a = 1

我经常在面试中问:除了赋值语句,还有哪些语句可以完成名字绑定?能准确回答的候选人寥寥无几。实际上,除了赋值语句外, Python 中还有好几类语句均与名字绑定相关,我们接着一一介绍。

模块导入

我们导入模块时,也会在当前上下文创建一个名字,并与被导入对象绑定:

  1. import xxx
  2. from xxx import yyy

函数、类定义

我们定义函数 / 类时,本质上是创建了一个函数 / 类对象,然后将其与函数 / 类名绑定:

  1. def circle_area(r):
  2. return PI * r ** 2
  3. class Dog(object):
  4. pass

as 关键字

除此此外, as 关键字也可以在当前上下文建立名字约束关系:

  1. import xxx as yyy
  2. from xxx import yyy as zzz
  3. with open('/some/file') as f:
  4. pass
  5. try:
  6. # do something
  7. except SomeError as e:
  8. # handle error

以上这几类语句均可在当前上下文建立名字约束,有着与赋值语句类似的行为,因此可以看作是 广义的赋值语句 。

作用域

现在问题来了,一个名字引入后,它的可见范围有多大呢?

我们以一个面试真题开始讨论:以下例子中 3 个 print 语句分别输出什么?

  1. a = 1
  2. def f1():
  3. print(a)
  4. def f2():
  5. a = 2
  6. print(a)
  7. print(a)

例子中,第 1 行引入的名字 a 对整个模块都可见,第 4 行和第 10 行均可访问到它,因此这两个地方输出 1 ;而第 7 行引入的名字 a 却只有函数 f2 内部可以访问到,第 8 行优先访问内部定义的 a ,因此这里将输出 2 。

由此可见,在不同的代码区域引入的名字,其影响范围是不一样的。第 1 行定义的 a 可以影响到 f1 ,而 f2 中定义的 a 却不能。再者,一个名字可能在多个代码区域中定义,但最终只能使用其中一个。

一个名字能够施加影响的程序正文区域,便是该名字的 作用域 在 Python 中,一个名字在程序中某个区域能否起作用,是由名字引入的位置决定的,而不是运行时动态决定的。因此, Python 具有 静态作用域 ,也称为 词法作用域 。那么,程序的作用域是如何划分的呢?

Python 在编译时,根据语法规则将代码划分为不同的 代码块 ,每个代码块形成一个 作用域

首先,整个 .py 文件构成最顶层的作用域,这就是 全局作用域 ,也称为 模块作用域 ;其次,当代码遇到 函数定义 ,函数体成为当前作用域的 子作用域 ;再次,当代码遇到 类定义 ,类定义体成为当前作用域的子作用域。

一个名字在某个作用域引入后,它的影响范围就被限制在该作用域内。其中,全局作用域对所有直接或间接内嵌于其中的子作用域可见;函数作用域对其直接子作用域可见,并且可以传递。

按照这个划分方式,真题中的代码总共有 3 个作用域: A 为最外层作用域,即全局作用域; f1 函数体形成作用域 B ,是 A 的子作用域; f2 函数体又形成作用域 C ,也是 A 的子作用域。
image.png
作用域 A 定义的变量 a 对于对 A 及其子作用域 B 、 C 可见,因此 f1 也可以访问到。理论上, f2 也可以访问到 A 中的 a ,只不过其作用域 C 也定义了一个 a ,优先访问本作用域内的。 C 作用域内定义的任何名字,对 A 和 B 均不可见。

A B C 三个作用域嵌套关系如左下所示,访问关系如右下所示:
image.png
箭头表示访问关系,例如作用域 B 中的语句可以访问到作用域 A 中的名字,反过来则不行。

闭包作用域

这个例子借助闭包实现提示信息定制功能:

  1. pi = 3.14
  2. def circle_area_printer(hint):
  3. def print_circle_area(r):
  4. print(hint, pi * r ** 2)
  5. return print_circle_area
  6. circle_area_en = circle_area_printer('Circle Area:')
  7. circle_area_zh = circle_area_printer('圆面积:')
  8. circle_area_en(2)
  9. circle_area_zh(3)

根据前面介绍的规则,我们对代码进行作用域划分,结果如下:
image.png
A B C 三个作用域嵌套关系如左下所示,访问关系如右下所示:
image.png
毫无疑问, B C 均在全局作用域 A 内,因此都可以访问到 A 中的名字。由于 B 是函数作用域,对其子作用域 C 可见。因此, hint 属于 B 作用域,而位于 C 作用域的语句可以访问它,也就不奇怪了。

类作用域

我们接着以一个简单的类为例,考察类作用域:

  1. slogan = 'life is short, use python.'
  2. class Dog(object):
  3. group = ''
  4. def __init__(self, name):
  5. self.name = name
  6. def yelp(self):
  7. print('woof,', slogan)
  8. def yelp_name(self):
  9. print('woof, i am', self.name)
  10. def yelp_group(self):
  11. print('woof, my group is', group)

根据前面介绍的规则,我们对代码进行作用域划分,结果如下:
image.png
其中, B 对应着类定义体,暂且叫做类作用域。各个作用域嵌套关系以及访问关系分别如下:
image.png
同样,全局作用域 A 对其他所有内嵌于其中的作用域可见。因此,函数 yelp 作用域 D 可以访问到全局作用域 A 中的名字 slogan 。但是由于 B 不是函数作用域,对其子作用域不可见。因此, yelp_group 函数作用域 F 访问不到类作用域 B 中的名字 group ,而 group 在全局作用域 A 中未定义,第 17 行便抛异常了。

复杂嵌套

函数 - 类

在 Python 中,类可以动态创建,甚至在函数中返回。在函数中创建并返回类,可以按函数参数对类进行动态定制,有时很有用。那么,这种场景中的作用域又该如何划分呢?我们一起来看一个简单的例子:

  1. slogan = 'life is short, use python.'
  2. def make_dog(group_name):
  3. class Dog(object):
  4. group = group_name
  5. def __init__(self, name):
  6. self.name = name
  7. def yelp(self):
  8. print('woof,', slogan)
  9. def yelp_name(self):
  10. print('woof, i am', self.name)
  11. def yelp_group(self):
  12. print('woof, my group is', self.group)
  13. return Dog
  14. if __name__ == '__main__':
  15. Dog = make_dog('silly-dogs')
  16. tom = Dog(name='tom')
  17. tom.yelp_group()

这个例子借助函数实现类属性 group 动态定制,以不同的 group_name 调用函数即可获得不同的 Dog 类。根据前面介绍的规则,我们对代码进行作用域划分,结果如下:
image.png
各个作用域嵌套关系以及访问关系分别如下: