python的global在编译层面的进阶理解

目录

报错情况

编译执行过程

(1)源代码(.py 文件)

(2)编译阶段:解析 & 生成字节码

(3)解释执行:Python 虚拟机(PVM)

字节码分析

语法树分析

字节码的生成

阶段一:AST 遍历与标记(第一遍扫描)

阶段二:符号分析与作用域解析(第二遍扫描)​编辑

错误分析

第一种

第二种

第三种

总结

特例


有那里不妥,还请多多指教。

报错情况

第一种情况,这样的代码会报错,你理解不?

x = 0

def chuli(s):
    if s is None:
        global x
        x = 50
    else:
        global x
        x = 100

第二种情况,这样的代码会报错,你理解不?

x = 0

def chuli(s):
    global x
    x = 50
    global x
    x = 100

第三种情况,这样的代码会报错,你理解不?

x = 0

def chuli(s):
    x = 50
    global x
    x = 100

第四种情况,这样的代码正确执行了,而且值是100,你理解不?

x = 0

def chuli(s):
    while False:
        global x
    x = 100

chuli(None)
print(x)  # 100

反正我一开始遇到 第一种情况 是无语的,我都在喷这语法设计了,甚至怀疑我的python学了个寂寞。而且我在网上搜索了一堆关于global的讲解,但是没人去尝试这样的错误,去解释这样出现的原因,都是在那反反复复的去讲解python的语法文档中写的非常清楚的内容。有时感觉,有点悲哀,我跟大多数人的思路有点不合群,大家都没有一点想法,找不到有点共情的文章了。然后我只能一点点去从编译的角度,看看这个编译器、解释器是怎么实现对这部分代码的语法分析的。

编译执行过程

在分析之前,我们说一说这个python代码从编译到执行的过程。

        当你运行一个 Python 文件(python script.py),Python 解释器会按照以下步骤执行代码:

(1)源代码(.py 文件)

        你编写的 Python 代码是纯文本,存储在 .py 文件中。

(2)编译阶段:解析 & 生成字节码

        Python 不会直接执行源码,而是先将其编译成字节码(Bytecode)

        这个过程包括:

  1. 词法分析(Lexical Analysis):拆分成标记(tokens)

  2. 语法分析(Syntax Analysis):检查代码结构是否合法,生成抽象语法树(AST)

  3. 字节码生成(Bytecode Compilation):转换成 Python 字节码(Bytecode),存储在 .pyc.pyo 文件中(位于 __pycache__ 文件夹)。

(3)解释执行:Python 虚拟机(PVM)

        Python 解释器的核心是 Python 虚拟机(PVM, Python Virtual Machine),它读取字节码并执行相应的操作。

        PVM 是一个虚拟机,它不会将代码编译成 CPU 机器码,而是逐行解释执行 Python 字节码。因此,Python 运行速度相对较慢,但更灵活,支持动态类型。

字节码分析

针对于第一种、第二种、第三张情况,chuli(s)函数都没有被调用运行,然后直接编译运行就会报错SyntaxError: name 'x' is assigned to before global declaration 错误,可以说明这个问题,大概是在编译阶段出现的报错,应该和解释执行阶段没什么关系。

而这里编译阶段最后有个字节码生成,字节码是 Python 解释器能理解的低级指令,与 CPU 指令类似,但独立于具体硬件。我们可以先尝试先从这个字节码角度切入看看global语句的情况。刚好python有个dis库可以查看字节码import dis。

import dis

x = 0
def chuli1(s):
    while False:
        global x
    x = 100

def chuli2(s):
    global x
    x = 100

def chuli3(s):
    x = 100

print(dis.dis(chuli1))
print(chuli1.__code__.co_consts)  # 查看常量表
print(chuli1.__code__.co_names)   # 查看变量名表
print(chuli1.__code__.co_varnames)  # 查看局部变量
print("-------------------------------------------------")
print(dis.dis(chuli2))
print(chuli2.__code__.co_consts)  # 查看常量表
print(chuli2.__code__.co_names)   # 查看变量名表
print(chuli2.__code__.co_varnames)  # 查看局部变量
print("-------------------------------------------------")
print(dis.dis(chuli3))
print(chuli3.__code__.co_consts)  # 查看常量表
print(chuli3.__code__.co_names)   # 查看变量名表
print(chuli3.__code__.co_varnames)  # 查看局部变量

这里执行三种可以正常运行通过的代码,可以发现,并没有global相关的字节码语句,只有STORE_FAST 和 STORE_GLOBAL的区别(这里如果想深入理解python的字节码,可以补齐一点python字节码的知识dis — Disassembler for Python bytecode — Python 3.13.2 documentation)。而且chuli3(s)函数中的x在函数的局部变量表中,chuli2(s)和chuli1(s)在全局变量表中,可以断定,global语句不会生成对应的字节码语句,而会将变量的位置初始化到函数的全局变量表中。

但是,依照语法,chuli1(s)中的“global x”的while循环根本,不会进去,“global x”应该不会被执行,但是chuli1(s)和chuli2(s)的字节码的效果是一样的。进一步说明,“global x”不是通过生成字节码,在解释执行阶段用虚拟机PVM来执行的。而是在生成字节码前被提前处理或者标记了。

语法树分析

所以,我们继续顺腾摸瓜,查找一下编译阶段具体是如何处理global语句的,来解释第一种、第二种、第三种在编译阶段为什么会报错。python中有个ast库可以获取python代码编译过程的抽象语法树(这里如果想深入理解python的字节码,可以补齐一点python字节码的知识ast — Abstract Syntax Trees — Python 3.13.2 documentation)。

import ast

code = """
x = 0
def chuli(s):
    if s is None:
        global x
        x = 50
    else:
        global x
        x = 100
"""

try:
    tree = ast.parse(code)
    print(ast.dump(tree, indent=4))
    print("AST 解析成功")
except SyntaxError as e:
    print("AST 解析失败:", e)

这里我们将前面三种情况会报错的代码的AST解析都是成功的,并且global x对应的语法分析结果"Global(names=['x'])" 仍然保留,并未被处理。

字节码的生成

以此,我们可以确定这个global是在字节码生成阶段被处理的,并且报错的。这个部分由python的CPython的symtable.c文件处理https://github.com/python/cpython/blob/main/Python/symtable.c

这个处理过程主要分为两个阶段:

阶段一:AST 遍历与标记(第一遍扫描)

这个阶段发生在代码访问抽象语法树(AST)节点时。相关的函数是 symtable_visit_stmt,特别是处理 Global_kind 这个global关键字对应的token:

  1. 识别 Global_kind 节点:
    当 symtable_visit_stmt 函数遇到一个语句节点 s,并且 s->kind == Global_kind(表示这是一个 global 语句)时,它会执行该 case 分支内的代码。

  2. 遍历名称:
    代码获取被声明为 global 的标识符序列:asdl_identifier_seq *seq = s->v.Global.names;。然后,它会循环处理这个序列中的每一个 identifier name(标识符名称)。

  3. 冲突检查:
    对于每一个 name,在添加 global 标记之前,它会执行关键的冲突检查

    • long cur = symtable_lookup(st, name);:它查找在当前作用域的符号表(st->st_cur->ste_symbols)中与这个 name 关联的现有标记(flags)。

    • if (cur & (DEF_PARAM | DEF_LOCAL | USE | DEF_ANNOT)):检查这个名字是否已经被标记为:

      • 参数 (DEF_PARAM)

      • 因之前的赋值而成为的局部变量 (DEF_LOCAL)

      • 在 global 声明之前被使用过 (USE)

      • 一个带注解的变量 (DEF_ANNOT)

    • 如果这些标记中有任何一个被设置了,就表示存在冲突,这是一个 SyntaxError(语法错误)。代码会根据冲突的标记确定具体的错误信息(例如 GLOBAL_PARAM - “名字 '...' 是参数又是全局变量”,GLOBAL_AFTER_USE - “名字 '...' 在 global 声明前被使用”,GLOBAL_AFTER_ASSIGN - “名字 '...' 在 global 声明前被赋值”,GLOBAL_ANNOT - “带注解的名字 '...' 不能是全局变量”),并使用正确的位置信息(SET_ERROR_LOCATION)抛出错误。

  4. 添加 DEF_GLOBAL 标记:
    如果没有冲突,代码继续执行:

    • if (!symtable_add_def(st, name, DEF_GLOBAL, LOCATION(s))):调用 symtable_add_def 函数(内部会调用 symtable_add_def_helper)。这个函数将 DEF_GLOBAL 标记添加到当前作用域符号表(st->st_cur->ste_symbols)中与 name 关联的现有标记上。

  5. 记录指令位置:

    • if (!symtable_record_directive(st, name, LOCATION(s))):调用 symtable_record_directive 函数。这个函数存储了这个 name 被声明为 global 的确切源代码位置(行号、列偏移等)。这对于后续分析阶段(如果出现其他冲突,如同时被声明为 global 和 nonlocal)进行准确的错误报告非常重要。

阶段二:符号分析与作用域解析(第二遍扫描)

在第一遍扫描遍历完整个 AST 后,会调用 symtable_analyze 函数,该函数会为每个作用域递归地调用 analyze_block 和 analyze_name。

  1. analyze_block 调用 analyze_name: analyze_block 遍历当前作用域条目(ste)中在第一遍扫描时收集到的符号。对于每个符号(name)及其关联的标记(flags),它会调用 analyze_name。

  2. analyze_name 处理 DEF_GLOBAL:
    在 analyze_name 内部,当它处理一个符号(name),其 flags(从第一遍扫描构建的符号表条目中获取)包含了 DEF_GLOBAL 位时:

    • if (flags & DEF_GLOBAL):这个条件判断为真。

    • Nonlocal 冲突检查: 首先检查 if (flags & DEF_NONLOCAL)。如果一个名字因为某种原因同时被标记为 global(在当前作用域)和 nonlocal(也在当前作用域,尽管 symtable_visit_stmt 应该阻止这种情况,但这是一种保护措施),就会引发一个 SyntaxError(“名字 '...' 是 nonlocal 又是 global”),并使用 symtable_record_directive 存储的位置信息。

    • 设置作用域为 GLOBAL_EXPLICIT: SET_SCOPE(scopes, name, GLOBAL_EXPLICIT);:它明确地将这个名字的作用域设置为 GLOBAL_EXPLICIT(显式全局)。这个设置是发生在一个临时的 scopes 字典中,该字典用于本次分析过程。之后 update_symbols 函数会使用这个字典,将最终的作用域值(与其他标记打包在一起)写回到主符号表条目(ste->ste_symbols)中。

    • 更新 global 集合: if (PySet_Add(global, name) < 0):它将这个名字添加到一个名为 global 的集合中。这个集合追踪在当前作用域或任何外层作用域中已知为全局的所有名字,并在分析嵌套的子作用域时向下传递。

    • 更新 bound 集合: if (bound && (PySet_Discard(bound, name) < 0)):如果这个名字可能存在于 bound 集合(在外层函数作用域中绑定的名字)中,它会被移除,因为显式的 global 声明会覆盖任何外层的绑定。

错误分析

第一种

x = 0

def chuli(s):
    if s is None:
        global x  # 第一次 global 声明
        x = 50   # 对 x 进行赋值(使用)
    else:
        global x  # 第二次 global 声明 <--- 问题所在
        x = 100

核心原因:

Python 的 global 语句作用于其所在的整个函数作用域,而不是仅仅作用于它出现的那个代码块(如 if 或 else 块)。一旦你在函数中的某个地方声明了一个名字是 global 的,这个声明对整个函数都有效。

关键在于,在一个名字(如 x)在函数作用域内被使用(赋值或读取)之后,你不能再次对同一个名字使用 global 声明。

编译器(symtable.c)处理流程及报错点:

  1. 模块级别: x = 0 被处理。在模块作用域的符号表中,x 被标记为DEF_LOCAL,只是符号表构建第一阶段记录“绑定发生在此作用域”的一种方式。在第二阶段的作用域解析中,它被正确识别为 GLOBAL_IMPLICIT。最终在字节码生成阶段,结合作用域类型(模块)和作用域属性(GLOBAL_IMPLICIT),编译器会生成操作全局命名空间的 LOAD_NAME/STORE_NAME 指令,而不是像函数局部变量那样生成 LOAD_FAST/STORE_FAST。

  2. 进入函数 chuli: 编译器为函数 chuli 创建一个新的符号表作用域。

  3. 处理 if s is None: 块:

    • global x (第一次):

      • 编译器(symtable_visit_stmt)遇到第一个 global x。

      • 它检查在 chuli 函数作用域内,x 是否已经被用作参数、局部变量、或者已经被使用或注解。此时还没有,检查通过。

      • 编译器在 chuli 的符号表中为 x 添加 DEF_GLOBAL 标记,表示它在这个函数里是全局变量。并记录下这个 global 语句的位置。

    • x = 50:

      • 编译器遇到对 x 的赋值语句。

      • 它查找 x。由于上一步的 global 声明,它知道 x 指的是全局变量。

      • 同时,因为发生了赋值,编译器也会在 chuli 的符号表中为 x 添加 DEF_LOCAL 标记(表示在本作用域内有绑定操作)或者 USE 标记(表示被使用了)。现在 x 的标记类似于 DEF_GLOBAL | DEF_LOCAL 或 DEF_GLOBAL | USE。

  4. 处理 else: 块:

    • global x (第二次):

      • 编译器(symtable_visit_stmt)遇到第二个 global x。

      • 它再次查找 x 在 chuli 作用域内的标记 cur。此时 cur 的值包含了 DEF_GLOBAL 以及因为 x = 50 而添加的 DEF_LOCAL 或 USE 标记。

      • 编译器执行冲突检查:if (cur & (DEF_PARAM | DEF_LOCAL | USE | DEF_ANNOT))。

      • 因为 cur 中包含了 DEF_LOCAL 或 USE 标记,这个 if 条件判断为

      • 代码根据检测到的冲突标记(DEF_LOCAL 或 USE)确定错误消息。由于 DEF_LOCAL (表示赋值) 存在,错误消息是 GLOBAL_AFTER_ASSIGN("名字 'x' 在 global 声明前被赋值")。

      • 编译器使用 PyErr_Format 设置 SyntaxError,并附带错误消息和第二个 global x 语句的位置。

      • symtable_visit_stmt 返回失败 (0),整个编译过程(符号表构建阶段)中止。

第二种

x = 0

def chuli(s):
    global x  # 第一次 global 声明
    x = 50   # 对 x 进行赋值 (构成一种“使用”)
    global x  # 第二次 global 声明 <--- 问题所在
    x = 100

核心原因:

Python 规定,在一个函数作用域内,对于一个特定的名字(比如这里的 x),global 声明必须出现在该名字在该函数中首次被使用(赋值或读取)之前

一旦你在函数中对 x 进行了赋值(x = 50),编译器就认为 x 已经被“使用”了。在此之后,你不能再次为同一个名字 x 使用 global 声明。

编译器(symtable.c)处理流程及报错点:

  1. 模块级别: x = 0 被处理。在模块作用域符号表中,x 被正确地识别(最终解析为 GLOBAL_IMPLICIT 类型,并准备使用 LOAD_NAME/STORE_NAME)。

  2. 进入函数 chuli: 编译器为函数 chuli 创建一个新的符号表作用域。

  3. 处理第一个 global x:

    • 编译器(symtable_visit_stmt)遇到第一个 global x。

    • 检查在 chuli 函数作用域内,x 是否已被用作参数、局部变量、或者已被使用或注解。此时还没有,检查通过。

    • 编译器在 chuli 的符号表中为 x 添加 DEF_GLOBAL 标记,表示它在这个函数里引用的是全局变量。

  4. 处理 x = 50:

    • 编译器遇到对 x 的赋值语句。

    • 它查找 x。由于上一步的 global 声明,它知道 x 指的是全局变量。

    • 关键点: 因为这里发生了赋值操作,这在符号表分析中被视为对该名字的一种“使用”或“绑定”。编译器会在 chuli 的符号表中为 x 添加相应的标记,比如 DEF_LOCAL(表示在此作用域有绑定发生)或者 USE 标记。现在 x 的标记包含了 DEF_GLOBAL 以及 DEF_LOCAL 或 USE。

  5. 处理第二个 global x:

    • 编译器(symtable_visit_stmt)遇到第二个 global x。

    • 它再次查找 x 在 chuli 作用域内的标记 cur。此时 cur 的值包含了 DEF_GLOBAL 以及因为 x = 50 而添加的 DEF_LOCAL 或 USE 标记。

    • 编译器执行冲突检查:if (cur & (DEF_PARAM | DEF_LOCAL | USE | DEF_ANNOT))。

    • 由于 cur 中包含了 DEF_LOCAL 或 USE 标记,这个 if 条件判断为

    • 编译器检测到冲突:一个 global 声明出现在名字 x 被赋值(DEF_LOCAL 标记被设置)或使用(USE 标记被设置)之后

    • 根据冲突的标记(主要是 DEF_LOCAL),编译器确定错误消息为 GLOBAL_AFTER_ASSIGN ("name 'x' is assigned to before global declaration" - “名字 'x' 在 global 声明前被赋值”)。

    • 编译器使用 PyErr_Format 设置 SyntaxError,附带错误消息和第二个 global x 语句的位置。

    • 符号表构建阶段失败,编译中止。

第三种

x = 0 # 全局变量 x

def chuli(s):
    x = 50   # <--- 关键点:这里先对 x 进行了赋值
    global x  # <--- 然后尝试声明 x 为全局变量
    x = 100

核心原因:

Python 在处理函数内的变量时,遵循以下规则:

  1. 默认局部变量: 如果在函数内部对一个变量进行赋值,并且之前没有对该变量使用 global 或 nonlocal 声明,那么 Python 会默认将该变量视为这个函数的局部变量,覆盖任何同名的外部变量。

  2. global 声明时机: global 声明必须出现在该变量在函数作用域内首次被使用(包括赋值)之前

编译器(symtable.c)处理流程及报错点:

  1. 模块级别: x = 0 被处理,x 被识别为模块(全局)变量。

  2. 进入函数 chuli: 编译器为函数 chuli 创建一个新的符号表作用域。

  3. 处理 x = 50:

    • 编译器遇到对 x 的赋值语句。

    • 它检查在 chuli 函数作用域内,之前是否有 global x 或 nonlocal x 的声明。答案是没有

    • 根据规则 1,编译器立即决定 x 在 chuli 函数内部是一个局部变量

    • 它在 chuli 的符号表中添加 x,并标记为 DEF_LOCAL(表示在这个作用域内发生了绑定,并且是局部的)。

  4. 处理 global x:

    • 编译器遇到 global x 语句。

    • 它查找 x 在 chuli 作用域内的标记 cur。此时 cur 的值是 DEF_LOCAL(因为之前的 x = 50)。

    • 编译器执行冲突检查:if (cur & (DEF_PARAM | DEF_LOCAL | USE | DEF_ANNOT))。

    • 因为 cur 中包含了 DEF_LOCAL 标记,这个 if 条件判断为

    • 冲突发生! 编译器检测到:在一个名字 (x) 已经被当作局部变量进行赋值(DEF_LOCAL 标记已设置)之后,又尝试用 global 来声明它。这在逻辑上是矛盾的。

    • 编译器根据检测到的冲突标记(DEF_LOCAL),确定错误消息为 GLOBAL_AFTER_ASSIGN ("name 'x' is assigned to before global declaration" - “名字 'x' 在 global 声明前被赋值”)。

    • 编译器使用 PyErr_Format 设置 SyntaxError,附带错误消息和 global x 语句的位置。

    • 符号表构建阶段失败,编译中止。

总结

python中的这种 global 声明,它不会被编译成字节码执行,是在通过 语法树 去 生成字节码 时,被处理的存在。

python中的这种 global 声明是函数级别的,它可以在函数的任何地方被声明而生效,并不会因为它出现在不会被执行的代码中,它就不会生效。

global多少次一个变量都没问题,只要不要出现前面的代码中使用了这个变量,或对这个变量采用了赋值操作后导致被标记为全局作用域就可以了。

我都觉得这设计有点恶心、特别了,居然找了那么久都没人注意这个。

如果函数需要修改全局作用域中的不可变变量,就需要 global 关键字。可变变量呢?看下面的特例。

特例

还有一个比较让人有点无语的设计,这里可以不用global去改变全局变量。

x = []
def chuli1():
    x = [100]
chuli1()
print(x)  # []

x = []
def chuli2():
    x.append(100)
chuli2()
print(x)  # [100]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值