在测试 Python 变量引用顺序的时候,修修改改,写出了下面这样的测试代码:

def plus(a, b):
    return 10

def test1():
    def plus(a, b):
        return 20
    return plus

def test2():
    def plus(a, b):
        return 30
    return plus

if __name__ == '__main__':
    demo1 = test1()
    demo2 = test2()
    print(plus)
    print(demo1)
    print(demo2)
    print(test1())
    print(test2())

对应输出结果:

<function plus at 0x000002063B18B798>
<function testa.<locals>.plus at 0x000002063B18B678>
<function testb.<locals>.plus at 0x000002063B18B948>
<function testa.<locals>.plus at 0x000002063B18B9D8>
<function testb.<locals>.plus at 0x000002063B18B9D8>

可以发现,前面几句都很正常:“变量引用的时候先在本地符号表中查找,然后是闭包函数的符号表,接着是全局符号表,最后是内置符号表。”但最后两行,不同函数的内存地址竟然一样。

请教了一下专业人士,原来和函数栈结构有关。


参考 C 语言函数调用栈(一) clover_toeic,先复习一下函数调用时的具体步骤:

  1. 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。

    注:x86 平台将参数压入调用栈中。而 x86_64 平台具有 16 个通用 64 位寄存器,故调用函数时前 6 个参数通常由寄存器传递,其余参数才通过栈传递。

  2. 主调函数将控制权移交给被调函数(使用 call 指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在 call 指令中)。

  3. 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。

  4. 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。

  5. 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如 EAX)。

  6. 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤 4 的逆向执行。

  7. 恢复步骤 3 中保存的寄存器值,包含主调函数的帧基指针寄存器。

  8. 被调函数将控制权交还主调函数(使用 ret 指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。

  9. 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤 1 之前的值。

步骤 3 与步骤 4 在函数调用之初常一同出现,统称为函数序 (prologue);步骤 6 到步骤 8 在函数调用的最后常一同出现,统称为函数跋 (epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与 CPU 架构和编译器相关。除步骤 5 代表函数实体外,其它所有操作组成函数调用。


回到这次的问题,参考 C 语言的逻辑,猜测原因是这样的:

执行 print 语句时,先调用括号内的 test,函数入栈、执行、出栈,返回值交给 print 打印。所以这一行语句执行后内存空间又回到了原来的状态,没有留下任何痕迹。

又因为 test1test2 两个函数的大小刚好一样,所以两句 print 返回的地址也就一样了。

demo1demo2 两句话是对函数的拷贝构造,执行完之后作为一个对象留了下来,没有被弹出,所以仍然占用着内存地址, print 结果当然就是各自不同的地址了。