Upvalue的逃逸和闭包
上一节介绍了Upvalue的概念,并以Upvalue最基本的用法为例介绍了为支持Upvalue而对语法分析做的改造。本节就来介绍Upvalue的完整特性,主要是Upvalue的逃逸。
下面参考《Lua程序设计》书中的示例代码:
local function newCounter()
local i = 0
return function ()
i = i + 1 -- upvalue
print(i)
end
end
local c1 = newCounter()
c1() -- 输出:1
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是每次调用都会生成一份新的。比如接着上面的代码,继续如下代码:
local c2 = newCounter()
c2() -- 输出:1。新的计数开始。
c1() -- 输出:3。继续上面c1的输出。
再次调用newCounter()
,会生成一个新的计数器,其中的局部变量i
会重新初始化为0,重新开始计数。此时就存在两个计数器:c1
和c2
,两者各自拥有独立的局部变量i
。于是当调用c2()
的时候,会从1开始重新计数;如果穿插调用之前的c1()
也会继续之前的计数。多么有趣!
--+---+-- +---+ +---+
| i | | i | | i |
--+-^-+-- +-^-+ +-^-+
| | |
/---+---\ | |
| | | |
+----+ +----+ +----+ +----+
| c1 | | c2 | | c1 | | c2 |
+----+ +----+ +----+ +----+
上面左图展示的是把i
放到全局唯一的静态存储,那么所有计数器函数指向唯一的i。这是不满足我们需求的。我们需要的是右图所示,每个计数器函数都有独立的i
。
堆上存储方案
既然不能放在栈上,也不能放在全局静态区,那么就只能放在堆上了。接着的问题是,什么时候放到堆上?有几个可能的方案:
- 刚进入到函数时,就把所有被Upvalue引用的局部变量放到堆上;
- 当一个局部变量被Upvalue引用时,才从栈上挪到堆上;
- 当函数退出时,把所有被Upvalue引用的局部变量挪到堆上;
第1个方案不行,因为一个局部变量在被Upvalue引用前,可能已经被作为局部变量使用过了,已经生成了相关的字节码。第2个方案应该是可行的,但是局部变量在被Upvalue引用后,后续还可能在当前函数内被作为局部变量使用,提前挪到堆上并没有必要,毕竟对栈的访问更快更方便。所以我们选择第3个方案。
这种把局部变量从栈上挪到堆上的操作,我们遵循Lua官方实现的代码,也称之为“关闭close”。
接下来,为了演示一个Upvalue分别在逃逸前和逃逸后被访问的情况,在上面的计数器示例代码的基础上改造下,在内部的匿名函数返回前,先在newCounter()
函数内部调用一次。为此,需要把这个匿名函数赋值给一个局部变量retf
:
local function newCounter()
local i = 0
local function retf()
i = i + 1 -- upvalue
print(i)
end
retf() -- 在newCounter()内部调用
return retf -- 返回retf
end
分两部分介绍这个例子,先是在工厂函数内部调用retf()
,再是工厂函数返回retf
造成的Upvalue逃逸。
首先,在工厂函数内部调用retf()
的时候,retf
要操作的i
仍然还在栈上。示意图如下。
| |
+----------+
base |newCounter|
+----------+
0 | i |<- - - - - - - - - - \
+----------+ |
1 | retf +--+->+-FuncProto-----+--+
+----------+ | |byte_codes: | |
2 | retf +--/ | GetUpvalue(0, 0) |
+----------+ | ... |
| | +------------------+
图中,左边是栈,其中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
从栈上挪到堆上。示意图如下:
| |
+----------+
base |newCounter| +===+
+----------+ close | i |<- - - \
0 | i +-------->+===+ ?
+----------+ ?
1 | retf +---->+-FuncProto-----?--+
+----------+ |byte_codes: ? |
| | | GetUpvalue(0, 0) |
| ... |
+------------------+
这样虽然保证了i
可以被继续访问,但是有个非常明显的问题:字节码GetUpvalue
关联的第2个参数不能定位到堆上的i
了(图中连续的?
线)。这也就是在上一节里提到的,直接用局部变量在栈上的索引来表示Upvalue的方案,是不可行的。需要在这个方案基础上做改进。
改进:Upvalue中介
为了在关闭局部变量后仍然可以被Upvalue访问到,我们需要一个Upvalue的中介,开始时还是用栈上索引来表示Upvalue,而当外层函数的局部变量被关闭后,就挪到这个中介里。
下面两个图展示了在加上Upvalue中介后的情况。
| | - - - - - - \
+----------+ | |
base |newCounter| | *-----+-+---
+----------+ | |Open(0)|
0 | i |<- - - - - - *-^-----+---
+----------+ |
1 | retf +--+->+-FuncProto-----+--+
+----------+ | |byte_codes: | |
2 | retf +--/ | GetUpvalue(0, 0) |
+----------+ | ... |
| | +------------------+
上图是在newCounter()
函数内部调用retf()
函数的示意图。对比之前的版本,增加了Upvalue中介列表(图中以*
为角的列表),并只有1个成员:Open(0)
,代表这个局部变量还没关闭,并且是在栈上的相对索引是0。而retf
的函数原型中,字节码GetUpvalue
关联的第2个参数虽然没有变,但其含义变了,变成了中介列表的索引。只不过这个例子中恰巧也是0而已。
| | /----------------\
+----------+ | |
base |newCounter| | *-------V-+---
+----------+ close | |Closed(i)|
0 | i +----------/ *-^-------+---
+----------+ |
1 | retf +---->+-FuncProto-----+--+
+----------+ |byte_codes: | |
| | | GetUpvalue(0, 0) |
| ... |
+------------------+
上图是newCounter()
函数返回前,关闭局部变量i
之后的示意图。上图中增加的Upvalue中介列表中的成员,变成了Closed(i)
,即把局部变量i
挪到这个中介列表中。这样GetUpvalue
仍然可以定位到第0个Upvalue中介,并访问关闭后的i
。
改进:共享Upvalue
上述方案可以支持目前简单的逃逸情景了,但是不支持多个闭包共享同一个局部变量的情景。比如下面示例代码:
local function foo()
local i, ip, ic = 0, 0, 0
local function producer()
i = i + 1
ip = ip + 1
end
local function consumer()
i = i - 1
ic = ic + 1
end
return produce, consume
end
上述的foo()
函数返回的两个内部函数都引用了局部变量i
,而且很明显这两个函数是要共享i
,要操作同一个i
,而不是各自独立的i
。那当foo()
函数结束关闭i
后,就需要两个函数来共享关闭后的i
了。由于这两个函数拥有不同的Upvalue列表,分别是i, ip
和i, ic
,所以两个函数不用共享同一个Upvalue列表。那就只能针对每个Upvalue单独共享了。
下图是单独共享每个Upvalue的方案:
| |
+----------+ +=======+
base | foo | |Open(0)|<===============+------------\
+----------+ +=======+ | |
0 | i |<- -/ +=======+ | |
+----------+ |Open(1)|<-------------|---\ |
1 | ip |<- - - +=======+ | | |
+----------+ +=======+ | | |
2 | ic |<- - - - |Open(2)|<-----------|---|--------|---\
+----------+ +=======+ *-+-+-+-+-- | |
3 | producer +---->+-FuncProto--------+ | i |ip | | |
+----------+ |byte_codes: | *-^-+-^-+-- | |
4 | consumer +--\ | GetUpvalue(0, 0)-+----/ | | |
+----------+ | | ... | | | |
| | | | GetUpvalue(0, 1)-+---------/ | |
| | ... | | |
| +------------------+ | |
| *-+-+-+-+--
\-------------->+-FuncProto-------+ | i |ic |
|byte_codes: | *-^-+-^-+--
| GetUpvalue(0, 0)-+-----/ |
| ... | |
| GetUpvalue(0, 1)-+----------/
| ... |
+------------------+
上图略显复杂,但大部分跟之前的方案是一样的。最左边仍然是栈。然后看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闭包类型:
pub enum Upvalue { // 上图中的Upvalue中介
Open(usize),
Closed(Value),
}
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: Vec<Rc<RefCell<Upvalue>>>,
}
pub enum Value {
LuaFunction(Rc<FuncProto>), // Lua函数
LuaClosure(Rc<LuaClosure>), // Lua闭包
这样一来,多次调用newCounter()
函数返回的不同闭包,虽然共享同一个函数原型,但是各自拥有独立的Upvalue。这也是本节开头两个计数器c1、c2可以独立计数的原因。
下图展示两个计数器的示意图:
+-LuaClosure--+
| | | proto-+----------------------+-->+-FuncProto--------+
+------+ | upvalues-+--->+---+-- | |byte_codes: |
| c1 +---->+-------------+ | i | | | GetUpvalue(0, 0) |
+------+ +-+-+-- | | ... |
| c2 +-\ | | +------------------+
+------+ | V=========+ |
| | | |Closed(i)| |
| +=========+ |
\-->+-LuaClosure--+ |
| proto-+----------------------/
| upvalues-+--->+---+--
+-------------+ | i |
+-+-+--
|
V=========+
|Closed(i)|
+=========+
类似的,我们也改造下上面共享Upvalue例子中的示意图。清晰起见,删掉FuncProto
的具体内容;然后把函数原型和Upvalue列表合并为LuaClosure
。如下图。
| |
+----------+ +=======+
base | foo | |Open(0)|<========+----------\
+----------+ +=======+ | |
0 | i |<- -/ +=======+ | |
+----------+ |Open(1)|<------|---\ |
1 | ip |<- - - +=======+ | | |
+----------+ +=======+ | | |
2 | ic |<- - - - |Open(2)|<----|---|------|---\
+----------+ +=======+ | | | |
3 | producer +---->+-LuaClosure--+ | | | |
+----------+ | proto | | | | |
4 | consumer +--\ | upvalues -+>*-+-+-+-+-- | |
+----------+ | +-------------+ | i |ip | | |
| | | *---+---+-- | |
| | |
\------------>+-LuaClosure--+ | |
| proto | | |
| upvalues -+>*-+-+-+-+--
+-------------+ | i |ic |
*---+---+--
由图可见,相比于上一章定义的Lua函数LuaFunction
而言,闭包LuaClosure
虽然可以拥有独立的Upvalue列表,但是多了一次内存分配和指针跳转。这里就面临一个选择:是用闭包完全代替函数,还是两者并存?Lua官方实现中是前者,这也是“Lua中所有函数都是闭包”这句话的来源。代替的好处是少一个类型,代码稍微简单一点点;并存的好处是函数类型毕竟少分配一点内存少一次指针跳转。除了这两点个优缺点之外,还有一个更大的影响行为的区别。比如下面的示例代码:
local function foo()
return function () print "hello, world!" end
end
local f1 = foo()
local f2 = foo()
print(f1 == f2) -- true or false?
这里调用foo()
返回的匿名函数不包括Upvalue。那么问题来了,两次调用foo()
的两个返回值相等吗?
-
如果保留
LuaFunction
类型,那么返回值就是LuaFunction
类型,f1
和f2
就只涉及函数原型,就是相等的。可以用上一章的代码执行验证。 -
如果不保留
LuaFunction
类型,那么返回的函数就是LuaClosure
类型。虽然不包含Upvalue,但是也是两个不同的闭包,f1
和f2
就是不等的。
那么上述哪个行为是符合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
等读取常量表的字节码。
对函数定义的处理,就跟其他类型的常量的处理方式类似。回忆一下,相关代码如下:
fn funcbody(&mut self, with_self: bool) -> ExpDesc {
// 省略准备工作
// chunk()函数返回的proto就是FuncProto类型
let proto = chunk(self.lex, has_varargs, params, Token::End);
ExpDesc::Function(Value::LuaFunction(Rc::new(proto)))
}
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
// 省略其他类型
// 把函数原因加入到常量表中,并生成LoadConst字节码
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引用上层局部索引的列表。
综上,新增的创建闭包的字节码如下:
pub enum ByteCode {
Closure(u8, u16),
这个字节码关联的两个参数类似LoadConst
字节码,分别是栈上目标地址,和内部函数原型列表inner_funcs
的索引。
另外,需要在函数原型中新增两个成员如下:
pub struct FuncProto {
pub upindexes: Vec<usize>,
pub inner_funcs: Vec<Rc<FuncProto>>,
其中inner_funcs
是函数内部定义的内层函数的原型列表。upindexes
是当前函数引用上层函数的局部变量的索引,这个成员后续需要改造。需要说明的是,inner_funcs
是当前函数作为外层函数的角色时使用的,而upindexes
是当前函数作为内层函数的角色时使用的。
我们在后面介绍完Upvalue完整的特性后,再来介绍对Upvalue索引upindexes
的解析。
在介绍完闭包的定义和语法分析后,接着看Upvalue的其他场景。
改进:对Upvalue的引用
之前介绍的Upvalue都是对上层函数的局部变量的引用,现在来看对上层函数的Upvalue的引用。对本节最开头的计数闭包示例做下改造,把递增的代码i = i + 1
再放到一层函数中:
local function newCounter()
local i = 0
return function ()
print(i) -- upvalue
local function increase()
i = i + 1 -- where does `i` refer?
end
increase()
end
end
local c1 = newCounter()
c1()
这个示例中newCounter()
函数返回的匿名函数的第1行print语句中的i
是之前已经介绍的普通的Upvalue,指向上层函数的局部变量。而内部函数increase()
函数中的i
是什么?也是Upvalue。那这个Upvalue是对谁的引用呢?
可以看做是对最外层newCounter()
函数中局部变量i
的 跨层 引用吗?不可以,因为虚拟机执行时不能实现。当匿名函数返回时,内部的increase()
函数还没有创建;只有当在更外面调用匿名函数时,内部的increase()
函数才会被创建并执行;而此时最外层的newCounter()
已经结束了,其中的局部变量i
也已经不存在了,也就无法对其进行引用了。
既然不能是对最外层newCounter()
函数中局部变量i
的 跨层 引用,那就只能是对外层的匿名函数中的Upvalue i
的引用了。
为了支持对Upvalue的引用,首先,要修改刚才FuncProto
中Upvalue列表的定义,从只支持局部变量修改为也支持Upvalue:
pub enum UpIndex {
Local(usize), // index of local variables in upper functions
Upvalue(usize), // index of upvalues in upper functions
}
pub struct FuncProto {
pub upindexes: Vec<UpIndex>, // 从usize修改为UpIndex
pub inner_funcs: Vec<Rc<FuncProto>>,
然后,来看上面例子中,在执行返回的匿名函数计数器c1
时,在调用内部increase()
函数时的示意图:
| |
+----------+
| c1 +-------------------->+-LuaClosure--+
+----------+ | proto |
| increase | | upvalues +--->+---+--
+----------+ +-------------+ | i |
| increase +-->+-LuaClosure--+ +-+-+--
+----------+ | proto | |
| | | upvalues +--->+---+-- |
+-------------+ | i | |
+-+-+-- V=========+
\---------------->|Closed(i)|
+=========+
左边是栈。其中c1
是函数调用入口,对应的闭包中包含的Upvalue i
是print语句中引用的。
栈的下面第一个increase
是c1
中的局部变量。第二个increase
是函数调用入口,对应的闭包中包含的Upvalue i
是执行递增操作的语句中引用的。在函数原型中,这个Upvalue应该对应上层函数的第0个Upvalue,即UpIndex::Upvalue(0)
,所以在虚拟机执行生成这个闭包时,这个Upvalue就指向c1
的第0个Upvalue,即图中的Closed(i)
。这样一来,在这个函数中对i
的递增操作也会反应在c1
函数的print语句中了。
改进:跨多层函数引用
再来看另外一种场景:跨层引用。稍微修改一下上面的用例,把print
语句放到递增操作之后,得到下面的示例代码:
local function newCounter()
local i = 0
return function ()
local function increase()
i = i + 1 -- upvalue of upper-upper local
end
increase()
print(i) -- upvalue
end
end
这个例子跟上面那个例子的区别在于,在解析到increase()
函数的时候,要返回的匿名函数中还没有生成Upvalue i
,那么increase()
函数中的i
要指向谁?总结下之前的Upvalue类型:要么是指向上层函数的局部变量,要么是上层函数的Upvalue,并且分析了不能跨多层函数引用。所以,只有一个解决办法了:在中间层函数创造一个Upvalue。这个Upvalue在当前函数(暂时)并不使用,只是用来给内层函数引用。
刚才说的当前函数“暂时”不使用这个创造出来的Upvalue,是指的在语法分析解析到内部函数的时候,暂时还不使用。在后续的解析过程中,还是可能用上的。比如上面这个例子后,后面的print语句就用到了这个Upvalue。
这个例子里,两个函数的原型和执行时的示意图,都跟上面的例子一样。这里省略。
至此,终于介绍完全部的Upvalue特性,并给出最终的方案。这期间也对语法分析和虚拟机执行部分有所涉及,下面根据最终方案,再对语法分析和虚拟机执行做简单的整理。
Upvalue索引的语法分析
上面在介绍闭包的语法分析时,指出在函数原型FuncProto
中需要新增成员upindexes
来表示当前函数的Upvalue索引。
在上一节中,罗列了变量的解析流程:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 在上层函数的局部变量中匹配,如果找到则是Upvalue;
- 否则是全局变量。
根据本节前面对Upvalue完整特性的介绍,对上述的第2步扩展更详细的Upvalue索引的解析步骤,变量解析的最终流程如下:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 在当前函数的Upvalue列表中匹配,如果找到则是已有Upvalue;(复用Upvalue)
- 在外层函数的局部变量中匹配,如果找到则新增Upvalue;(普通Upvalue)
- 在外层函数的Upvalue中匹配,如果找到则新增Upvalue;(对上层函数中Upvalue的引用)
- 在更外层函数的局部变量中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的引用)
- 在更外层函数的Upvalue中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的Upvalue的引用)
- 循环上述第5,6步,如果到达最外层函数仍未匹配,则是全局变量。
这个流程中明显有很多重复的地方。最明显的是第3,4步是第5,6步的特殊情况,即没有中间层函数的情况,所以可以去掉3,4步。另外在代码实现时,第1,2步也可以作为特殊情况而省略。由于本节内容太多,这里就不贴具体代码了。
上一节的语法分析,为了支持Upvalue需要访问上层函数的局部变量列表,所以新增了上下文ParseContext
数据结构,包含各级函数的局部变量列表。本节介绍了Upvalue也可以引用上层函数的Upvalue,所以就也需要在ParseContext
中增加各级函数的Upvalue列表。
struct ParseContext<R: Read> {
all_locals: Vec<Vec<String>>,
all_upvalues: Vec<Vec<(String, UpIndex)>>, // 新增
lex: Lex<R>,
}
pub struct FuncProto {
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的时机是所有函数退出的地方,包括Return
、Return0
和TailCall
字节码。具体的关闭代码这里就省略了。
小节
本节介绍了Upvalue的逃逸,并新增了闭包类型。但主要介绍的都是如何设计和管理Upvalue,而并没有讲具体的操作,包括如何创建、读写、和关闭Upvalue。不过设计方案讲明白后,这些具体的操作相对也就很简单了。本节篇幅已经很长了,就省略这部分的介绍和代码。
Rust的DST
现在介绍Rust语言的一个特征,DST。
本节前面对闭包数据结构LuaClosure
的定义如下:
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: Vec<Rc<RefCell<Upvalue>>>,
}
这里忽略其中的函数原型proto
字段,而只关注Upvalue列表upvalues
字段。为了能存储任意个Upvalue,这里的upvalues定义为一个列表Vec。这样就需要再额外分配一段内存。整个闭包的内存布局如下:
+-LuaClosure--+
| proto |
| upvalues: | Upvalue列表
| ptr --+--->+------+------+-
| capacity | | | |
| length | +------+------+-
+-------------+
上图中,左边是闭包LuaClosure
,其中ptr
指向的右边额外的内存,是Upvalue列表Vec的实际存储空间。这样额外再分配一段内存的缺点有三个:
- 浪费内存,每一段内存都需要额外的管理空间和因为对齐造成的浪费;
- 在申请内存时,需要多执行一次分配,影响性能;
- 在访问Upvalue时,需要多一次指针跳转,也影响性能。
而对于这种不定长数组的需求,在C语言中经典的做法是:在数据结构中定义零长数组,然后在实际分配内存的时候按需指定实际长度。示例代码如下:
// 定义数据结构
struct lua_closure {
struct func_proto *proto;
int n_upavlue; // 实际个数
struct upvalue upvalues[0]; // 零长数组
}
// 申请内存
struct lua_closure *c = malloc(sizeof(struct lua_closure) // 基本空间
+ sizeof(struct upvalue) * n_upvalue); // 额外空间
// 初始化
c->n_upvalue = n_upvalue;
for (int i = 0; i < n_upvalue; i++) {
c->upvalues[i] = ...
}
相应的内存布局如下:
+-------------+
| proto |
| n_upvalue |
: : \
: : + Upvalue列表
: : /
+-------------+
这种做法可以避免上述的3个缺点。那在Rust中能这么做吗?比如如下定义:
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: [Rc<RefCell<Upvalue>>], // slice
}
这个定义中,upvalues的类型从列表Vec变成了slice []。好消息是Rust是支持DST类型(即这里的slice)作为数据结构的最后一个字段的,也就是说上述定义是合法的。坏消息是,这样的数据结构没法初始化。一个没法初始化的数据结构,自然也就没法使用了。引用The Rustonomicon的话就是:custom DSTs are a largely half-baked feature for now。
我们可以想想为什么没法初始化?比如Rc
有Rc::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,那有没有其他解决办法?可以用固定长度的数组。比如修改定义如下:
enum VarUpvalues {
One(Rc<RefCell<Upvalue>>), // 1个Upvalue
Two([Rc<RefCell<Upvalue>>; 2]), // 2个Upvalue
Three([Rc<RefCell<Upvalue>>; 3]), // 3个Upvalue
Four([Rc<RefCell<Upvalue>>; 4]), // 4个Upvalue
More(Vec<Rc<RefCell<Upvalue>>>), // 更多个Upvalue
}
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: VarUpvalues,
}
这样的话对于不多于4个Upvalue的闭包,就可以避免额外的内存分配了。这应该满足了大多数情况。而对于更多个Upvalue的情况,再分配一块内存的浪费相对就没那么大了。这个方案的另外一个优点是不涉及unsafe。当然,这个方案的问题是会带来编码的复杂。由于创建LuaClosure
只是在创建闭包的时候一次性生成,不是一个高频操作,也就没必要搞这么复杂了。所以,最终绕一圈回来还是使用最开始的Vec方案。