Upvalue的逃逸和闭包

上一节介绍了Upvalue的概念,并以Upvalue最基本的用法为例介绍了为支持Upvalue而对语法分析做的改造。本节就来介绍Upvalue的完整特性,主要是Upvalue的逃逸。

下面参考《Lua程序设计》书中的示例代码:

  1. local function newCounter()
  2. local i = 0
  3. return function ()
  4. i = i + 1 -- upvalue
  5. print(i)
  6. end
  7. end
  8. local c1 = newCounter()
  9. c1() -- 输出:1
  10. c1() -- 输出:2

上述代码中的newCounter()是个典型的工厂函数,它创造并返回一个匿名函数。这里需要说明的是,返回的匿名函数引用了newCounter()中的局部变量i,也就是Upvalue。 下半段代码调用newCounter()函数并把返回的匿名函数赋值给c1并调用。此时newCounter()函数已经结束,看上去其中定义的局部变量i也已经超出了作用范围,此时再调用c1去引用局部变量i会出问题(如果你是C语言程序员应该能明白这个意思)。然而在Lua中,闭包的机制保证了这里调用c1是没问题的。也就是Upvalue的逃逸。

《Lua程序设计》这本书是面向Lua程序员的,介绍到Upvalue逃逸的概念就足够了。但我们的目的是要实现一个解释器(而不只是使用解释器),所以不仅要知道这样是没问题的,还要知道如何来做到没问题,即如何实现Upvalue的逃逸。

不可行的静态存储方案

最简单的办法是参考C语言中函数内部的static变量,对于被Upvalue引用的局部变量(比如这里newCounter()函数中的i)并不放到栈上,而是放到一个静态的区域。但是这个方案并不可行,因为C语言中的static变量是全局唯一的,而Lua中的Upvalue是每次调用都会生成一份新的。比如接着上面的代码,继续如下代码:

  1. local c2 = newCounter()
  2. c2() -- 输出:1。新的计数开始。
  3. c1() -- 输出:3。继续上面c1的输出。

再次调用newCounter(),会生成一个新的计数器,其中的局部变量i会重新初始化为0,重新开始计数。此时就存在两个计数器:c1c2,两者各自拥有独立的局部变量i。于是当调用c2()的时候,会从1开始重新计数;如果穿插调用之前的c1()也会继续之前的计数。多么有趣!

  1. --+---+-- +---+ +---+
  2. | i | | i | | i |
  3. --+-^-+-- +-^-+ +-^-+
  4. | | |
  5. /---+---\ | |
  6. | | | |
  7. +----+ +----+ +----+ +----+
  8. | c1 | | c2 | | c1 | | c2 |
  9. +----+ +----+ +----+ +----+

上面左图展示的是把i放到全局唯一的静态存储,那么所有计数器函数指向唯一的i。这是不满足我们需求的。我们需要的是右图所示,每个计数器函数都有独立的i

堆上存储方案

既然不能放在栈上,也不能放在全局静态区,那么就只能放在堆上了。接着的问题是,什么时候放到堆上?有几个可能的方案:

  1. 刚进入到函数时,就把所有被Upvalue引用的局部变量放到堆上;
  2. 当一个局部变量被Upvalue引用时,才从栈上挪到堆上;
  3. 当函数退出时,把所有被Upvalue引用的局部变量挪到堆上;

第1个方案不行,因为一个局部变量在被Upvalue引用前,可能已经被作为局部变量使用过了,已经生成了相关的字节码。第2个方案应该是可行的,但是局部变量在被Upvalue引用后,后续还可能在当前函数内被作为局部变量使用,提前挪到堆上并没有必要,毕竟对栈的访问更快更方便。所以我们选择第3个方案。

这种把局部变量从栈上挪到堆上的操作,我们遵循Lua官方实现的代码,也称之为“关闭close”。

接下来,为了演示一个Upvalue分别在逃逸前和逃逸后被访问的情况,在上面的计数器示例代码的基础上改造下,在内部的匿名函数返回前,先在newCounter()函数内部调用一次。为此,需要把这个匿名函数赋值给一个局部变量retf

  1. local function newCounter()
  2. local i = 0
  3. local function retf()
  4. i = i + 1 -- upvalue
  5. print(i)
  6. end
  7. retf() -- newCounter()内部调用
  8. return retf -- 返回retf
  9. end

分两部分介绍这个例子,先是在工厂函数内部调用retf(),再是工厂函数返回retf造成的Upvalue逃逸。

首先,在工厂函数内部调用retf()的时候,retf要操作的i仍然还在栈上。示意图如下。

  1. | |
  2. +----------+
  3. base |newCounter|
  4. +----------+
  5. 0 | i |<- - - - - - - - - - \
  6. +----------+ |
  7. 1 | retf +--+->+-FuncProto-----+--+
  8. +----------+ | |byte_codes: | |
  9. 2 | retf +--/ | GetUpvalue(0, 0) |
  10. +----------+ | ... |
  11. | | +------------------+

图中,左边是栈,其中newCounter是函数调用入口,也是当前函数的base位置,后面i和第一个retf是局部变量,第二个retf是函数调用的栈上入口,两个retf指向同一个函数原型。retf函数原型中的字节码序列中,第一个字节码GetUpvalue是把Upvalue i加载到栈上以执行加法。这个字节码有两个关联参数。第1个是加载到栈上的目标地址,这里忽略;第2个是Upvalue的源地址,参考上一节中对Upvalue的语法分析,这个参数的含义是:上一层函数的局部变量的栈索引。在这个例子里,就是i在newCounter()函数中的索引,也就是0。到此为止还都是上一节的内容,还没涉及到逃逸。

现在考虑Upvalue的逃逸。在newCounter()函数退出后,左边的栈上的3个空间都会销毁,i也就不存在了。为了retf函数后续可以继续访问i,那在newCounter()函数退出前,需要关闭局部变量i,把i从栈上挪到堆上。示意图如下:

  1. | |
  2. +----------+
  3. base |newCounter| +===+
  4. +----------+ close | i |<- - - \
  5. 0 | i +-------->+===+ ?
  6. +----------+ ?
  7. 1 | retf +---->+-FuncProto-----?--+
  8. +----------+ |byte_codes: ? |
  9. | | | GetUpvalue(0, 0) |
  10. | ... |
  11. +------------------+

这样虽然保证了i可以被继续访问,但是有个非常明显的问题:字节码GetUpvalue关联的第2个参数不能定位到堆上的i了(图中连续的?线)。这也就是在上一节里提到的,直接用局部变量在栈上的索引来表示Upvalue的方案,是不可行的。需要在这个方案基础上做改进。

改进:Upvalue中介

为了在关闭局部变量后仍然可以被Upvalue访问到,我们需要一个Upvalue的中介,开始时还是用栈上索引来表示Upvalue,而当外层函数的局部变量被关闭后,就挪到这个中介里。

下面两个图展示了在加上Upvalue中介后的情况。

  1. | | - - - - - - \
  2. +----------+ | |
  3. base |newCounter| | *-----+-+---
  4. +----------+ | |Open(0)|
  5. 0 | i |<- - - - - - *-^-----+---
  6. +----------+ |
  7. 1 | retf +--+->+-FuncProto-----+--+
  8. +----------+ | |byte_codes: | |
  9. 2 | retf +--/ | GetUpvalue(0, 0) |
  10. +----------+ | ... |
  11. | | +------------------+

上图是在newCounter()函数内部调用retf()函数的示意图。对比之前的版本,增加了Upvalue中介列表(图中以*为角的列表),并只有1个成员:Open(0),代表这个局部变量还没关闭,并且是在栈上的相对索引是0。而retf的函数原型中,字节码GetUpvalue关联的第2个参数虽然没有变,但其含义变了,变成了中介列表的索引。只不过这个例子中恰巧也是0而已。

  1. | | /----------------\
  2. +----------+ | |
  3. base |newCounter| | *-------V-+---
  4. +----------+ close | |Closed(i)|
  5. 0 | i +----------/ *-^-------+---
  6. +----------+ |
  7. 1 | retf +---->+-FuncProto-----+--+
  8. +----------+ |byte_codes: | |
  9. | | | GetUpvalue(0, 0) |
  10. | ... |
  11. +------------------+

上图是newCounter()函数返回前,关闭局部变量i之后的示意图。上图中增加的Upvalue中介列表中的成员,变成了Closed(i),即把局部变量i挪到这个中介列表中。这样GetUpvalue仍然可以定位到第0个Upvalue中介,并访问关闭后的i

改进:共享Upvalue

上述方案可以支持目前简单的逃逸情景了,但是不支持多个闭包共享同一个局部变量的情景。比如下面示例代码:

  1. local function foo()
  2. local i, ip, ic = 0, 0, 0
  3. local function producer()
  4. i = i + 1
  5. ip = ip + 1
  6. end
  7. local function consumer()
  8. i = i - 1
  9. ic = ic + 1
  10. end
  11. return produce, consume
  12. end

上述的foo()函数返回的两个内部函数都引用了局部变量i,而且很明显这两个函数是要共享i,要操作同一个i,而不是各自独立的i。那当foo()函数结束关闭i后,就需要两个函数来共享关闭后的i了。由于这两个函数拥有不同的Upvalue列表,分别是i, ipi, ic,所以两个函数不用共享同一个Upvalue列表。那就只能针对每个Upvalue单独共享了。

下图是单独共享每个Upvalue的方案:

  1. | |
  2. +----------+ +=======+
  3. base | foo | |Open(0)|<===============+------------\
  4. +----------+ +=======+ | |
  5. 0 | i |<- -/ +=======+ | |
  6. +----------+ |Open(1)|<-------------|---\ |
  7. 1 | ip |<- - - +=======+ | | |
  8. +----------+ +=======+ | | |
  9. 2 | ic |<- - - - |Open(2)|<-----------|---|--------|---\
  10. +----------+ +=======+ *-+-+-+-+-- | |
  11. 3 | producer +---->+-FuncProto--------+ | i |ip | | |
  12. +----------+ |byte_codes: | *-^-+-^-+-- | |
  13. 4 | consumer +--\ | GetUpvalue(0, 0)-+----/ | | |
  14. +----------+ | | ... | | | |
  15. | | | | GetUpvalue(0, 1)-+---------/ | |
  16. | | ... | | |
  17. | +------------------+ | |
  18. | *-+-+-+-+--
  19. \-------------->+-FuncProto-------+ | i |ic |
  20. |byte_codes: | *-^-+-^-+--
  21. | GetUpvalue(0, 0)-+-----/ |
  22. | ... | |
  23. | GetUpvalue(0, 1)-+----------/
  24. | ... |
  25. +------------------+

上图略显复杂,但大部分跟之前的方案是一样的。最左边仍然是栈。然后看producer()函数指向的内容仍然是函数原型和对应的Upvalue列表。由于这个函数用到了两个Upvalue,所以列出了两条字节码。然后就是不一样的地方了:对于的Upvalue列表中,并不直接是Upvalue,而是Upvalue的地址。而真正的Upvalue是单独在堆上分配的,也就是图中的Open(0)Open(1)Open(2)。这3个Upvalue通过索引可以访问栈上的局部变量。最后的consumer()函数类似,区别是引用了不同的Upvalue。

foo()函数结束,关闭所有Upvalue引用的局部变量时,上图中的Open(0)Open(1)Open(2)分别被替换为Closed(i)Closed(ip)Closed(ic)。此时,producer()consumer()函数对应的Upvalue列表中都有的i指向的是同一个Closed(i)。如此一来,在外层foo()函数退出后,这两个函数仍然可以访问同一个i了。只是替换3个Upvalue,改动相对较小,这里就省去关闭后的图了。

闭包的定义

在继续介绍更多的Upvalue使用场景前,我们先基于上述方案,引入闭包的概念。

依照上面的方案,返回的retf并不只是一个函数原型了,还要包括对应的Upvalue列表。而函数原型加上Upvalue,就是闭包!在Value中增加Lua闭包类型:

  1. pub enum Upvalue { // 上图中的Upvalue中介
  2. Open(usize),
  3. Closed(Value),
  4. }
  5. pub struct LuaClosure {
  6. proto: Rc<FuncProto>,
  7. upvalues: Vec<Rc<RefCell<Upvalue>>>,
  8. }
  9. pub enum Value {
  10. LuaFunction(Rc<FuncProto>), // Lua函数
  11. LuaClosure(Rc<LuaClosure>), // Lua闭包

这样一来,多次调用newCounter()函数返回的不同闭包,虽然共享同一个函数原型,但是各自拥有独立的Upvalue。这也是本节开头两个计数器c1、c2可以独立计数的原因。

下图展示两个计数器的示意图:

  1. +-LuaClosure--+
  2. | | | proto-+----------------------+-->+-FuncProto--------+
  3. +------+ | upvalues-+--->+---+-- | |byte_codes: |
  4. | c1 +---->+-------------+ | i | | | GetUpvalue(0, 0) |
  5. +------+ +-+-+-- | | ... |
  6. | c2 +-\ | | +------------------+
  7. +------+ | V=========+ |
  8. | | | |Closed(i)| |
  9. | +=========+ |
  10. \-->+-LuaClosure--+ |
  11. | proto-+----------------------/
  12. | upvalues-+--->+---+--
  13. +-------------+ | i |
  14. +-+-+--
  15. |
  16. V=========+
  17. |Closed(i)|
  18. +=========+

类似的,我们也改造下上面共享Upvalue例子中的示意图。清晰起见,删掉FuncProto的具体内容;然后把函数原型和Upvalue列表合并为LuaClosure。如下图。

  1. | |
  2. +----------+ +=======+
  3. base | foo | |Open(0)|<========+----------\
  4. +----------+ +=======+ | |
  5. 0 | i |<- -/ +=======+ | |
  6. +----------+ |Open(1)|<------|---\ |
  7. 1 | ip |<- - - +=======+ | | |
  8. +----------+ +=======+ | | |
  9. 2 | ic |<- - - - |Open(2)|<----|---|------|---\
  10. +----------+ +=======+ | | | |
  11. 3 | producer +---->+-LuaClosure--+ | | | |
  12. +----------+ | proto | | | | |
  13. 4 | consumer +--\ | upvalues -+>*-+-+-+-+-- | |
  14. +----------+ | +-------------+ | i |ip | | |
  15. | | | *---+---+-- | |
  16. | | |
  17. \------------>+-LuaClosure--+ | |
  18. | proto | | |
  19. | upvalues -+>*-+-+-+-+--
  20. +-------------+ | i |ic |
  21. *---+---+--

由图可见,相比于上一章定义的Lua函数LuaFunction而言,闭包LuaClosure虽然可以拥有独立的Upvalue列表,但是多了一次内存分配和指针跳转。这里就面临一个选择:是用闭包完全代替函数,还是两者并存?Lua官方实现中是前者,这也是“Lua中所有函数都是闭包”这句话的来源。代替的好处是少一个类型,代码稍微简单一点点;并存的好处是函数类型毕竟少分配一点内存少一次指针跳转。除了这两点个优缺点之外,还有一个更大的影响行为的区别。比如下面的示例代码:

  1. local function foo()
  2. return function () print "hello, world!" end
  3. end
  4. local f1 = foo()
  5. local f2 = foo()
  6. print(f1 == f2) -- true or false

这里调用foo()返回的匿名函数不包括Upvalue。那么问题来了,两次调用foo()的两个返回值相等吗?

  • 如果保留LuaFunction类型,那么返回值就是LuaFunction类型,f1f2就只涉及函数原型,就是相等的。可以用上一章的代码执行验证。

  • 如果不保留LuaFunction类型,那么返回的函数就是LuaClosure类型。虽然不包含Upvalue,但是也是两个不同的闭包,f1f2就是不等的。

那么上述哪个行为是符合Lua语言要求的呢?答案是:都可以。Lua手册中对函数比较的描述如下:

Functions created at different times but with no detectable differences may be classified as equal or not (depending on internal caching details).

也就是无所谓,并没有对此做规定。那我们也就可以随便选择了。这个项目里,我们最开始是选择闭包代替函数的,后来又把函数类型加了回来。感觉区别不大。

闭包的语法分析

之前没有闭包,还是LuaFunction的时候,对于函数定义的处理很直观:

  • 解析函数定义,并生成函数原型FuncProto;
  • 把FuncProto用Value::LuaFunction包装下,放到常量表里;
  • 生成LoadConst等读取常量表的字节码。

对函数定义的处理,就跟其他类型的常量的处理方式类似。回忆一下,相关代码如下:

  1. fn funcbody(&mut self, with_self: bool) -> ExpDesc {
  2. // 省略准备工作
  3. // chunk()函数返回的proto就是FuncProto类型
  4. let proto = chunk(self.lex, has_varargs, params, Token::End);
  5. ExpDesc::Function(Value::LuaFunction(Rc::new(proto)))
  6. }
  7. fn discharge(&mut self, dst: usize, desc: ExpDesc) {
  8. let code = match desc {
  9. // 省略其他类型
  10. // 把函数原因加入到常量表中,并生成LoadConst字节码
  11. ExpDesc::Function(f) => ByteCode::LoadConst(dst as u8, self.add_const(f) as u16),

现在为了支持闭包,需要做如下改进:

  • 相关Value类型定义改成了LuaClosure(Rc<LuaClosure>),所以解析出的函数原型FuncProto没法直接放到常量表里了。虽然也能间接放,但不直观。不如在函数原型FuncProto中再加个表专门保存内层函数的原型列表。

  • 虚拟机执行函数定义时,除了函数原型外还要生成Upvalue。那么类似LoadConst之类的直接读取常量表的字节码也不满足需求了。需要新增一个专门的字节码,把函数原型和生成的Upvalue聚合为闭包。

  • 另外,在生成Upvalue的时候,需要知道这个函数用到了上层函数的哪些局部变量。所以函数原型中还要新增Upvalue引用上层局部索引的列表。

综上,新增的创建闭包的字节码如下:

  1. pub enum ByteCode {
  2. Closure(u8, u16),

这个字节码关联的两个参数类似LoadConst字节码,分别是栈上目标地址,和内部函数原型列表inner_funcs的索引。

另外,需要在函数原型中新增两个成员如下:

  1. pub struct FuncProto {
  2. pub upindexes: Vec<usize>,
  3. pub inner_funcs: Vec<Rc<FuncProto>>,

其中inner_funcs是函数内部定义的内层函数的原型列表。upindexes是当前函数引用上层函数的局部变量的索引,这个成员后续需要改造。需要说明的是,inner_funcs是当前函数作为外层函数的角色时使用的,而upindexes是当前函数作为内层函数的角色时使用的。

我们在后面介绍完Upvalue完整的特性后,再来介绍对Upvalue索引upindexes的解析。

在介绍完闭包的定义和语法分析后,接着看Upvalue的其他场景。

改进:对Upvalue的引用

之前介绍的Upvalue都是对上层函数的局部变量的引用,现在来看对上层函数的Upvalue的引用。对本节最开头的计数闭包示例做下改造,把递增的代码i = i + 1再放到一层函数中:

  1. local function newCounter()
  2. local i = 0
  3. return function ()
  4. print(i) -- upvalue
  5. local function increase()
  6. i = i + 1 -- where does `i` refer?
  7. end
  8. increase()
  9. end
  10. end
  11. local c1 = newCounter()
  12. c1()

这个示例中newCounter()函数返回的匿名函数的第1行print语句中的i是之前已经介绍的普通的Upvalue,指向上层函数的局部变量。而内部函数increase()函数中的i是什么?也是Upvalue。那这个Upvalue是对谁的引用呢?

可以看做是对最外层newCounter()函数中局部变量i跨层 引用吗?不可以,因为虚拟机执行时不能实现。当匿名函数返回时,内部的increase()函数还没有创建;只有当在更外面调用匿名函数时,内部的increase()函数才会被创建并执行;而此时最外层的newCounter()已经结束了,其中的局部变量i也已经不存在了,也就无法对其进行引用了。

既然不能是对最外层newCounter()函数中局部变量i跨层 引用,那就只能是对外层的匿名函数中的Upvalue i的引用了。

为了支持对Upvalue的引用,首先,要修改刚才FuncProto中Upvalue列表的定义,从只支持局部变量修改为也支持Upvalue:

  1. pub enum UpIndex {
  2. Local(usize), // index of local variables in upper functions
  3. Upvalue(usize), // index of upvalues in upper functions
  4. }
  5. pub struct FuncProto {
  6. pub upindexes: Vec<UpIndex>, // 从usize修改为UpIndex
  7. pub inner_funcs: Vec<Rc<FuncProto>>,

然后,来看上面例子中,在执行返回的匿名函数计数器c1时,在调用内部increase()函数时的示意图:

  1. | |
  2. +----------+
  3. | c1 +-------------------->+-LuaClosure--+
  4. +----------+ | proto |
  5. | increase | | upvalues +--->+---+--
  6. +----------+ +-------------+ | i |
  7. | increase +-->+-LuaClosure--+ +-+-+--
  8. +----------+ | proto | |
  9. | | | upvalues +--->+---+-- |
  10. +-------------+ | i | |
  11. +-+-+-- V=========+
  12. \---------------->|Closed(i)|
  13. +=========+

左边是栈。其中c1是函数调用入口,对应的闭包中包含的Upvalue i是print语句中引用的。

栈的下面第一个increasec1中的局部变量。第二个increase是函数调用入口,对应的闭包中包含的Upvalue i是执行递增操作的语句中引用的。在函数原型中,这个Upvalue应该对应上层函数的第0个Upvalue,即UpIndex::Upvalue(0),所以在虚拟机执行生成这个闭包时,这个Upvalue就指向c1的第0个Upvalue,即图中的Closed(i)。这样一来,在这个函数中对i的递增操作也会反应在c1函数的print语句中了。

改进:跨多层函数引用

再来看另外一种场景:跨层引用。稍微修改一下上面的用例,把print语句放到递增操作之后,得到下面的示例代码:

  1. local function newCounter()
  2. local i = 0
  3. return function ()
  4. local function increase()
  5. i = i + 1 -- upvalue of upper-upper local
  6. end
  7. increase()
  8. print(i) -- upvalue
  9. end
  10. end

这个例子跟上面那个例子的区别在于,在解析到increase()函数的时候,要返回的匿名函数中还没有生成Upvalue i,那么increase()函数中的i要指向谁?总结下之前的Upvalue类型:要么是指向上层函数的局部变量,要么是上层函数的Upvalue,并且分析了不能跨多层函数引用。所以,只有一个解决办法了:在中间层函数创造一个Upvalue。这个Upvalue在当前函数(暂时)并不使用,只是用来给内层函数引用。

刚才说的当前函数“暂时”不使用这个创造出来的Upvalue,是指的在语法分析解析到内部函数的时候,暂时还不使用。在后续的解析过程中,还是可能用上的。比如上面这个例子后,后面的print语句就用到了这个Upvalue。

这个例子里,两个函数的原型和执行时的示意图,都跟上面的例子一样。这里省略。

至此,终于介绍完全部的Upvalue特性,并给出最终的方案。这期间也对语法分析和虚拟机执行部分有所涉及,下面根据最终方案,再对语法分析和虚拟机执行做简单的整理。

Upvalue索引的语法分析

上面在介绍闭包的语法分析时,指出在函数原型FuncProto中需要新增成员upindexes来表示当前函数的Upvalue索引。

上一节中,罗列了变量的解析流程:

  1. 在当前函数的局部变量中匹配,如果找到则是局部变量;
  2. 在上层函数的局部变量中匹配,如果找到则是Upvalue;
  3. 否则是全局变量。

根据本节前面对Upvalue完整特性的介绍,对上述的第2步扩展更详细的Upvalue索引的解析步骤,变量解析的最终流程如下:

  1. 在当前函数的局部变量中匹配,如果找到则是局部变量;
  2. 在当前函数的Upvalue列表中匹配,如果找到则是已有Upvalue;(复用Upvalue)
  3. 在外层函数的局部变量中匹配,如果找到则新增Upvalue;(普通Upvalue)
  4. 在外层函数的Upvalue中匹配,如果找到则新增Upvalue;(对上层函数中Upvalue的引用)
  5. 在更外层函数的局部变量中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的引用)
  6. 在更外层函数的Upvalue中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的Upvalue的引用)
  7. 循环上述第5,6步,如果到达最外层函数仍未匹配,则是全局变量。

这个流程中明显有很多重复的地方。最明显的是第3,4步是第5,6步的特殊情况,即没有中间层函数的情况,所以可以去掉3,4步。另外在代码实现时,第1,2步也可以作为特殊情况而省略。由于本节内容太多,这里就不贴具体代码了。

上一节的语法分析,为了支持Upvalue需要访问上层函数的局部变量列表,所以新增了上下文ParseContext数据结构,包含各级函数的局部变量列表。本节介绍了Upvalue也可以引用上层函数的Upvalue,所以就也需要在ParseContext中增加各级函数的Upvalue列表。

  1. struct ParseContext<R: Read> {
  2. all_locals: Vec<Vec<String>>,
  3. all_upvalues: Vec<Vec<(String, UpIndex)>>, // 新增
  4. lex: Lex<R>,
  5. }
  6. pub struct FuncProto {
  7. pub upindexes: Vec<UpIndex>,

上面代码中,ParseContext是语法分析上下文,是语法分析的内部数据结构,其Upvalue列表all_upvalues的成员类型是(String, UpIndex),其中String是Upvalue变量名,用来上面第2,4,6步的匹配;UpIndex是Upvalue的索引。

FuncProto是语法分析阶段的输出,是交由虚拟机执行阶段使用的,此时并不需要Upvalue变量名了,只需要UpIndex索引即可。

虚拟机执行

本节前面部分在介绍Upvalue设计方案的时候,基本是按照虚拟机执行阶段来介绍的,这里再完整过一遍。

首先,是创建闭包,也就是定义函数。为此引入了新的字节码ByteCode::Closure,其职责是生成Upvalue,并将其和函数原型一起打包为闭包,并加载到栈上。

这里需要说明的是,在语法分析阶段为了能访问上层函数的局部变量,需要引入ParseContext上下文;但是,在虚拟机执行阶段,Upvalue虽然也要访问上层函数的栈空间,但是并不需要做语法分析那样类似的改造。这是因为在创建闭包的时候,Upvalue列表是由外层函数生成并传入闭包的,内层函数就可以通过Upvalue列表间接访问外层函数的栈空间。

另外,生成的Upvalue列表除了传入闭包外,外层函数自己也需要把列表维护起来的,目的有二:

  • 上面共享Upvalue部分有介绍,如果一个函数中包含多个闭包,那么这些闭包的Upvalue是要共享局部变量的。所以,在创建Upvalue的时候,要先检查这个局部变量关联的Upvalue是否已经创建了。如果是,则共享;否则才创建新的。

    这里有个小问题,这个是否已经创建的检查是在虚拟机执行阶段进行的。Upvalue列表一般不会很多,所以不至于使用hash表,而使用Vec的话这个匹配检查的时间复杂度就是O(n),当Upvalue很多的时候这里可能影响性能。那能不能把这个匹配检查放在语法分析阶段呢?下一节再详细介绍这个问题。

  • 外层函数在退出的时候,需要关闭Upvalue。

    需要说明的是,理论上讲,只需要关闭逃逸的Upvalue;而不需要关闭没有逃逸的Upvalue。但是,在语法阶段确定一个Upvalue是否逃逸是非常困难的,除了上述例子中比较明显的把内部函数作为返回值的逃逸情况,还有比如把内部函数赋值给一个外部的表这种情况。在虚拟机阶段判断是否逃逸也很麻烦。所以为了简单起见,我们这里参考Lua官方实现,在函数结束时关闭所有Upvalue,而不区分是否逃逸。

关闭Upvalue的时机是所有函数退出的地方,包括ReturnReturn0TailCall字节码。具体的关闭代码这里就省略了。

小节

本节介绍了Upvalue的逃逸,并新增了闭包类型。但主要介绍的都是如何设计和管理Upvalue,而并没有讲具体的操作,包括如何创建、读写、和关闭Upvalue。不过设计方案讲明白后,这些具体的操作相对也就很简单了。本节篇幅已经很长了,就省略这部分的介绍和代码。

Rust的DST

现在介绍Rust语言的一个特征,DST。

本节前面对闭包数据结构LuaClosure的定义如下:

  1. pub struct LuaClosure {
  2. proto: Rc<FuncProto>,
  3. upvalues: Vec<Rc<RefCell<Upvalue>>>,
  4. }

这里忽略其中的函数原型proto字段,而只关注Upvalue列表upvalues字段。为了能存储任意个Upvalue,这里的upvalues定义为一个列表Vec。这样就需要再额外分配一段内存。整个闭包的内存布局如下:

  1. +-LuaClosure--+
  2. | proto |
  3. | upvalues: | Upvalue列表
  4. | ptr --+--->+------+------+-
  5. | capacity | | | |
  6. | length | +------+------+-
  7. +-------------+

上图中,左边是闭包LuaClosure,其中ptr指向的右边额外的内存,是Upvalue列表Vec的实际存储空间。这样额外再分配一段内存的缺点有三个:

  • 浪费内存,每一段内存都需要额外的管理空间和因为对齐造成的浪费;
  • 在申请内存时,需要多执行一次分配,影响性能;
  • 在访问Upvalue时,需要多一次指针跳转,也影响性能。

而对于这种不定长数组的需求,在C语言中经典的做法是:在数据结构中定义零长数组,然后在实际分配内存的时候按需指定实际长度。示例代码如下:

  1. // 定义数据结构
  2. struct lua_closure {
  3. struct func_proto *proto;
  4. int n_upavlue; // 实际个数
  5. struct upvalue upvalues[0]; // 零长数组
  6. }
  7. // 申请内存
  8. struct lua_closure *c = malloc(sizeof(struct lua_closure) // 基本空间
  9. + sizeof(struct upvalue) * n_upvalue); // 额外空间
  10. // 初始化
  11. c->n_upvalue = n_upvalue;
  12. for (int i = 0; i < n_upvalue; i++) {
  13. c->upvalues[i] = ...
  14. }

相应的内存布局如下:

  1. +-------------+
  2. | proto |
  3. | n_upvalue |
  4. : : \
  5. : : + Upvalue列表
  6. : : /
  7. +-------------+

这种做法可以避免上述的3个缺点。那在Rust中能这么做吗?比如如下定义:

  1. pub struct LuaClosure {
  2. proto: Rc<FuncProto>,
  3. upvalues: [Rc<RefCell<Upvalue>>], // slice
  4. }

这个定义中,upvalues的类型从列表Vec变成了slice []。好消息是Rust是支持DST类型(即这里的slice)作为数据结构的最后一个字段的,也就是说上述定义是合法的。坏消息是,这样的数据结构没法初始化。一个没法初始化的数据结构,自然也就没法使用了。引用The Rustonomicon的话就是:custom DSTs are a largely half-baked feature for now。

我们可以想想为什么没法初始化?比如RcRc::new_uninit_slice() API可以创建slice,那能不能加一个类似的API来创建这种包含slice的数据结构?另外,还可以参考dyn_struct

不过,即便可以初始化了,上述数据结构的定义可以使用了,但是还会有另外一个问题:由于upvalues字段是DST,那么整个LuaClosure也跟着变成了DST,所以指针也就会变成胖指针,要包括slice的实际长度,Rc<LuaClosure>就变成了2个word,进而导致enum Value从2个word变成3个word。这也就不满足我们的要求了,就跟之前不能使用Rc<str>来定义字符串类型一样。

既然不能用slice,那有没有其他解决办法?可以用固定长度的数组。比如修改定义如下:

  1. enum VarUpvalues {
  2. One(Rc<RefCell<Upvalue>>), // 1个Upvalue
  3. Two([Rc<RefCell<Upvalue>>; 2]), // 2个Upvalue
  4. Three([Rc<RefCell<Upvalue>>; 3]), // 3个Upvalue
  5. Four([Rc<RefCell<Upvalue>>; 4]), // 4个Upvalue
  6. More(Vec<Rc<RefCell<Upvalue>>>), // 更多个Upvalue
  7. }
  8. pub struct LuaClosure {
  9. proto: Rc<FuncProto>,
  10. upvalues: VarUpvalues,
  11. }

这样的话对于不多于4个Upvalue的闭包,就可以避免额外的内存分配了。这应该满足了大多数情况。而对于更多个Upvalue的情况,再分配一块内存的浪费相对就没那么大了。这个方案的另外一个优点是不涉及unsafe。当然,这个方案的问题是会带来编码的复杂。由于创建LuaClosure只是在创建闭包的时候一次性生成,不是一个高频操作,也就没必要搞这么复杂了。所以,最终绕一圈回来还是使用最开始的Vec方案。