站内链接:

词法作用域

作用域分为两类: 词法作用域(静态), 动态作用域(lisp, bash), 大部分的开发语言都是静态作用域, 即作用域在代码编写定义或者预编译阶段就已经确定了,下面举例来说明两种作用域的执行区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
@show_function(desc='作用域-静态作用域')
def scope_static_verify():
name = 'global'

def fun1():
print(f'被调用函数fun1, 此时name值:{name}')

def fun2():
name = 'func2'
fun1()

fun2()
scope_static_verify() # 输出为global, 即fun1在哪里被调用不影响name的查找逻辑.

bash 的动态查找例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name="global"

function fun1() {
echo ${name}
}

function fun2() {
local name="func2"
fun1
}

fun1 # 输出为global
echo "xxxxxxxxxxxxxxx"
fun2 # 输出为:func2

代码对象

变量

Code Object封装了 python 虚拟机的字节码, 即 python 虚拟机上的汇编语言, 即每一个代码对象代表已编译的可执行 python 代码.

Python 程序由代码块构成的。 块是作为单元执行的一段 Python 程序文本。 这些都是代码块:模块、函数体和类定义。 交互模式下输入的每个命令都是一个块。 脚本文件(作为标准输入给解释器或指定为解释器的命令行参数的文件)是代码块。 脚本命令(在解释器命令行上使用 -c 选项指定的命令)是代码块。 传递给内置函数 eval()exec() 的字符串参数是一个代码块。

关于 code 对象中的属性, 可以见官方文档:code, 下面就简单的介绍下这些属性的含义:

  • co_argcount: 代码块参数数量, 即函数的形参数量
  • co_code: 字节码指令序列, 其可以通过 dis 反编译为可读模式: dis.dis(obj.co_code)
  • co_const: 代码块中使用到的任何常量列表, 比如用到语句 if a == 'kuang', 则常量列表中肯定会存在kuang
  • co_filename: 代码块所在的文件名, 例如src/syntax/code_test.py
  • co_firstlineno: 代码对象源代码在文件中所在的第一行的行号
  • co_flag: 代码对象的种类数目, 协程对象, 字符串, 函数等等
  • co_lnotab: 一串用于计算某个字节码偏移量处的指令所对应的源代码行号的字节
  • co_stacksize:代码块执行期间任何时候求值堆栈上存在的最大项目数

下面的属性需要重点关注, 它们和作用域, 命名空间, 闭包有着极大关系:

  • co_name: 代码块名, 例如函数名
  • co_varnames: 代码块中局部定义的名称的数量
  • co_names: 代码对象内使用的非局部名称的集合
  • co_nlocals: 代码对象使用的局部名称的数量, 该值和len(co_varnames)是一一对应的
  • co_freevars: 代码块内定义的自由变量(在一个块内使用但未在该块内定义的变量, 但不适用于全局变量)的集合
  • co_cellvars: 对象执行期间创建, 并且其中的值不会立刻释放, f1.co_cellvars的值必然出现在 f1 的内嵌函数f2.freevars中, 这就是闭包和自由变量的关系.

下面我们举例来说明上面代码对象和闭包相关的含义, 对于如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import os
import sys

COMMON_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + os.sep + 'common'
sys.path.insert(0, COMMON_PATH)
from util import show_function # pylint: disable=import-error


global_name = 'global'


@show_function(desc='代码对象-闭包')
def fun1():
name = 'fun1'

def fun2():
age = 1

def fun3():
nonlocal age
age += 1
print(f'名字: {name}, 全局: {global_name}, 年龄:{age}')

print('打印最里层函数的作用域信息:')
for attr in dir(fun3.__code__):
if attr.startswith('co_'):
print(f"{attr}:\t{getattr(fun3.__code__, attr)}")
return fun3

print('打印第二层函数作用域信息:')
for attr in dir(fun2.__code__):
if attr.startswith('co_'):
print(f"{attr}:\t{getattr(fun2.__code__, attr)}")
print('\n\n')
return fun2


fun1()

其输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
打印第二层函数作用域信息:
co_argcount: 0
co_cellvars: ('age',) # 另见fun1.__closure__
co_code: b'**字节码**'
co_consts: (None, 1, <code object fun3 at 0x10b2009d0, file "src/syntax/code_test.py", line 20>, 'fun1.<locals>.fun2.<locals>.fun3', '打印最里层函数的作用域信息:', 'co_', ':\t')
co_filename: src/syntax/code_test.py
co_firstlineno: 17
co_flags: 19
co_freevars: ('name',) # 在一个块内使用但未在块内定义的变量
co_kwonlyargcount: 0
co_lnotab: b'\x00\x01\x04\x02\x0e\x05\x08\x01\x0e\x01\n\x01\x1c\x01'
co_name: fun2
co_names: ('print', 'dir', '__code__', 'startswith', 'getattr')
co_nlocals: 2
co_posonlyargcount: 0
co_stacksize: 7
co_varnames: ('fun3', 'attr')

打印最里层函数的作用域信息:
co_argcount: 0
co_cellvars: ()
co_code: b'**字节码**'
co_consts: (None, 1, '名字: ', ', 全局: ', ', 年龄:')
co_filename: src/syntax/code_test.py
co_firstlineno: 20
co_flags: 19
co_freevars: ('age', 'name')
co_kwonlyargcount: 0
co_lnotab: b'\x00\x02\x08\x01'
co_name: fun3
co_names: ('print', 'global_name')
co_nlocals: 0
co_posonlyargcount: 0
co_stacksize: 7
co_varnames: ()

最里层 fun3 的co_cellvars为空, 表示其自身的局部变量没有被其嵌套函数所使用的(实际根本没有嵌套函数), 所以不需要分配额外的空间以延长自由变量的生命周期. 此时自由变量co_freevars的值为(age, name), 但是局部变量co_varnames为空, 故而可以暂时知道该这两个字段一定是引用了外部作用域的定义, 从而产生了自由变量, 往上层寻找 fun2 的代码对象值信息, 果然在fun2.co_freevarsfunc2.co_cellvars中发现了nameage的值. 同理, fun2 的co_freevars存在键(name), 但是其co_varnames不存在该键, 则说明这里又产生了一个闭包, 查找逻辑同上.

最后, 这些分析都是基于作用域的基础上进行的, 此时实际上函数对象都还没有实例化或者被调用, 这从另一方面又佐证了 python 作用域是词法作用域.

类和实例

2.1 节已经提过, code object是虚拟机的字节码或者代码块, 即该数据和 runtime 没有关系, 同一个类的多个实例 a 和 b, 他们获取的 code object 应该是相同的, 那么应该怎么获取一个实例的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1. source_code_module.py
class ObjectB(object):
_NAME = 'B'

def __init__(self, name, age):
self.name = name
self.age = age

def show(self):
print(f'name:{self.name}, age:{self.age}')

@classmethod
def showclass(cls):
print('classmethod')

@staticmethod
def staticclass(cls):
print('staticmethod')


ob = ObjectB('bifeng', 13)
ob2 = ObjectB('xiaoyuan', 19)

此时通过inspect模块去获取实例 b 和 b2 的源码信息会报错:

1
XXX is not a module, class, method, function, traceback, frame, or code object

其中, 获取源码的相关代码如下:

1
2
3
4
5
from source_code_module import ob, ob2, ObjectB
import inspect

print(inspect.getsource(ObjectB))
print(inspect.getsource(ob))

dis 输出

反编译产生的输出, 需要记录几个常用的动作即可, 实际上开发过程中不会用到这些信息, 这里介绍主要是为了能够简单的看懂一些反编译产生的输出, 有一个大概的印象即可:

  • STORE_NAME: 保存非局部名称变量的索引: co_names
  • STORE_FAST: 保存局部变量属性的索引: co_varnames
  • STORE_GLOBAL: 保存全局变量属性的索引
  • STORE_ATTR: 保存某一个属性键的索引号: TOS.name = TOS1
  • LOAD_FAST: 从运行栈中取出变量
  • LOAD_GLOBAL: 从全局运行栈中取出变量
  • LOAD_CONST: 从 const 栈中取出常量信息

其他更加详细的字节码指令见dis 输出说明, 现在让我们看一个简单的打印函数的字节码指令输出:

1
2
3
4
5
6
7
8
# 文件名: hello.py
import dis
age = 18
def hello():
name = 'bifeng'
print(f'Hi, {name}, {age}...')

print(dis.dis(hello))

dis 的输出格式说明如下:

  • 第一列: 4, 表示源代码行号, 分别为行号: 4, 5
  • 第二列: 表示当前执行的指令(可选)
  • 第三列: 表示从之前的指令到此可能的 JUMP(非必须)
  • 第四列: 数字是字节码中对应于字节索引的地址(值为 2 的倍数, python3.6 每个指令使用 2 个字节)
  • 第五列: 指令本身对应的人类可读的名字
  • 第六列: Python 内部用于获取某些常量或变量,管理堆栈,跳转到特定指令等的指令的参数
  • 第七列: 计算后的实际参数

命令python hello.py的完整输出如下(基于栈的操作):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
5           0 LOAD_CONST               1 ('bifeng')       # 常量入栈(位置0一般存储None, 表示返回值)
2 STORE_FAST 0 (name) # 保存值或结果赋值给STORE_FAST(0号)(co_varnames)

6 4 LOAD_GLOBAL 0 (print) # 全局变量入全局栈
6 LOAD_CONST 2 ('Hi, ') # 常量入栈-2
8 LOAD_FAST 0 (name) # 加载0号局部变量值
10 FORMAT_VALUE 0
12 LOAD_CONST 3 (', ') # 常量入栈-3
14 LOAD_GLOBAL 1 (age) # 全局变量入栈-1
16 FORMAT_VALUE 0
18 LOAD_CONST 4 ('...') # 常量入栈-4
20 BUILD_STRING 5 # 拼接指定个数来自栈的字符串并将结果推入栈顶
22 CALL_FUNCTION 1 # 调用函数
24 POP_TOP # 栈顶弹出
26 LOAD_CONST 0 (None)
28 RETURN_VALUE

命令python -m dis hello.py的完整输出如下(删除最后两行 dis 代码):

1
2
3
4
5
6
7
8
9
1           0 LOAD_CONST               0 (18)
2 STORE_NAME 0 (age) # 保存值到0号全局变量(co_names)

4 4 LOAD_CONST 1 (<code object hello at 0x7fb2580b5390, file "hello.py", line 4>)
6 LOAD_CONST 2 ('hello') # 函数名
8 MAKE_FUNCTION 0
10 STORE_NAME 1 (hello) # 存储co_names
12 LOAD_CONST 3 (None)
14 RETURN_VALUE

inspect 输出

inspect 函数: 对实时对象及其源代码提供自省功能, 从而获取对象的模块, 类, 方法, 函数, 回溯, 帧对象, 代码对象等信息.

  1. getmembers 函数, 扫描对象, 获取对象中的信息, 通过和可选的谓词函数配合, 可以提取需要的信息, 另外, 不同类型对象扫描的结果不同, 其中包含的信息也是不一样的. 该函数可以审查模块, 审查类, 审查实例.

待审查测试模块: inspecttest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
""" inspect示例 """


def module_level_function(arg1, arg2='default', *args, **kwargs):
"""该函数在模块中声明"""
local_variable = arg1 * 2
return local_variable


class Parent(object):
""" 父类 """

def __init__(self, name):
self.name = name

def get_name(self):
""" 返回名称 """
return self.name


class Children(Parent):
""" 子类: Children """

def do_something(self):
"""Does some work"""
print('Hi, i am children')

def get_name(self):
return 'Children(' + self.name + ')'


instance_of_a = Parent('sample_instance')

如果需要获取该模块中的相关对象信息, 可以编写如下的测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. 获取全部的所有信息, 不做任何过滤
import inspect
import inspecttest

for name, obj in inspect.getmembers(inspecttest):
print(name, obj)

# 2. 过滤获取类, 函数信息
for name, obj in inspect.getmembers(inspecttest):
if not inspect.isclass(obj) and not inspect.isfunction(obj):
continue
print(name, obj)

# 3. 在getmembers中指定获取某些类型的信息
for name, obj in inspect.getmembers(inspecttest, inspect.isclass):
print(name, obj)
  1. getsource 函数: 获取实时对象的原始代码信息, 该函数返回字符串类型
1
2
3
4
import inspecttest
import inspect

print(inspect.getsource(inspecttest.Children))

其输出如下:

1
2
3
4
5
6
7
8
9
class Children(Parent):
""" 子类: Children """

def do_something(self):
"""Does some work"""
print('Hi, i am children')

def get_name(self):
return 'Children(' + self.name + ')'
  1. signature: 提取函数参数信息, signature(obj).parameters
  2. getclasstree: 获取对象的类结构信息(基类, 子类等)
  3. getmro: 获取 mro 解析顺序
  4. currentframe: 获取当前代码所在位置的堆栈顶部的帧信息
1
2
3
4
5
6
import inspect

def testframe():
frame = inspect.currentframe()
print('line {} of {}'.format(frame.f_lineno, frame.f_code.co_filename))
testframe()
  1. stack: 获取当前帧到第一个调用者的所有堆栈帧信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import inspect

def teststack():
name = 'bifeng'

def current_stack():
stacks = inspect.stack()
for _stack in stacks:
frame = _stack.frame
print(f'文件名:{frame.f_code.co_filename}, 行号:{_stack.lineno}')
print(f'变量信息:{_stack.frame.f_locals}')

return current_stack

teststack()()

作用域和命名空间

Namespace

名称空间: A namespace is a mapping from names to objects.Most namespaces are currently implemented as Python dictionaries. 名字空间中存储的是名字到对象的映射关系. 不同的命名空间相互独立, 每一个命名空间都有其唯一的名字, 伴随着函数的调用和释放, 实例的创建和释放, 模块的导入和释放, 相应的命名空间会被创建和销毁, 这是一个动态的过程, 即命名空间存在生命周期, 并且不同的命名空间的生命周期是不一样的.

命名空间按照”变量定义位置”, 可以划分为三类(闭包情况下出现伪闭包命名空间):

  • Local: 局部 Namespace, 每一个 function, class 所拥有自己的命名空间(实例对象和类名的命名空间有什么区别?), 定义了所有变量的信息
  • Global: 全局 Namespace, 模块加载时, 记录 module 中的变量, 函数, 类, 导入的模块, 模块级常量变量
  • Built-in: 内置命名空间, 内置函数和异常, 变量等

那么, 我们如何查看当前所在命名空间中所有定义的变量呢? 我查了网络上的好多资料均没用明确的之处具体某一个函数会打印当前命名空间的值, 这里就以dir, globals, locals来进行说明了.

  • locals:Update and return a dictionary representing the current local symbol table., 该函数返回当前命名空间的字典引用信息
  • globals: Return the dictionary implementing the current module namespace, 该函数返回当前模块全局命名空间的字典引用信息
  • dir: Without arguments, return the list of names in the current local scope . With an argument, attempt to return a list of valid attributes for that object, 这里涉及到作用域的解释有一点迷糊, 暂时还是认定为命名空间吧.

注意, locals 和 globals 返回的是字典的引用, 此时改变命名空间的变量定义, 这两个函数返回的值也会顺便的更改, 在我个人感觉, 命名空间非常像 javascript 下变量对象, 实际上类, 函数也是一个对象. 下面举一个简单的例子来说明命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import os
import sys

COMMON_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + os.sep + 'common'
sys.path.insert(0, COMMON_PATH)
from util import show_function # pylint: disable=import-error

# 1. 打印内建命名空间信息
print(f'内建命名空间信息:{dir(__builtins__)}')


# 2. 简单的命名空间
@show_function(desc='命名空间-简单定义')
def ns_local():
global h # 注意, 这里不能用nonlocal, 除非再嵌套一层
x, y, z = 1, {}, 'wa' # pylint: disable=possibly-unused-variable
h = h + 1 # pylint: disable=used-before-assignment
print(f'局部命名空间中的值:{locals()}, h值: {h}')


x, y, h = 2, [], -2
ns_local() # 输出: {'x': 1, 'y': {}, 'z': 'wa'}, h值: -1
ns_local() # 输出: {'x': 1, 'y': {}, 'z': 'wa'}, h值: 0
ns_local() # 输出: {'x': 1, 'y': {}, 'z': 'wa'}, h值: 1
print(f'全局命名空间中的值:{globals()}') # 输出: {..., 'x': 2, 'y': [], 'h': 1}

注意上面的ns_local函数, 每一次调用实际上就是一个实例化函数变量对象的过程, 每一次初始化上下文都会创建一个命名空间, 创建的三个命名空间都是相互独立的, 那么这些命名空间是在什么时候被销毁的? 不同的命名空间有不同的声明周期:

  • 内置命名空间: 解释器启动时创建, 解释器退出时销毁
  • 全局命名空间: 模块被解释器读入的时候创建, 在解释器退出时销毁, 即其销毁也是伴随着解释器退出而退出的, 所以尽可能少定义全局变量
  • 局部命名空间: 函数局部命名空间在函数调用时创建, 函数退出时销毁(闭包情况下会延长, 每一个递归函数都有自己的命名空间). 类命名空间类似

在了解命名空间的基本概念之后, 现在, 让我们回归认真想一下如下的问题

  • 外部命名空间为何无法访问该命名空间? 那么为何内部命名空间可以访问外部命名空间的变量呢?
  • 嵌套的命名空间是怎样进行某一个变量的值查找的? 类似 javascript 的原型链? 这里也有一个命名空间链吗?
  • 闭包的命名空间是怎样查找的?

作用域

作用域: A scope is a textual region of a Python program where a namespace is directly accessible. "Directly accessible" here means that an unqualified reference to a name attempts to find the name in the namespace., 根据第一章可知, python 作用域是静态作用域, 其在代码编写的时候就已经决定. 单独理解作用域可能会和命名空间产生混淆, 不能分清主次, 分清哪个是虚拟概念, 需要将这两者结合起来一起理解, 现在让我们简单回答小 2.1 节问题:

  • 命名空间依托于作用域的概念(规则), 完成了不同代码块(不同命名空间)的交互逻辑
  • 作用域规则提出了四个查找锚点: Local, Enclosing, Global, Built-in
  • 依托于 Enclosing 作用域规则, 在函数对象调用结束之后未主动销毁外层命名空间, 从而确保闭包能够顺利进行

下面是一个非常好的类比作用域和命名空间的例子, 我直接拷贝网上的信息:

用一个类比来理解命名空间与作用域:

  1. 四种作用域相当于我们生活中的国家(Built-in)、省(Global)、市(Enclosing)、县(Local)
  2. 命名空间相当于公务员花名册,记录着哪个职位是哪个人。

国家级公务员服务于全国民众(全国老百姓都可以喊他办事),省级公务员只服务于本身民众(国家层面的人或者其他省的人我不管),市(Enclosing)、县(Local)也是一个道理。

当我们要找某一类领导(例如想找个警察帮我打架)时(要访问某个名称),如果我是在县(Local)里头,优先在县里的领导花名册中找(优先在自己作用域的命名空间中找),县里花名册中没警察没有就去市里的花名册找(往上一层作用域命名空间找),知道找到国家级都还没找到,那就会报错。如果省级民众想找个警察帮忙大家,不会找市里或者县里的,只会找自己省里的(其它省都不行),或者找国家级的。

国家、省、市、县肯定一直都在那里,可不会移动(作用域是静态的);领导可以换届,任期移到就换人(命名空间是动态的,每次调用函数都会新的命名空间,函数执行结束,命名空间销毁)。

命名空间就是具体的花名册, 其内部的所有人员以及场所都是实际存在的物体, 作用域(国家, 省, 市, 县)都是一个行政概念, 但是这些行政概念又管理者每一个对应的花名册, 决定了哪些花名册对其他花名册可见/不可见, 这是一个规则(LEGB). 那么, 这个规则是什么呢?

  • Local: 局部变量内部作用域, 如函数, 方法, 类内部命名空间
  • Enclosing: 嵌套作用域, 包含了非局部以及非全局的任意封闭命名空间的作用域, 这是闭包产生的基础
  • Global: 模块全局作用域
  • Built-in: 内部作用域

他们的查找顺序是: local -> enclosing -> global -> builtin-in, 每一次按照逻辑规则向上查找上一级花名册就是寻找命名空间, 然后在命名空间中匹配变量是否存在, 如果不存在继续按照该查找规则向上. 目前, 我找了很多资料, 也没用在 python 中发现类似 javascript 的[[scopes]]这种存储变量对象的作用域数组, __closure__中存储的变量值与实际好像有一点不符合, 这是目前没有搞清楚的一点问题.

nonlocal

在内部函数使用外部变量的时候需要用到两个修饰符: global, nonlocal

  • global: 使用模块全局变量信息, global X
  • nonlocal: 使用外层嵌套作用域变量, 注意, 其必须在 2 层以上嵌套中使用, 否则会报错

引用