python从exec函数理解名字、对象及命名空间

发布时间 2023-11-10 10:41:48作者: 筛网柠檬

 

第一层理解 exec函数基本使用

python中有个自建(builtin)函数 exec,这个函数支持动态执行 Python 代码,函数返回值永远是None。

这个函数是将存放起来的一串字符当作python的代码来执行,这串代码可以放在数据库中,在内存中,在文件中等等;

可以将任意字符串当作代码执行的方式增加了python语言的灵活性,我们甚至可以封装一些框架,保存并执行用户编辑的python代码脚本

python语法:

exec(object, globals=None, locals=None, /, *, closure=None)

参数:

  • object: 必传。object接收一串符合python语法的字符串或者“代码实例”;
  • globals: 非必传。“全局命名空间”,下文会描述关于命名空间的理解。
  • locals: 非必传。“局部命名空间”,使用exec函数执行的代码相当于在“模块”级别执行,globals和locals通常是同一个字典。
  • /: python3.8新增语法。位置形参数标记,在此左边的参数都是位置形参,不能使用key=value的形式传入参数。
  • *: 收集多个位置形参,使用函数时如果传了许多个位置形参不会报错,在函数执行时不会用到这些参数。
  • closure: 3.11后第一个参数object可以传入闭包,closure作为闭包的环境元组

举例:

(1)只传第一个参数(object)

exec("print(1)")

输出打印:

1

a = "print(99)"
exec(a)

输出打印:

99

code = """
name = "石榴"
print(name)
"""
exec(code)

输出:

石榴

(三个引号可以写带换行符的字符串)

(2)传入第一个参数(object)和第二个参数globals

my_vars = {"a": 100, "b": 1000}
code = "print(a + b)"
exec(code, my_vars)

输出打印:1100

code字符串中的python代码,想要打印a + b,但是code字符串中a和b并没有定义,找不到名字a与名字b绑定的对象,找不到定义会去某一个地方寻找,这个地方就叫命名空间。

第二层理解 名字与类型

名字(name)

引用《python3 学习笔记》中的描述

通常认知里,变量被一段具体的内存保存,变量名是对内存的别名。因为在写代码时,无法确定内存的位置,所以用名称符号代替。接下来静态编译和动态解释型语言对于变量名的处理完全不同。静态编译器会以固定的内存地址,直接或间接寻址指令代替变量名。也就是变量名不参与执行过程,是个替身,可被删除。但在解释型语言中,名字和对象是两个在内存中真实存在的运行期实体。名字是动态语言模型的基础。

名字就像银行中的柜员,顾客不能直接操作柜台上的钞票,必需先找到柜员,让柜员操作钞票。这么做需要很多额外的开销,对性能不利,但是多了很多机制。

名字需要与目标对象关联起来才有意义。

print(x)

输出:

Traceback (most recent call last):
File "/Users/wangchen/Desktop/study/二层.py", line 1, in <module>
print(x)
NameError: name 'x' is not defined

最简单的关联方式就是赋值。

x = 110
print(x)

输出:10

上面的赋值步骤具体是:

1. 先目标对象100

2.准备好名字x

3. 在名字空间中为两者建立关联

python命名语法与合规要求:

python名字命名的统一风格

  • 类型名称使用驼峰大写字母开头的CapWords格式
  • 模块文件名、函数名、方法成员等都适用小些加下划线lower_case_with_underscores格式
  • 全局常量使用大写字母加下划线UPPER_CASE_WITH_UNDERSCORES格式
  • 不使用内置类型、函数、标准库相同的名字

类型(type)与实例(instance)(对象)

实例(instance)也可以叫对象(object),是某个类型的实例。

 猫和狗是动物的子类,小黄是具体的一条狗,小黄就是类型狗的一个实例。狗属于动物的一个子类,动物的特性被狗继承。

python中的类型和自然界很像,1000的类型是整数int,1000具有整数int的特征,整数还有1,2,3等等,1000是整数int的众多实例中的一个。

可以用自建函数isinstance(实例, 类型),判断实例的类型

print(isinstance(1000, int))
print(isinstance(1000, str))

 输出:

True

False

1000是int的实例,不是str的实例

python中所有的类型都继承于object,可以用自建函数issubclass(子类,父类)判断继承关系,object也是int的父类

print(issubclass(int,object))

输出:

True

有点违背思考方式的是,python中的类型同时也是实例,类型都是type的实例,但与继承关系无关。

print(isinstance(object, type))

输出:

True

与继承关系无关:

print(issubclass(object, type))

输出:

False

得出下面的图:

除了用赋值语句将名字与对象绑定在一起,下面这个语句也会将名字与对象绑定:

  • 函数的正式参数,

  • 类定义,

  • 函数定义,

  • 赋值表达式,

  • 如果在一个赋值中出现,则为标识符的 目标 :

    • for 循环头,

    • 在 with 语句, except 子句, except* 子句,或格式化模式匹配的 as 模式的 as 之后,

    • 在结构模式匹配中的捕获模式

  • import 语句。

  • type 语句。

  • 类型形参列表

第三层 python中的对象

1. 对象的属性

对象继承父类后,将父类的特点保存在哪里了呢?

可以使用自建函数dir(instance)查看对象有哪些特性

a = 1000
print(dir(a))

输出:

['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__getstate__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_count', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

这些属性都可以用点符号进行访问:

print(a.to_bytes)

输出:

<built-in method to_bytes of int object at 0x104dbf888>

2. 同一个对象可以与多个名字建立关系

python很有趣的语法:

a = b = 100

与a=100再b=100一样,python很多语法都很符合人类直觉

怎么判断两个名字关联的对象是不是同一个呢?可以使用自建函数id(object)

print(id(a))
print(id(b))

输出:

4376492168

4376492168

在CPython中,这串数字是内存的地址:https://docs.python.org/zh-cn/3.11/reference/datamodel.html

查看其他版本的python可以查看文档:https://docs.python.org/zh-cn/3/reference/introduction.html

python提供了关键字判断两个名字关联的是不是同一个对象

a = b = 100
print(a is b)

输出:

True

3. 对象的数据结构

我简单的将一个对象理解为一个字典,对象的数据结构伪代码:

{
    "id": xxxxx,  # 唯一标记,内存地址"value": 1000,
    "dad": int,
    "WhatICanDo": ['__abs__', '__add__', ...],
    "WhatIHave": ['__code__'...]
}

注意这里没有名字,因为名字与对象关联是名字空间做的事情

第四层 名字空间

对象与名字是在命名空间中建立联系。命名空间默认使用字典的数据结构,使用globals()自建函数返回当前的全局命名空间。

a = 1000
print(globals())

输出:

{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x103c8d0d0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'xxx.py', '__cached__': None, 'a': 1000}

可以看到结尾处globals中有'a': 1000,对a的赋值语句在globals中创建了一个key。

当函数内代码执行时,使用的是局部名字空间。

def f():
     b = 1000 
     print(globals())
     print(locals())
 
 f()

输出:

{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7fbc399b5fd0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '四层.py', '__cached__': None, 'a': 1000, 'f': <function f at 0x7fbc399ba160>}
{'b': 1000}

基本可以认为在模块级别(.py文件)的名字关联会在全局命名空间中,一旦进入函数(发生function_call或者method_call)就会使用局部命名空间

是不是不用赋值语句也可以将名字与对象关联起来?

globals()['c'] = 2000
print(c)

输出:

2000

看起来是可以的

寻找名字关联的对象时,如果当前命名空间没有找到,遵循LGB查找规则继续查找,(locals、globals、buildin),也就是按这三种种命名空间的优先级进行查找,本地(局部)命名空间优先级最高。

edge指的是相邻的命名空间,因为python函数可以嵌套定义,所以在locals与globals之间还可能有其他的命名空间。

常用的print自建函数,是典型的global中找不到“print”这个名字,然后继续向buildin中找

如果在global中关联了print,那么就不会执行自建函数的print了:

print = 10
print(100) 

输出:

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

TypeError: 'int' object is not callable

print变成了对象10,解除了与原对象print的关联;为什么 'int' object is not callable呢?这与运算符重载特性有关

回到exec函数代码:

my_vars = {"a": 100, "b": 1000}
code = "print(a + b)"
exec(code, my_vars)

了解了名字对象与命名空间可以这么理解:

1. 先创建了字典my_vars,作为命名空间传入exec

2. 执行代码,找到名字a关联的对象100, 与名字b关联的对象1000

3. 执行加法并打印出来

第五层 对象与运算符

说到对象,本层内容憋不住想说下,虽然和exec函数没啥关系。python中虽然处处都是对象,但python支持函数式编程,运算符就是函数式编程的一个特点。

 既然 a = 100 可以转换成直接在命令空间字典中赋值,那么所有语法是不是都可以转换成另一种形式?

a = 200
b = 300

print(a == b)
print(a.__eq__(b))

a += 1
print(a)
a = a.__add__(1)
print(a)

输出:

False
False
201
202

答案是可以将某个语法换成另一种形式,换成更对象操作的更本质的形式。实际上,所有运算符都会被转为对象属性,可以看看官方对运算符的描述:https://docs.python.org/zh-cn/3/library/operator.html

我用下面代码做验证:
class MyStr(str):

    def __eq__(self, value):
        if value == 666:
            return True
        else:
            return False


a = MyStr("abc")
b = "abc"
c = 666

print(a)
print(a == b)
print(a == c)

输出:

abc
False
True

这段代码相当于修改了str的__eq__方法,直接影响了 == 语法的行为,只有输入是666时才会返回True;所以结论是 == 语法与 __eq__的行为是一致的。

这种行为在python中称为运算符重载。python虽然支持运算符重载,但也加了限制:

def equal(value):
    if value == 666:
        return True
    else:
        return False

a = 100
a.__eq__ = equal

输出:

Traceback (most recent call last):
File "运算符重载.py", line 9, in <module>
a.__eq__ = equal
AttributeError: 'int' object attribute '__eq__' is read-only

python不允许对自建类型对象进行运算符重载

除了__eq__ 还有有几个非常典型的运算符值得聊聊,全部的运算符可以参考官方文档:https://docs.python.org/zh-cn/3.11/library/operator.html

1. __call__: 对应的运算符是(),所有的函数对象都有这个属性,函数对象执行()时,运行函数的代码对象:(注意单写(),前面什么都不加在语法上是空元组的意思)

def a():
    print("w")

print(dir(a))
a()
a.__call__()

输出:

['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
w
w

如果函数返回的对象具有__call__属性,那么返回值也可以进行()运算

def b():
    print("b")
    return b

b()()()()()()

输出:

b
b
b
b
b
b

2. __getitem__:对应的运算符是[]

a = [1, 0, 10]
print(dir(a))
print(a[1])
print(a.__getitem__(1))

输出:

['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']
0
0

3. __getattribute__:对应的运算符是点.

这个运算符用的实在太多了,上个例子中的a.__getitem__实际上就是在a.__getattribute__("__getitem"),这个运算符背后的逻辑非常复杂不单纯,还涉及描述器的逻辑。比如在类型中定义的方法第一个参数是self,但是在实例中调用时却不用传第一个参数self,这个逻辑就是典型的描述符逻辑。

通过以上这些例子,可以反向理解,某个对象是否能进行某种运算是由对象是否具有属性决定的,比如函数对象不能用[],是因为函数对象没有__getitem__属性。

第六层 执行模型

上面在python层面描述了对象与名字关联的过程,以及我对“对象”的理解;

如果想进一步探究python解释器是怎么样与内存交互,需要先了解python的执行模型,引用官方的文档:

Python 程序是由代码块构成的。 代码块 是被作为一个单元来执行的一段 Python 程序文本。 以下几个都属于代码块:模块、函数体和类定义。 交互式输入的每条命令都是代码块。 一个脚本文件(作为标准输入发送给解释器或是作为命令行参数发送给解释器的文件)也是代码块。 一条脚本命令(通过 -c 选项在解释器命令行中指定的命令)也是代码块。 通过在命令行中使用 -m 参数作为最高层级脚本(即 __main__ 模块)运行的模块也是代码块。 传递给内置函数 eval() 和 exec() 的字符串参数也是代码块。

代码块在 执行帧 中被执行。 一个帧会包含某些管理信息(用于调试)并决定代码块执行完成后应前往何处以及如何继续执行。

如果程序执行相当于做饭,那么帧就是做饭时用到的厨房,编译就是做饭前把菜洗好,肉切好,执行就是把菜放到锅里炒,python执行的"锅"是stack栈,这口锅的特点是先放锅里的最后才能拿出来,后放锅里的先拿出来

执行帧示意图:(基于python3.11源代码 include/intenal/pycore_frame.h)

 

stack就是锅,其他都是切好的菜,准备好的调料。

每一个函数被调用时,就会创建上面一个帧结构;然后根据字节码,可以看到代码是怎么在这一帧中运行的

python提供了dis模块用于将字节码反编译成人类可读:https://docs.python.org/zh-cn/3/library/dis.html

import dis

code = """
a = 1
b = 'sky'
c = ()
l = [a, b, c]
print(l)
"""

dis.dis(code)

 输出:

竖着看

第一列 2 是原代码的行号

第二列 字节码的下标,叫偏移量

第三列 LOAD_CONST 动作

第四列 参数,从代码对象中的某个序列对象中取值,这个参数是序列对象的下标;比如LOAD_CONST从co_consts取值,参数是co_consts的下标,LOAD_NAME从co_names取值,参数是co_names的下标

第五列(1)参数指向的具体的值

由于需要从代码对象中取值,需要知道编译后代码对象中的值;python代码可以直接查看代码对象内容

import dis

code = """
a = 1
b = 'sky'
c = ()
l = [a, b, c]
print(l)
"""

a_com = compile(code, "a.py", "exec")
print(type(a_com))
print(dir(a_com))
print("co_consts:", a_com.co_consts)
print("co_names:", a_com.co_names)
print("co_cellvars:", a_com.co_cellvars)
print("co_argcount:", a_com.co_argcount)
print("co_code:", a_com.co_code)
print("co_filename:", a_com.co_filename)
print("co_firstlineno:", a_com.co_firstlineno)
print("co_flags:", a_com.co_flags)
print("co_freevars:", a_com.co_freevars)
print("co_kwonlyargcount:", a_com.co_kwonlyargcount)
print("co_lnotab:", a_com.co_lnotab)
print("co_name:", a_com.co_name)
print("co_nlocals:", a_com.co_nlocals)
print("co_posonlyargcount:", a_com.co_posonlyargcount)
print("co_stacksize:", a_com.co_stacksize)
print("co_varnames:", a_com.co_varnames)

输出:

<class 'code'>
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_posonlyargcount', 'co_stacksize', 'co_varnames', 'replace']
co_consts: (1, 2, 3, None)
co_names: ('l', 'print')
co_cellvars: ()
co_argcount: 0
co_code: b'd\x00d\x01d\x02g\x03Z\x00e\x01e\x00\x83\x01\x01\x00d\x03S\x00'
co_filename: a.py
co_firstlineno: 2
co_flags: 64
co_freevars: ()
co_kwonlyargcount: 0
co_lnotab: b'\n\x01'
co_name: <module>
co_nlocals: 0
co_posonlyargcount: 0
co_stacksize: 3
co_varnames: ()

LOAD_CONST的具体操作是将参数值压到栈里,可以查阅文档查看动词解释:https://docs.python.org/zh-cn/3/library/dis.html

根据以上信息作出代码a执行的示意图:

 

先看编译部分,编译时将代码处理成字节码,并对数据进行一定程度的整理。相当于做菜前,把菜切好,蒜剥好,调料准备好,等待下锅。

值的关注的是co_const和co_names,编译时会将字面量值放入co_const, 名字放入co_names

进入执行期,stack栈内存就是炒锅。字节码就是菜谱,程序按照字节码进行炒菜。

RESUME 啥也没做,官方文档解释是在文档执行内部追踪、调试和优化检查。

LOAD_CONST 从co_const中取出下标是参数的对象,压入栈中。

STORE_NAME 按参数从co_names中取值,然后从栈的顶部取值,将名字与对象建立链接,存入f_locals命名空间。

LOAD_NAME 根据名字找对象,我把原代码贴出来了,因为这里体现了上文说的LGB命名空间优先级关系。

BUILD_LIST 建立序列对象,从对象的最大坐标,栈顶取值放入后,坐标-1,然后再从栈顶取值。我把原码贴出来了。

PUSH_NULL PRECALL 都是为CALL做准备,CALL时会带走一些食材和调料,到一个新的厨房去做菜,做完后再把菜端回来放入本厨房的锅里。

CALL的调用会创建新的执行帧并执行,每个执行帧结束后会回到上一帧,直到程序结束。

第七层 陷阱

python中有很多陷阱让人琢磨不透,但是可以通过字节码然后找C源码找到原因,这也是理解第六层的实用性。这里列出几个关于名字,对象及exec函数的陷阱。例子参考了B站up 码农高天的视频。

a = 1000
b = 1000
print(a is b)


a = 1000
exec("b = 1000")
print(a is b)

输出:

True
False

解释:

第一段代码a与b关联的对象是同一个,在同一个执行帧中,a = 1000时建立了1000这个对象,到b = 1000时已经有了1000对象就不再创建1000这个对象了,直接将b与已经有的1000对象关联,所以a与b关联了同一个对象

第二段代码a与1000关联后,执行exec函数进入新的执行帧,新的执行帧里没有服用上一帧的对象,而是创建了新的对象1000,然后与b关联,所以a与b不是同一个对象,虽然值相同。这个例子也体现了,1000是对象,不是字面量。

a = "sky"
exec('b = "sky"')
print(a is b)


a = "sk y"
exec('b = "sk y"')
print(a is b)

输出:

True

False

解释:

第一段代码,这个例子似乎不符合上个例子,原因是字符串对象的特殊性,字符串在python中是非常常用的对象,这个字符串有可能是名字,所以python觉得这东西值得一存,说不定一会就调用这个名字字符串了,所以缓存了字符串对象。所以a与b关联了同一个对象。

第二段代码,这个例子是符合第一段代码的解释的,有空格后这个字符串不可能是名字了,所以python没缓存。在新的帧中又创建了“sk y”对象。所以a和b不是同一个对象。

var = 1
exec("var=2")
print(var)


def f():
    var = 1
    exec("var=2")
    print(var)
f()

输出:

2
1

解释:

第一段代码,var在全局变量中,在exec中修改了全局变量字典,所以退出exec后,var在全局变量中已经发生改变,所以var=2

第二段代码,var在函数f的局部变量中,var=2后修改了局部变量,退出exec函数后,局部变量应该被修改,但是var却等于1,是因为局部变量不是字典的数据结构,而是一段连续的内存块,不能被其他帧修改。所以var还是1。为啥locals不设计成字典呢,有部分原因因为字典通过键找值的过程效率低,locals使用高效率的连续内容,通过移动指针找值效率高。

参考:

python文档:https://docs.python.org/zh-cn/3.11/library

《python3学习笔记》作者:雨痕

B站up:码农高天 https://space.bilibili.com/245645656