返回值
本节介绍Lua函数的返回值。首先介绍固定数量返回值的情况,再介绍可变数量的情况。
跟上一节中的参数特性涉及形参和实参两部分类似,要实现函数返回值功能,也涉及两个地方:
-
被调用函数在退出前生成返回值。这个由Lua中的return语句完成。对应地需要增加
Return
字节码。 -
调用者读取并处理返回值。这部分功能在
Call
字节码中实现。之前Call
字节码只是调用了函数,而没有处理返回值。
跟参数是由栈来传递一样,返回值的这两部分之间也是由栈来衔接的。
接下来先介绍第一部分,即函数退出并生成返回值的return语句和Return
字节码。
Return字节码
被调用函数和调用者之间,是使用栈来传递返回值的。被调用函数生成返回值并加载到栈上,然后把返回值在栈上的位置通知调用者,调用者从栈上读取返回值。
Lua语言的函数支持多个返回值。如果这些返回值在栈上的位置不连续,那么就很难把具体的返回值告知给调用者。所以要求所有返回值在栈上连续排列,这样就可以通过在栈上的起始索引和数量来告知调用者了。为此,需要把所有返回值依次加载到栈顶。比如下面的例子:
local function foo()
local x, y = 1, 2
return x, "Yes", g1+g2
end
在函数返回前的栈布局如下:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \
+-------+ + 局部变量
| y | 1 /
+-------+
| x | 2 \
+-------+ |
| "yes" | 3 + 返回值
+-------+ |
| g1+g2 | 4 /
+-------+
| g2 | 5<-- 临时变量
+-------+
| |
栈右边的数字0~5是相对地址。其中2~4是返回值在栈上的位置,那么这个函数要返回的信息就是(2, 3)
,其中2
是返回值在栈上的起始位置,3
是返回值个数。由此可知新增的字节码Return
就需要关联2个参数。
除了上述的普遍情况外,还有两个特殊情况,即返回值个数为0和1的情况。
首先,对于返回值个数为0的情况,也就是没有返回值的return语句,虽然也可以使用Return
字节码来返回(0, 0)
,但是为了清晰,再增加一个不需要关联参数的字节码Return0
。
其次,对于返回值个数为1的情况,在语法分析时可以优化。上述多个返回值的情况下,强制把所有返回值依次加载到栈上,是为了连续,为了能够通知调用者返回值的位置。而如果只有1个返回值,就不要求连续了,那么对于本来就在栈上的局部变量,就无需再次加载到栈上了。当然对于其他类型的返回值(比如全局变量、常量、表索引等等)还是需要加载的。比如下面的例子:
local function foo()
local x, y = 1, 2
return x
end
在函数返回前的栈布局如下:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \ <-----返回值
+-------+ + 局部变量
| y | 1 /
+-------+
| |
只有一个返回值x
,并且是局部变量,已经在栈上,返回(0, 1)
即可,无需再次加载到栈顶。
综上,新增的两个字节码定义如下:
pub enum ByteCode {
Return0,
Return(u8, u8),
返回值语句的解析流程如下:
- 对于无返回值,生成
Return0
字节码; - 对于单个返回值,按需加载到栈上,并生成
Return(?, 1)
字节码; - 对于多个返回值,强制依次加载到栈上,并生成
Return(?, ?)
字节码。
return语句的语法分析
上面总结了返回语句的解析流程,现在开始语法分析。return语句的BNF定义如下:
retstat ::= return [explist] [‘;’]
除了可选的多个返回值表达式外,还可以有1个可选的;
。另外还有一个规则,即return语句后面必须紧跟一个block的结束Token,比如end
、else
等。这个语句比较简单,只是细节比较多。下面先列出代码:
fn ret_stat(&mut self) {
let code = match self.lex.peek() {
// return ;
Token::SemiColon => {
self.lex.next();
ByteCode::Return0 // 没有返回值
}
// return
t if is_block_end(t) => {
ByteCode::Return0 // 没有返回值
}
_ => { // 有返回值
let mut iret = self.sp;
// 读取表达式列表。只保留最后一个并返回ExpDesc,而把前面的加载到栈上。
// 返回值:nexp为前面加载的表达式个数,last_exp为最后一个表达式。
let (nexp, last_exp) = self.explist();
// check optional ';'
if self.lex.peek() == &Token::SemiColon {
self.lex.next();
}
// check block end
if !is_block_end(self.lex.peek()) {
panic!("'end' expected");
}
if nexp == 0 {
// 单个返回值,按需加载
iret = self.discharge_any(last_exp);
} else {
// 多个返回值,其他返回值已经依次加载到栈顶,现在需要把最后一个
// 表达式也强制加载到栈顶,排在其他返回值的后面
self.discharge(self.sp, last_exp);
}
ByteCode::Return(iret as u8, nexp as u8 + 1)
}
};
self.fp.byte_codes.push(code);
}
因为对单个和多个返回值的处理有区别,所以在读取返回值列表时,要保留最后一个表达式不要直接加载到栈上。此时上一节改造过的explist()
函数再次派上了用场。如果只有最后这一个表达式,即nexp == 0
,那么就是单个表达式的情况,则按需加载到栈上;否则,就是多个返回值的情况,其他返回值已经依次加载到栈顶,需要把最后一个表达式也强制加载到栈顶,排在其他返回值的后面。
复习一下,上面代码中,单个返回值的情况中的discharge_any()
方法是按需加载,即并不处理已经在栈上的表达式(局部变量或临时变量等);而多个返回值的情况中的discharge()
方法是强制加载。
Return字节码的执行
完成语法分析后,接下来介绍虚拟机对Return
字节码的执行。需要做两件事情:
-
从当前函数的执行
execute()
中退出,使用Rust的return语句即可; -
告知给调用者返回值的位置,最直观的方法就是返回
Return
字节码关联的两个参数:返回值在栈上的起始位置和个数。不过这里的起始位置要从相对位置转换为绝对位置。代码如下:
ByteCode::Return(iret, nret) => {
return (self.base + iret as usize, nret as usize);
}
这样有点啰嗦,并且还有2个问题:
-
Lua中Rust函数类型(比如
print
函数)的原型是fn (&mut ExeState) -> i32
,只有一个返回值i32
,代表的是Rust函数返回值的个数。如果Lua函数类型的返回两个值,那么这两类函数的返回信息不一致,后续不方便处理。 -
本节后面会支持Lua函数的可变数量返回值,具体的返回值个数需要根据执行情况计算得出。
所以这里也改成只返回Lua函数返回值的个数,而不用返回起始位置。为此,需要把栈上可能的临时变量清理掉,以确保返回值在栈顶。这样,调用者只根据返回值的个数,就能确定返回值的位置。还用上面的例子:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \
+-------+ + 局部变量
| y | 1 /
+-------+
| x | 2 \
+-------+ |
| "yes" | 3 + 返回值
+-------+ |
| g1+g2 | 4 /
+-------+
| | <--清理掉临时变量g2
这个例子里,在清理掉栈顶的临时变量g2
后,对调用函数只返回3
即可,调用函数就可以读取栈顶的3个值作为返回值。
那么为什么Return
字节码中需要关联2个参数呢?除了返回值的个数,还要编入返回值的起始位置?这是因为语法分析阶段很难确定在执行过程中栈顶是否有临时变量(比如上面例子中的g2
),即便能够确定也对这些临时变量无能为力(除非增加一个字节码去清理临时变量)。所以只通过个数是无法表示返回值的。而在虚拟机执行阶段,由于可以清理可能的临时变量,没有了临时变量的干扰,就无须再返回起始地址了。
综上,Return
字节码的执行代码如下:
ByteCode::Return(iret, nret) => {
let iret = self.base + iret as usize; // 相对地址转换为绝对地址
self.stack.truncate(iret + nret as usize); // 清理临时变量,确保栈顶nret都是返回值
return nret as usize;
}
ByteCode::Return0 => {
return 0;
}
相应地,虚拟机执行的入口函数execute()
也要修改原型,从没有返回值,修改为usize类型返回值:
pub fn execute(&mut self, proto: &FuncProto) -> usize {
字节码遍历和函数退出
既然说到了execute()
函数,那么就说下字节码序列的遍历和退出。
这个项目最开始的时候只支持顺序执行,使用Rust Vec的迭代器即可:
for code in proto.byte_codes.iter() {
match *code {
后来支持跳转语句后,就要手动来遍历,并通过pc是否超出字节码序列来判断退出:
let mut pc = 0;
while pc < proto.byte_codes.len() {
match proto.byte_codes[pc] {
现在支持了Lua的return语句,对应的Return
字节码的执行会退出execute()
函数。如果所有的Lua函数最终都包含Return
字节码,那就不需要通过pc是否超出字节码序列来判断退出了。这样execute()
函数中原来的while
循环就可以改成loop
循环,减少一次条件判断:
let mut pc = 0;
loop {
match proto.byte_codes[pc] {
ByteCode::Return0 => { // Return或Return0字节码,退出函数
return 0;
}
为此,我们在所有Lua函数的结尾都加上Return0
字节码:
fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> FuncProto {
let mut proto = ParseProto::new(lex);
assert_eq!(proto.block(), end_token);
if let Some(goto) = proto.gotos.first() {
panic!("goto {} no destination", &goto.name);
}
// 所有Lua函数的结尾都加上`Return0`字节码
proto.fp.byte_codes.push(ByteCode::Return0);
proto.fp
}
至此,完成了函数退出并生成返回值的功能。接下来介绍第二部分:调用者读取返回值。
读取返回值:位置
被调用函数通过return语句返回后,虚拟机执行序列就重新回到外层调用函数的Call
字节码,这里也就读取并处理返回值的位置。如何处理返回值?取决于函数调用所处的不同应用场景。因为Lua函数支持多返回值,并且在语法分析阶段不能确定返回值的具体个数,类似上一节的可变参数表达式...
,所以对函数返回值的处理就跟可变参数的处理类似,也包括3种场景:
-
作为函数调用的最后一个参数、return语句的最后一个参数、表构造的最后一个列表成员时,读取全部返回值。比如下面示例:
print("hello: ", foo(1, 2)) -- 最后一个实参 local t = {1, 2, foo()} -- 最后一个列表成员 return a+b, foo() -- 最后一个返回值
-
作为局部变量定义语句、或赋值语句的等号
=
后面最后一个表达式时,会按需求扩展或缩减返回值个数。比如下面示例:local x, y = foo() -- 取前2个实参,分别赋值给x和y t.k, t.j = a, foo() -- 取前1个实参,赋值给t.j
-
其他地方都只代表实际传入的第一个实参。比如下面示例:
local x, y = foo(), b -- 不是最后一个表达式,只取第1个实参并赋值给x t.k, t.j = foo(), b -- 不是最后一个表达式,只取第1个实参并赋值给t.k if foo() then -- 条件判断 t[foo()] = foo() + f -- 表索引,和二元运算操作数 end
除此之外,还有一种场景:
-
单独的函数调用语句,此时忽略返回值。比如下面示例:
print("no results") foo(1, 2, 3)
第4种场景不需要处理返回值,暂时忽略。前面3种场景,都需要把返回值从栈顶挪到函数入口的位置。比如对于print("hello", sqr(3, 4))
语句,在调用sqr()
函数前的栈布局如下面左图所示:
| | | | | |
+-------+ +-------+ +-------+
| print | | print | | print |
+-------+ +-------+ +-------+
|"hello"| |"hello"| |"hello"|
+-------+ +-------+ +-------+
| sqr | | sqr | / | 9 | <--原来sqr入口位置
+-------+ +-------+ <--base /-+ +-------+
| 3 | | 3 | | \ | 16 |
+-------+ +-------+ | +-------+
| 4 | | 4 | | | |
+-------+ +-------+ |
| | | 9 | \ |
+-------+ +返回值--/
| 16 | /
+-------+
| |
左图中,print
函数在栈的最上面,下面依次是参数"hello"
字符串常量和sqr()
函数,再下面是sqr()
函数的两个参数3
和4
。这里的重点是,在语法分析阶段,函数的参数是由explist()
生成字节码,依次加载到栈上,所以sqr()
函数一定位于外层print()
函数的参数位置。那么,sqr()
函数的返回值,就应该挪到sqr()
函数的位置,作为print()
函数的参数,如上图中的最右面图所示。
由此总结上面的3个栈布局图分别是:
-
左图是
sqr()
函数调用前的状态; -
中图是
sqr()
函数调用后,也就是本节之前部分介绍的Return
字节码执行后的状态; -
右图是
sqr()
函数调用后的预期状态,即sqr()
函数的返回值作为print()
函数的返回值。
于是,我们需要做的就是把栈布局从中图变成右图,所以在Call
字节码的处理流程中,把返回值从栈顶挪到函数入口的位置,即下面代码中的最后一行:
ByteCode::Call(func, narg_plus) => {
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
// 这里省略参数的处理。
// 调用函数,nret为位于栈顶返回值的个数
let nret = self.execute(&f);
// 删除从函数入口到返回值起始位置的栈数据,也就可以把
// 返回值挪到函数入口位置。
self.stack.drain(self.base+func as usize .. self.stack.len()-nret);
}
这里并不是直接把返回值挪到函数入口位置,而是通过Vec::drain()
方法把函数入口到返回值起始位置的栈数据清空,来实现返回值就位的。这么做也是为了同时清理被调用函数占用的栈空间,以便及时释放资源。
读取返回值:个数
上面介绍了把返回值放到什么位置,现在介绍如何处理返回值的个数。这一点也跟上一节的可变参数表达式一样,按照上述4种场景,也分为4种:
- 全部返回值;
- 固定前N个返回值;
- 第一个返回值;
- 不需要返回值。
跟VarArgs
字节码类似,Call
字节码也要增加一个参数,用以表示需要多少个返回值:
pub enum ByteCode {
Call(u8, u8, u8) // 增加第3个关联参数,表示需要多少个返回值
但这里有个区别,就是VarArgs
关联的表示个数的参数,取值0
表示全部可变实参。而函数调用这里增加了第4种场景,本来就不需要返回值,即需要0
个返回值,那么Call
字节码的新增关联参数就不能用0
做特殊值来表示全部返回值了。这就像上一节参数个数的场景,即本来就存在0
个参数的情况,就不能简单用0
做特殊值了。对于这个问题有两个解决方案:
-
参考上一节参数个数的处理方式,用
0
代表全部返回值,并且把固定N个返回值的情况改为N+1编入到Call
字节码中。这也是Lua官方实现采用的方案; -
把第4种场景的“不需要返回值”,重新理解为“忽略返回值”,也就是不需要处理返回值,或者说无所谓怎么处理返回值都可以。那么这个场景下这个关联参数随便填任何数都可以。我们这里选择填
0
。
我们选择后面的方案。也就是说,0
这个取值有2种含义:
- 需要全部返回值;
- 不需要返回值。
这两个场景虽然含义不同,但是虚拟机执行时的处理方式是一样的,都是不处理返回值。也就是说所有返回值(如果有的话)都会被放到函数入口的位置。
如果这个参数的值不是0
,就对应上述第2和第3种场景,即需要固定前N个和前1个返回值的情况,则需要处理:
- 如果实际返回值少于预期需求,那么需要补上nil;
- 否则,无需处理。多出的返回值在栈上就被认为是临时变量,并没有什么影响。
下面在虚拟机执行Call
字节码的流程中增加这个填补nil的处理:
ByteCode::Call(func, narg_plus, want_nret) => {
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
let nret = self.execute(&f);
self.stack.drain(self.base+func as usize .. self.stack.len()-nret);
// 按需填补nil
// 如果want_nret==0,那么无需处理,也不会进到if{}分支中。
let want_nret = want_nret as usize;
if nret < want_nret {
self.fill_stack(nret, want_nret - nret);
}
}
至此,完成对Call
字节码的虚拟机执行部分。
返回值场景的语法分析
我们之前所有的功能介绍,都是先语法分析,生成字节码,然后再虚拟机执行,执行字节码。不过这次有所不同,上面只是介绍了不同场景下Call
字节码的虚拟机执行;而没有介绍语法分析,即在各个场景下如何生成Call
字节码。现在补上。
上述第1和第2种场景,跟可变参数表达式的对应场景完全一样,所以这里不需要对这些语句做修改,只需要在discharge_expand()
和discharge_expand_want()
中增加ExpDesc::Call
表达式即可。下面列出discharge_expand()
的代码,而``discharge_expand_want()`类似,这里就省略掉。
fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
let code = match desc {
ExpDesc::Call(ifunc, narg_plus) => { // 新增函数调用表达式
ByteCode::Call(ifunc as u8, narg_plus as u8, 0)
}
ExpDesc::VarArgs => {
ByteCode::VarArgs(self.sp as u8, 0)
}
_ => {
self.discharge(self.sp, desc);
return false
}
};
self.fp.byte_codes.push(code);
true
}
在Lua中,语法分析阶段无法确定值的个数的情况,就只有可变参数和函数调用了。所以这两个函数至此完整了。假如还有其他类似的语句,也可以向这个函数中添加语句,而不需要修改具体的应用场景。
接下来看第3种场景,只取第一个返回值。跟上一节的可变参数语句一样的是,也是在discharge()
函数中完成ExpDesc::Call
表达式的加载。而跟可变参数语句不一样的是,可变参数生成的VarArgs
字节码的第一个关联参数就是目标地址,而这里的Call
字节码关联的3个参数是没有目标地址的。上面介绍了虚拟机执行时是把返回值放到函数的入口地址,但是discharge()
函数是要把表达式的值加载到指定地址的。所以,ExpDesc::Call
表达式的加载可能需要2条字节码:先生成Call
字节码调用函数并把返回值放到函数入口位置,再生成Move
字节码把第一个返回值赋值给目标地址。代码如下:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::Call(ifunc, narg_plus) => {
// 生成Call,只保留1个返回值,并放在ifunc位置
self.fp.byte_codes.push(ByteCode::Call(ifunc as u8, narg_plus as u8, 1));
// 生成Move,把返回值从ifunc复制到dst位置
self.fp.byte_codes.push(ByteCode::Move(dst as u8, ifunc as u8));
}
比如下面的示例代码:
local x, y
x = foo()
其栈布局如下:
| | | | | |
+-------+ +-------+ +-------+
| x | | x | /---->| x |
+-------+ +-------+ | +-------+
| y | | y | | | y |
+-------+ +-------+ | +-------+
| foo | /---->| 100 |----/ | |
+-------+ | +-------+ Move字节码把返回值赋值给目标地址
: : | | |
+-------+ |
| 100 |----/ Call字节码把返回值100
+-------+ 挪到函数入口foo的位置
| |
- 左图是
foo()
函数返回前的栈布局,假设栈顶的100
是函数的返回值; - 中图是
Call
字节码执行完毕后,把返回值挪到函数入口位置,这是本节上面完成的功能; - 右图是
Move
字节码把返回值赋值给目标地址,即局部变量x
。
可以看到这个场景下生成了2条字节码,而返回值也被移动了2次。这里就有优化的空间。之所以需要2条字节码,是因为Call
字节码没有关联目标地址的参数,所以不能直接赋值。而之所以没有关联目标地址参数,是因为Call
字节码中已经塞了3个参数了,没有空间再塞进去目标地址了。
确定了问题后,优化方案也就很明显了。既然这个场景下总是只需要1个返回值,那么Call
字节码中的第3个关联参数(需要的返回值个数)就没有意义。所以可以新增一个专门用于这个场景的字节码,删掉Call
字节码中的第3个参数,腾出空间就可以加上目标地址这个参数了。为此,我们新增CallSet
字节码:
pub enum ByteCode {
Call(u8, u8, u8), // 关联参数:函数入口,参数个数,预期返回值个数
CallSet(u8, u8, u8), // 关联参数:目标地址,函数入口,参数个数
这样,在discharge()
函数中,函数调用语句就只需要一个字节码即可:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::Call(ifunc, narg) => {
ByteCode::CallSet(dst as u8, ifunc as u8, narg as u8)
}
CallSet
字节码的虚拟机执行如下:
ByteCode::CallSet(dst, func, narg) => {
// 调用函数
let nret = self.call_function(func, narg);
if nret == 0 { // 没有返回值,设置nil
self.set_stack(dst, Value::Nil);
} else {
// use swap() to avoid clone()
let iret = self.stack.len() - nret as usize;
self.stack.swap(self.base+dst as usize, iret);
}
// 清理函数调用占用的栈空间
self.stack.truncate(self.base + func as usize + 1);
}
上述代码中的call_function()
方法,是把Call
字节码的执行流程提取出来的函数。在调用完函数后,如果没有返回值则把目标地址设置nil,否则把第一个返回值赋值给目标地址。最后一行是
清理函数调用占用的栈空间,有两种情况:
- 如果目标地址是局部变量,那么清理的位置是从函数入口;
- 如果目标地址是临时变量,在
discharge_any()
中把函数返回值的目标地址都设置为函数入口位置,所以清理的位置是从函数入口后面1个位置开始。
综上,总是从函数入口位置后面1个位置开始清理,是可以满足上述两个情况的。只是对于局部变量的情况,会多保留一个函数入口而已。
可变数量返回值
上面介绍了返回值的语法分析和虚拟机执行,但还漏掉一个地方。上一节列出的可变参数的3种应用场景时,第1种场景里包括3条语句:表构造、函数实参、和函数返回值。当时只介绍了前2个语句,现在支持了返回值语句后,补上最后一个语句。
本节上面介绍了return语句的语法分析,但当时是把所有返回值的表达式依次加载到栈上,即只支持固定数量的返回值。当函数返回值语句的最后一个表达式是可变参数或者函数调用语句时,那么虚拟机执行时的全部可变参数或者函数全部返回值都会作为这个函数的返回值,也就是说返回值的数量在语法分析阶段无法确定,也就是可变数量的返回值。
可变数量的返回值,语法分析可以参考上一节中表构造或函数实参,即使用改造后的explist()
函数,对最后一个表达式特殊处理。具体代码这里省略。
需要解释的是如何用字节码表示“可变数量”。本节中新增2个返回值相关的字节码,Return0
和Return
。其中Return0
用于没有返回值的情况,所以Return
字节码中关联的返回值个数的参数不会是0
,那么0
就可以作为特殊值,用来表示可变数量的返回值。
可变数量相关语句和场景总结
这里总结下可变数量相关的语句和场景。直接导致出现可变数量的语句包括:
- 可变参数语句,有3个应用场景;
- 函数调用语句,除了可变参数的3个应用场景外,还有一个忽略返回值的场景。
这两条语句的几个应用场景中,第1个场景都是取虚拟机执行时的实际全部参数或返回值,这个场景包括3个语句:
- 表构造,对应
SetList
字节码; - 函数实参,对应
Call/CallSet
字节码; - 函数返回值,对应被调用函数的
Return
字节码,和调用函数的Call/CallSet
字节码。
上述几个字节码中,为了表示“虚拟机执行时实际全部表达式”这个状态,都使用了0
作为特殊值,其中:
-
Call/CallSet
字节码的第2个参数代表实际参数的个数。因为函数调用本来就支持没有参数的情况,所以为了0
能作为特殊值,把固定参数的情况都修正加1,即N个固定参数就在字节码中编入N+1; -
Call/CallSet
字节码的第3个参数代表预期返回值的个数。函数调用本来也支持不需要返回值的情况,但是我们把“不需要”理解为“忽略”,那么读取全部返回值也没问题,于是0
就可以作为特殊值; -
SetList
和Return
字节码的第2个参数都是表示个数。但是这两个字节码用于固定个数的时候并不支持没有表达式,所以0
就可以直接作为特殊值。
另外,需要再次强调的是,当上述字节码中用0
代表特殊值时,具体的表达式个数是通过栈顶来计算得到,这就要确保栈顶没有临时变量,所以虚拟机执行可变参数和函数调用语句时,都要显式清理栈上临时变量。
小结
本节开始介绍了固定数量的返回值。被调用函数通过Return/Return0
字节码把返回值放置在栈顶,然后调用函数在Call/CallSet
字节码中读取返回值。
后续介绍了可变数量的返回值,这个跟上一节的可变参数很类似。