lua深入理解闭包

1,637 阅读10分钟

一、闭包的概念

在Lua中,闭包(closure)是由一个函数和该函数会访问到的非局部变量(或者是upvalue)组成的,其中非局部变量(non-local variable)是指不是在局部作用范围内定义的一个变量,但同时又不是一个全局变量,主要应用在嵌套函数和匿名函数里,因此若一个闭包没有会访问的非局部变量,那么它就是通常说的函数。也就是说,在Lua中,函数是闭包一种特殊情况。另外在Lua的C API中,所有关于Lua中的函数的核心API都是以closure来命名的,也可视为这一观点的延续。在Lua中,函数是一种第一类型值(First-Class Value),它们具有特定的词法域(Lexical Scoping)。

在 Lua 中,所有的值都是一等公民,包含函数也是。这就意味着函数可以保存在变量中,当作参数传递,以及作为另一个函数的返回值。比如以下示例代码:

tb[2] = function() print("func") end

其实就是把一个匿名函数,作为table的值存储起来。

在lua中,以下代码中的两个函数动作完全是等价的。不过注意,后者是把函数赋值给一个变量

local function foo print("foo") end
local foo = function print("foo") end

另外,Lua 支持把一个函数写在另外一个函数里面,即嵌套函数,比如下面的示例代码:

local function foo() 
  local i = 1 
  local function bar() 
    i = i + 1 
    print(i) 
  end 
  return bar
end
local fn = foo()
print(fn()) -- 2'

你可以看到, bar 这个函数可以读取函数 foo 里面的局部变量 i,并修改它的值,即使这个变量并不在 bar 里面定义。这个特性叫做词法作用域(lexical scoping)。

事实上,lua的这些特征正是闭包的基础。所谓闭包,简单地理解,它其实是一个函数,不过它访问了另外一个函数词法作用域中的变量。

如果按照闭包的定义来看,Lua 的所有函数实际上都是闭包,即使你没有嵌套。这是因为 Lua 编译器会把 Lua 脚本外面,再包装一层主函数。比如下面这几行简单的代码段:

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

在编译后,就会变为下面的样子:

function main(...)
     local foo, bar
     local function fn()
         foo = 1
         bar = 2
     end
end

而函数 fn 捕获了主函数的两个局部变量,因此也是闭包。

当然,我们知道,很多语言中都有闭包的概念,它并非 Lua 独有,你也可以对比着来加深理解。只有理解了闭包,你才能明白我们接下来要讲的 upvalue。

upvalue 就是 Lua 中独有的概念了。从字面意思来看,可以翻译成 上面的值。实际上,upvalue 就是闭包中捕获的自己词法作用域外的那个变量。还是继续看上面那段代码:

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

你可以看到,函数 fn 捕获了两个不在自己词法作用域的局部变量 foo 和 bar,而这两个变量,实际上就是函数 fn 的 upvalue。

二、upvalue

upvalue,就是所谓的上值,即内嵌函数可以访问上值访问到对应的外部变量(非局部变量)。

Lua使用扁平的一个存储方式表示闭包,GC头部包含了垃圾回收信息,本文并不讨论此部分。接下来,闭包包含了一个指向原型(prototype)的指针[可以理解为java中的类定义,每个java对象都会通过类定义进行生成],原型包含了函数的所有静态信息:主要部分是函数编译之后的代码,其余包括参数个数、调试信息和其他类似的数据,最后,闭包还包含0个或多个指向upvalue的指针,每个upvalue表示一个由闭包使用的非局部变量。

image.png

一个upvalue有两种状态:open和closed。当一个upvalue被创建时,它是open的,并且它的指针指向Lua栈中对应的变量。当Lua关闭了一个upvalue,upvalue指向的值被复制到upvalue结构内部,并且指针也相应进行调整。如下图所示:

image.png

三、共享和关闭upvalue

如果两个闭包需要一个共同的外部变量,每个闭包都会有一个独立的upvalue。当这些upvalue关闭后,每个闭包都包含该共同变量的一个独立的拷贝。当一个闭包修改该变量时,另一个闭包将看不到此修改。

  为避免这个问题,解释器必须确保每个变量最多只有一个upvalue指向它。解释器维护了一个保存栈中所有open upvalue的链表。该链表中upvalue顺序与栈中对应变量的顺序相同。当解释器需要一个变量的upvalue时,它首先遍历这个链表:如果找到变量对应的upvalue,则复用它,因此确保了共享;否则创建一个新的upvalue并将其链入链表中正确的位置。

image.png

由于upvalue链表是有序的,且每个变量最多有一个对应的upvalue,因此当在链表中查找变量的upvalue时,遍历元素的最大数量是静态确定的。最大数量是逃往(escape to)内层闭包的变量个数和在闭包和外部变量之间声明的变量个数之和。例如,以下的代码段:

function foo ()
  local a, b, c, d
  local f1 = function () return d + b end
  local f2 = function () return f1() + a end
  ...

当解释器初始化f2时,解释器在确定a没有对应的upvalue之前会遍历3个upvalue,按顺序分别是f1、d和b。

  当一个变量退出作用域时,它所对应的upvalue(如果有)必须被关闭。open upvalue链表也被用于关闭upvalue。当Lua编译一个包含逃离的变量(被作为upvalue)的块时,它在块的末尾生成一个CLOSE指令,该指令“关闭到某一层级(level)为止的upvalue”。执行该指令时,解释器遍历open upvalue链表直到到达给定层级为止,将栈中变量值复制到upvalue中,并将upvalue从链表中移除。

image.png

为描述open upvalue链表如何确保upvalue共享,考虑如下的代码段:

local a = {} -- an empty array
local x = 10
for i = 1, 2 do
  local j = i
  a[i] = function () return x + j end
end
x = 20

在代码段开头,open upvalue链表是空的。因此,当解释器在循环中创建第一个闭包时,它会为x和j创建upvalue,并将其插入upvalue链表中。在循环体的末尾有一条CLOSE指令标识j退出了作用域,当解释器执行这条指令时,它关闭j的upvalue并将其从链表移除。解释器在第二次迭代中创建闭包时,它找到x的upvalue并复用,但找不到j的upvalue,因此创建一个新的upvalue。在循环体末尾,解释器再一次关闭j的upvalue。

  在循环结束之后,程序中包含两个闭包,这两个闭包共享一个x的upvalue,但每个闭包有一个独立的j的拷贝。x的upvalue是开启的,即x的值仍在栈中。因此最后一行的赋值(x=20)改变了两个闭包使用的x值。

四、编译器

所有的局部变量都保存在寄存器中,当编译器看到一个局部变量定义时,它为该变量分配一个新的寄存器。在当前函数内,所有对该变量的访问都会生成引用对应的寄存器指令,即使该变量在之后逃亡内层函数。

编译器递归地编译内层函数。当编译器看到一个内层函数时,它停止为当前函数生成指令,并为新的函数生成完整的指令。在编译内层函数时,编译器为该函数需要的所有upvalue(外部变量)创建一个表;在最终生成的闭包结构中也反映了这张表。当函数结束时,编译器返回外层函数,并生成一条创建带有所需upvalue的闭包的指令。

查找变量的过程是递归的,因此当一个变量被加入当前函数的upvalue表中时,该变量或是直接外层函数的局部变量,或已被加入外层函数的upvalue表。作为示例,考虑图6代码中函数C的return语句的编译。编译器在当前局部变量表找到变量c,因此将其作为局部变量处理。编译器在当前函数找不到g,因此在它的外层函数B中查找,接着递归地在函数A和包裹程序块的匿名函数中查找;最终没有找到g,因此编译器将其作为全局变量处理。编译器在当前函数中找不到变量b,因此在函数B中查找;在函数B的局部变量表中找到b,接着在C的upvalue表中加入b。最后,编译器在当前函数中找不到a,因此在B中查找;在B中也没有找到,紧接着在A中查找;因此,编译器将a加入B的upvalue表和C的upvalue表。

function A (a)
  local function B(b)
    local function C(c)
      return c + g + b + a
     end
   end
end

upvalue表中的每个条目都包括一个标志和一个索引。标志标识变量是否是外层函数的局部变量。若变量是外层函数的局部变量,则索引表示它在活动记录中的寄存器位置;若变量是外层函数的upvalue,则索引表示它在upvalue表中的位置。在两种情况下,条目中都包含足够的信息用以定位外层函数的变量。

在以上所示的例子中,在编译即将结束时,函数C的upvalue表中包含两个upvalue:第一个(b)是外层函数的局部变量,其索引为1,因为它将保存在函数B的寄存器1中;第二个(a)是外层函数的upvalue,它的索引也为1,因为它是函数B的第一个(也是唯一一个)upvalue。

闭包总结

  1. 闭包的主要作用有两个,一是简洁,不需要在不使用时生成对象,也不需要函数名;二是捕获外部变量形成不同的调用环境

    function f1()
        local i = 0
    ​
        return function()
            i = i + 1
            return i
        end
    end
    ​
    g1 = f1()
    g2 = f1()
    ​
    print("g1 " .. g1())
    print("g1 " .. g1())
    ​
    print("------------------------")
    ​
    print("g2 " .. g2())
    print("g2 " .. g2())
    ​
    $ lua test.lua 
    > g1 1
    > g1 2
    > ------------------------
    > g2 1
    > g2 2
    
    1. 闭包原理概述:

      • 闭包(函数)编译时会生成原型(prototype),包含参数、调试信息、虚拟机指令等一系列该闭包的源信息,其中在递归编辑内层函数时,会为内层函数生成指令,同时为该内层函数需要的所有upvalue创建表,以便之后调用时进行upvalue值搜索
      • 在lua中,会生成一个全局栈,所有的upvalue都会指向该栈中的值,若对应的参数离开的作用域,栈中的值也会被释放,upvalue的指针会指向自己,等待被gc
      • 闭包运行时,会通过创建指向upvalue的指针,并循环upvalue linked list,找到所需要的外部变量进行运行

欢迎大家沟通指正!

参考资料

www.cs.tufts.edu/~nr/cs257/a…

blog.csdn.net/MaximusZhou…

极客时间 -《OpenResty从入门到实战》