当前位置: 云端笔记 » 编程 » Python » 【Python】闭包现象

【Python】闭包现象

闭包就是能够读取其他函数内部变量的函数 当一个嵌套的内部函数引用了外部函数的变量,且外部函数返回了这个内部函数时,就形成了闭包。此时,即使外部函数已经执行完毕,被引用的变量也会被保存在内存中,不会被垃圾回收。

形成闭包的三个必要条件
  • 必须有嵌套函数:函数内部定义了另一个内部函数。
  • 内部函数使用了外部变量:内部函数引用了外层函数的局部变量(非全局变量)。
  • 外部函数返回了内部函数:外层函数将这个内部函数作为返回值返回。

我们知道,函数内声明的变量在函数执行完后就会销毁,而global往往是引用了外部的全局变量,不在我们说的函数内声明局部变量声明范围,示例1

num = 1

num_id = id(num)
print("全局num的id:", num_id)

def f1():
    global num
    num1 = 100
    print("通过global声明的num是全局的那个num吗?", id(num) == num_id)
    print("函数内部的num1:", num1, "id为:", id(num1))

print("------调用函数f1------")
f1()

print("------f1调用后------")
num2 = 100
print("num=", num, "id为", id(num))
print("num=2", num, "id为", id(num2))

运行结果

全局num的id: 4350651320
------调用函数f1------
通过global声明的num是全局的那个num吗? True
函数内部的num1: 100 id为: 4350654488
------f1调用后------
num= 1 id为 4350651320
num=2 1 id为 4350654488

结果显示全局num变量始终同一个,但是num1在函数内部声明为100,地址为4350654488,我们在函数外部将100赋值给num2,结果地址跟函数内部声明的100是一样的。 其实这就是触发了Python 内部存在小整数对象池(-5 到 256)和字符串驻留机制

这里有个猜想,num1是属于不可变量,我们知道全局声明的不可变量会在内存驻留,那函数内部声明的不可变量是否也会驻留呢? 示例2:

def f():
    s1 = "hello"
    b1 = True
    t1 = ("hello", "world")
    f1 = 100.123
    print(f"函数内部s1的id:{id(s1)},函数内部b1的id:{id(b1)},函数内部t1的id:{id(t1)},函数内部f1的id:{id(f1)}")

f()
s2 = "hello"
b2 = True
t2 = ("hello", "world")
f2 = 100.123
print(f"函数外部s2的id:{id(s2)},函数外部b2的id:{id(b2)},函数外部t2的id:{id(t2)},函数外部f2的id:{id(f2)}")

运行结果

函数内部s1的id:4386080640,函数内部b1的id:4383465672,函数内部t1的id:4386155584,函数内部f1的id:4385172688
函数外部s2的id:4386080640,函数外部b2的id:4383465672,函数外部t2的id:4386155584,函数外部f2的id:4385172688

表面上看,我们调用了f()先在函数内部声明了不可变量类型,根据不可变量创建时依据对象池是否已经存在原则,s2,b2,t2,f2赋值时直接拿地址,所有id都一样,一切解释的通,但是其实并不是这样,再看例子:

def f():
    s1 = "hello"
    b1 = True
    t1 = ("hello", "world")
    f1 = 100.123
    print(f"函数内部s1的id:{id(s1)},函数内部b1的id:{id(b1)},函数内部t1的id:{id(t1)},函数内部f1的id:{id(f1)}")

print(f.__code__.co_consts)

运行结果

(None, 'hello', True, ('hello', 'world'), 100.123, '函数内部s1的id:', ',函数内部b1的id:', ',函数内部t1的id:', ',函数内部f1的id:')

我们根本就没有调用f函数,但是里面却已经有了相关值,这个其实在预编译阶段就发生了。 预编译与常量折叠 Python 在正式运行第一行代码之前,有一个“整体通读并编译”的阶段:

  • 当 Python 解释器读入这个 .py 文件时,编译器会一次性把整个文件翻译成字节码。在编译函数 f 时,它敏锐地捕捉到了代码里写死的这些不可变字面量。
  • 提前分配内存:编译器提前在内存中开辟了一块空间,把 None、”hello”、True、(“hello”, “world”) 和 100.123 统一打包,挂载到了函数对象的 co_consts(常量表)属性中。

核心结论:

  • 非纯粹逐行解析:Python 绝不是“读一行翻译一行”。它的执行模式是“先整体预编译,再动态逐行解释执行”。
  • 常驻原因非 GC 失效:后续哪怕你调用并结束了 f(),函数内的这些不可变量也不会被垃圾回收(GC)。原因不是它们有什么常驻特权,而是因为函数的常量表(co_consts)作为全局静态资源,一直牢牢地引用着它们,导致它们的引用计数永远不会归零。
  • 作用域地址合并:正因如此,当你在函数外部写下一模一样的字面量(如 f2 = 100.123)时,Python 编译器在同一个文件的常量池里直接找到了现成的对象,这才促成了我们在前几轮测试中看到的“函数内外 id 完全一致”的内存神话。

是不是只要是不可变量就会有预编译呢? 示例:

def f():
    s1 = "hello"
    b1 = True
    t1 = ("hello", "world")
    f1 = float("100.123")
    print(f"函数内部s1的id:{id(s1)},函数内部b1的id:{id(b1)},函数内部t1的id:{id(t1)},函数内部f1的id:{id(f1)}")

print(f.__code__.co_consts)
运行结果
(None, 'hello', True, ('hello', 'world'), '100.123', '函数内部s1的id:', ',函数内部b1的id:', ',函数内部t1的id:', ',函数内部f1的id:')

我们在函数内部通过float将字符串”100.123″转化为float类型,但是co_consts打印的是字符串,意味着运行到这一行时,Python 必须动态调用 float() 并在堆内存中临时开辟一块空间来存放这个浮点数。

所以预编译其实是这样的: 预编译是 Python 编译器针对“已知、确定、且不可变”的字面量所做的一种空间换时间的优化手段。只要你的不可变变量沾染了“函数调用”、“动态拼接”、“运行时计算”或者“内部包含可变对象”,它就无法享受预编译的红利,只能乖乖在运行期动态创建,并在生命周期结束时被垃圾回收(GC)销毁。

那可变对象是否可预编译进常量表? 都说是常亮量表了,可变怎么可以编译进去呢 独立内存与防污染机制 列表是可变的(Mutable)。这意味着如果你多次调用函数 f(),或者在外部也定义一个相同的列表,Python 必须保证它们在内存中是完全独立、互不干扰的不同的个体。

  • 如果 Python 偷懒,把 [1, 2, 3] 像浮点数那样提前创建好放在常量表里,那么每次调用 f() 时,l1 都会指向同一个列表。
  • 这样的话,一旦某次调用时修改了列表(比如 l1.append(4)),就会彻底污染静态常量表,导致下一次调用 f() 时,l1 莫名其妙变成了 [1, 2, 3, 4]。这显然会带来灾难性的 Bug。
局部变量在函数调用后到底销毁的是什么

我们对局部变量常常是这样描述的: 局部变量(Local Variable)是指在函数(或方法)内部定义的变量,它在函数被调用时才临时创建,在函数执行结束时就会被销毁(内存被垃圾回收)。

这个描述其实没有问题的,但似乎又有问题,我们声明了一个局部变量,得用一个名字表示吧,这就是局部变量名,它指向了一个内存空间。就是变量名引用了一个内存地址,这个变量名在外部是无法使用的,那函数执行完后python做了哪些动作?

  • 将这个变量引用的内存空间的引用计数-1。
  • gc查看这个内存的引用是否为0
    • 为0: 触发回收,内存销毁
    • 不为0: 不销毁

所以局部变量被调用后会取消变量一次引用(-1),是否销毁内存完全是gc的事情

闭包

这里我们终于回到了本文的主题,我们根据闭包的3条件创建一个示例:

def greet(name):
    def say():
        print(hex(id(name)))
        print(f"你好,{name}!")
    return say

# 1. 给它取个名字,打包带走
my_greet = greet("张三")

my_greet()
print(my_greet.__closure__)

运行结果:

0x10569e060
你好,张三!
(<cell at 0x10575a590: str object at 0x10569e060>,)

我们先从变量及引用视角分析下这段代码:

  • 我们在外部、调用一个greet函数,当执行greet()函数后,返回了say函数的内存地址,并赋值给my_greet全局变量,所以say函数在整个代码周期都会有引用。
  • 我们在执行了个greet函数的同时传递了变量”张三”给say函数,say函数会在内存中开辟空间存放这个”张三”,这个是不可变类型,内存中不会销毁,这个也不会触发预编译的静态常量表,这是因为”张三” 是运行期调用 greet(“张三”)动态传入的。
  • 既然”张三”在代码周期一直存在,只要有引用就可以调用,我们在代码中可以多次通过my_greet()打印这个”张三”,那是不是my_greet指向的内存中有对”张三”的引用?,一直记录着这个地址?
def greet(name):
    def say():
        print(f"你好,{name}!")
    return say                  

# 1. 给它取个名字,打包带走
my_greet = greet("张三")

my_greet()
print(my_greet.__closure__)

def greet2():
    pass
print(greet2.__closure__)

运行结果

你好,张三!
(<cell at 0x105592590: str object at 0x1054d6010>,)
None

结果表明

  • my_greet.closure有个元组: (,)
全局变量 my_greet ──(引用)──> [ say 函数对象 ]
                                    │
                         (内部拥有 __closure__ 属性)
                                    │
                                    ▼
                        [ cell 容器对象 ] (物理地址: 0x10575a590)
                                    │
                        (内部持有一根纯 C 语言指针)
                                    │
                                    ▼
                        [ 字符串 "张三" ] (物理地址: 0x10569e060)
  • 普通函数也有closure:但是是None

既然普通函数有closure,闭包函数会实现了这个closure,那么什么情况下会触发这个实现closure?

  • 在编译阶段,编译器如果发现内部函数引用了嵌套作用域的局部变量,就会将其标记为 co_freevars(自由变量)。
  • 到了运行阶段,当外部函数被调用、内部函数对象被实例化返回的一瞬间,Python 虚拟机会触发‘闭包绑定机制’,把装有外部变量指针的 cell 对象打包成元组,直接赋值给该函数对象的 closure 属性 。若无引用,该属性则保持默认的 None。”

一句话:闭包就是外部函数返回了一个内部函数,而内部函数谁用了外部函数的局部变量。本质是内部函数有这个变量的引用

未经允许不得转载:云端笔记 » 【Python】闭包现象

相关文章

评论 (0)

2 + 8 =

contact