参数
上一节介绍了Lua函数的定义和调用流程。这一节介绍函数的参数。
参数这个名词有两个概念:
- 形参,parameter,指函数原型中的参数,包括参数名和参数类型等信息;
- 实参,argument,指函数调用时的参数,是具体的值。
本节后面介绍语法分析和虚拟机执行时,都要明确区分形参和实参。
很重要的一点是:在Lua语言中,函数的参数就是局部变量!在语法分析时,形参也会放到局部变量表中起始位置,这样后续代码中如果有对形参的引用,也会在局部变量表中定位到。在虚拟机执行阶段,实参被加载到栈上紧跟函数入口的位置,后面再跟局部变量,与语法分析阶段局部变量表中的顺序一致。比如对于如下函数:
local function foo(a, b)
local x, y = 1, 2
end
在执行foo()
函数时,栈布局如下(栈右边的数字0-3是相对索引):
| |
+-----+
| foo |
+=====+ <---base
| a | 0 \
+-----+ + 参数
| b | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 /
+-----+
| |
参数和局部变量唯一的区别就是,参数的值是在调用时由调用者传入的,而局部变量是在函数内部赋值的。
形参的语法分析
形参的语法分析,也就是函数定义的语法分析。上一节介绍函数定义时,语法分析的过程省略了参数部分,现在加上。函数定义的BNF是funcbody,其定义如下:
funcbody ::= `(` [parlist] `)` block end
parlist ::= namelist [`,` `...`] | `...`
namelist ::= Name {`,` Name}
由此可以看到,形参列表由两个可选的部分组成:
-
可选的多个Name,是固定参数。上一节在解析新函数,创建
FuncProto
结构时,局部变量表locals
字段被初始化为空列表。现在要改为初始化为形参列表。这样形参就在局部变量表的最前面,后续新建的局部变量跟在后面,与本节开头的栈布局图一致。另外,由于Lua语言中调用函数的实参个数允许跟形参个数不等。多则舍去,少则补nil。所以FuncProto
结果中也要增加形参的个数,用以在虚拟机执行时做比较。 -
最后一个可选的
...
,表明这个函数支持可变参数。如果支持,那么在后续的语法分析中,函数体内就可以使用...
来引用可变参数,并且在虚拟机执行阶段,也要对可变参数做特殊处理。所以在FuncProto
中需要增加一个标志位,表明这个函数是否支持可变参数。
综上,一共有三个改造点。在FuncProto
中增加两个字段:
pub struct FuncProto {
pub has_varargs: bool, // 是否支持可变参数。语法分析和虚拟机执行中都要使用。
pub nparam: usize, // 固定参数个数。虚拟机执行中使用。
pub constants: Vec<Value>,
pub byte_codes: Vec<ByteCode>,
}
另外在初始化ParseProto
结构时,用形参列表来初始化局部变量locals
字段。代码如下:
impl<'a, R: Read> ParseProto<'a, R> {
// 新增has_varargs和params两个参数
fn new(lex: &'a mut Lex<R>, has_varargs: bool, params: Vec<String>) -> Self {
ParseProto {
fp: FuncProto {
has_varargs: has_varargs, // 是否支持可变参数
nparam: params.len(), // 形参个数
constants: Vec::new(),
byte_codes: Vec::new(),
},
sp: 0,
locals: params, // 用形参列表初始化locals字段
break_blocks: Vec::new(),
continue_blocks: Vec::new(),
gotos: Vec::new(),
labels: Vec::new(),
lex: lex,
}
}
至此,完成形参的语法分析。其中涉及到可变参数、虚拟机执行等部分,下面再详细介绍。
实参的语法分析
实参的语法分析,也就是函数调用的语法分析。这个在之前章节实现prefixexp的时候已经实现过了:通过explist()
函数读取参数列表,并依次加载到栈上函数入口的后面位置。与本节开头的栈布局图一致,相当于是给形参赋值。这里解析到实际的参数个数,并写入到字节码Call
的参数中,用于在虚拟机执行阶段跟形参做比较。
但当时的实现并不完整,还不支持可变参数。本节后面再详细介绍。
虚拟机执行
上面的实参语法分析中,已经把实参加载到栈上,相当于是给形参赋值,所以虚拟机执行函数调用时,本来就无需再处理参数了。但是在Lua语言中,函数调用时实参个数可能不等于形参个数。如果实参多于形参,那无需处理,就认为多出的部分是个占据栈位置但无用的临时变量;但如果实参少于形参,那么需要对不足的部分设置为nil,否则后续字节码对这个形参的引用就会导致Lua的栈访问异常。除此之外,Call
字节码的执行就不需要对参数做其他处理。
上面语法分析时已经介绍过,形参和实参的个数,分别在FuncProto
结构中的nparam
字段和Call
字节码的关联参数中。所以函数调用的虚拟机执行代码如下:
ByteCode::Call(func, narg) => { // narg是实际传入的实参个数
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
let narg = narg as usize;
let f = f.clone();
if narg < f.nparam { // f.nparam是函数定义中的形参个数
self.fill_stack(narg, f.nparam - narg); // 填nil
}
self.execute(&f);
}
至此,完成了固定参数的部分,还是比较简单的;下面介绍可变参数部分,开始变得复杂起来。
可变参数
<在Lua中,可变形参和可变实参都用...
来表示。当在函数定义的形参列表中出现时,代表可变形参;而在其他地方出现都代表可变实参。
在上面形参的语法分析中已经提到了可变参数,其功能比较简单,代表这个函数支持可变参数。本节接下来主要介绍可变参数作为实参的处理,也就是执行函数调用时实际传入的参数。
本节最开始就介绍函数的参数就是局部变量,并画了栈的布局图。不过这个说法只适合固定的实参,而对于可变实参就不适合了。在之前的foo()
函数中加上可变参数作为示例,代码如下:
local function foo(a, b, ...)
local x, y = 1, 2
print(x, y, ...)
end
foo(1, 2, 3, 4, 5)
加上可变参数后,栈布局该变成什么样?或者说,可变实参要存在哪里?上述代码中最后一行foo()
调用时,其中1
和2
分别对应形参a
和b
,而后面的3
、4
和5
就是可变实参。在调用开始前,栈布局如下:
| |
+-----+
| foo |
+=====+ <-- base
| 1 | \
+-----+ + 固定实参,对应a和b
| 2 | /
+-----+
| 3 | \
+-----+ |
| 4 | + 可变实参,对应...
+-----+ |
| 5 | /
+-----+
| |
那进入到foo()
函数后,后面的三个实参要存在哪里?最直接的想法是保持上面的布局不变,也就是可变实参存到固定实参的后面。但是,这样是不行的!因为这样就会挤占局部变量的空间,即示例里的x
和y
就要后移,后移的距离是可变实参的个数。但是在语法分析阶段是不能确定可变实参的个数的,就无法确定局部变量在栈上的位置,就无法访问局部变量了。
Lua官方的实现是,在语法分析阶段忽略可变参数,让局部变量仍然在固定参数的后面。但是在虚拟机执行时,在进入到函数中后,把可变参数挪到函数入口的前面,并且记录可变实参的个数。这样后续在访问可变参数时,根据函数入口位置和可变实参的个数,就可以定位栈位置,即stack[self.base - 1 - 实参个数 .. self.base - 1]
。下面是栈布局图:
| |
+-----+
| 3 | -4 \
+-----+ | num_varargs: usize // 记录下可变实参的个数
| 4 | -3 + 相对于上图, +-----+
+-----+ | 把可变实参挪到函数入口前面 | 3 |
| 5 | -2 / +-----+
+-----+
| foo | <-- 函数入口
+=====+ <-- base
| a=1 | 0 \
+-----+ + 固定实参,对应a和b
| b=2 | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 / 仍然紧跟固定参数后面
既然这个方案需要在虚拟机执行时需要记录额外信息(可变实参的个数),并且还要移动栈上参数,那么更简单的做法是直接记录可变实参:
| |
+-----+
| foo | <-- 函数入口 varargs: Vec<Value> // 直接记录可变实参
+=====+ +-----+-----+-----+
| a=1 | 0 \ | 3 | 4 | 5 |
+-----+ + 固定实参,对应a和b +-----+-----+-----+
| b=2 | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 /
相比于Lua的官方实现,这个方法没有利用栈,而是使用Vec,会有额外的堆上内存分配。但是更加直观清晰。
确定下可变实参的存储方式后,就可以进行语法分析和虚拟机执行了。
ExpDesc::VarArgs和应用场景
上面讲的是函数调用时传递可变参数,接下来介绍在函数体内如何访问可变参数。
访问可变实参是一个独立的表达式,语法是也...
,在exp_limit()
函数中解析,并新增一种表达式类型ExpDesc::VarArgs
,这个类型没有关联参数。
读取这个表达式很简单,先检查当前函数是否支持可变参数(函数原型中有没有...
),然后返回ExpDesc::VarArgs
即可。具体代码如下:
fn exp_limit(&mut self, limit: i32) -> ExpDesc {
let mut desc = match self.lex.next() {
Token::Dots => {
if !self.fp.has_varargs { // 检查当前函数是否支持可变参数?
panic!("no varargs");
}
ExpDesc::VarArgs // 新增表达式类型
}
但是读到的ExpDesc::VarArgs
如何处理?这就要先梳理使用可变实参的3种场景:
-
当
...
作为函数调用的最后一个参数、return语句的最后一个参数、表构造的最后一个列表成员时,代表实际传入的全部实参。比如下面示例:print("hello: ", ...) -- 最后一个实参 local t = {1, 2, ...} -- 最后一个列表成员 return a+b, ... -- 最后一个返回值
-
当
...
作为局部变量定义语句、或赋值语句的等号=
后面最后一个表达式时,会按需求扩展或缩减个数。比如下面示例:local x, y = ... -- 取前2个实参,分别赋值给x和y t.k, t.j = a, ... -- 取前1个实参,赋值给t.j
-
其他地方都只代表实际传入的第一个实参。比如下面示例:
local x, y = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给x t.k, t.j = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给t.k if ... then -- 条件判断 t[...] = ... + f -- 表索引,和二元运算操作数 end
其中,第1个场景是最基本的,但也是实现起来最复杂的;后面两个场景属于特殊情况,实现起来相对简单。下面对这3种场景依次分析。
场景1:全部可变实参
先介绍第1种场景,即加载全部可变实参。这个场景中的3个语句如下:
-
函数调用的最后一个参数,是把当前函数的可变实参作为调用函数的可变实参,涉及两个可变实参,有点绕,不方便描述;
-
return语句的最后一个参数,但是现在还不支持返回值,要在下一节介绍;
-
表构造的最后一个列表成员。
这3个语句的实现思路类似,都是在解析表达式列表的时候,只discharge前面的表达式,而保留最后一个表达式不discharge;然后在解析完整个语句后,单独检查最后一个语句是否为ExpDesc::VarArgs
:
-
如果不是,则正常discharge。这种情况下,在语法分析时就能确定所有表达式的数量,而这个数量就可以编码进对应的字节码中。
-
如果是,则用新增的字节码
VarArgs
加载全部可变参数,而实际参数的个数在语法分析时不知道,要在虚拟机执行时才能知道,所以总的表达式的数量也不知道,也就无法编码到对应的字节码中,就需要用特殊值或新字节码来处理。
这3个语句中第3个语句表构造相对而言最简单,下面先介绍表构造语句。
之前表构造的语法分析流程是:在循环读取全部成员过程中,如果解析到数组成员,则立即discharge到栈上;在循环读取完毕后,所有数组成员依次被加载到栈上,然后生成SetList
字节码将其添加到表里。这个SetList
字节码的第2个关联参数就是成员数量。为了简单起见,这里忽略超过50个成员时分批加载的处理。
现在修改流程:为了单独处理最后一个表达式,在解析到数组成员时,要延迟discharge。具体做法比较简单但不容易描述,可以参见下面代码。代码摘自table_constructor()
函数,只保留跟本节相关内容。
// 新增这个变量,用来保存最后一个读到的数组成员
let mut last_array_entry = None;
// 循环读取全部成员
loop {
let entry = // 省略读取成员的代码
match entry {
TableEntry::Map((op, opk, key)) => // 省略字典成员部分的代码
TableEntry::Array(desc) => {
// 使用replace()函数,用新成员desc替换出上一个读到的成员
// 并discharge。而新成员,也就是当前的“最后一个成员”,被
// 存到last_array_entry中。
if let Some(last) = last_array_entry.replace(desc) {
self.discharge(sp0, last);
}
}
}
}
// 处理最后一个表达式,如果有的话
if let Some(last) = last_array_entry {
let num = if self.discharge_expand(last) {
// 可变参数。在语法分析阶段无法得知具体的参数个数,所以用0来代表栈上全部
0
} else {
// 计算出总的成员个数
(self.sp - (table + 1)) as u8
};
self.fp.byte_codes.push(ByteCode::SetList(table as u8, num));
}
上述代码整理流程比较简单,这里不一一介绍。在处理最后一个表达式时,有几个细节需要介绍:
- 新增的
discharge_expand()
方法,用以特殊处理ExpDesc::VarArgs
类型表达式。可以预见这个函数后面还会被其他两个语句(return语句和函数调用语句)用到。其代码如下:
fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
match desc {
ExpDesc::VarArgs => {
self.fp.byte_codes.push(ByteCode::VarArgs(self.sp as u8));
true
}
_ => {
self.discharge(self.sp, desc);
false
}
}
}
-
最后一个表达式如果是可变参数,那么
SetList
字节码的第2个关联参数则设置为0
。之前(不支持可变数据表达式的时候)SetList
字节码的这个参数不可能是0,因为如果没有数组成员,那不生成SetList
字节码即可,而没必要生成一个关联参数是0的SetList
。所以这里可以用0
作为特殊值。相比而言,这个场景里的其他两个语句(return语句和函数调用语句)本来就支持0个表达式,即没有返回值和没有参数,那就不能用0
作为特殊值了。到时候再想其他办法。当然这里也可以不用
0
这个特殊值,而是新增一个字节码,比如叫SetListAll
,专门用来处理这种情况。这两种做法差不多,我们还是选择使用特殊值0
。 -
虚拟机执行时,对于
SetList
第二个关联参数是0
的情况,就取栈上表后面的全部的值。也就是从表的位置一直到栈顶,都是用来初始化的表达式。具体代码如下,增加对0
的判断:
ByteCode::SetList(table, n) => {
let ivalue = self.base + table as usize + 1;
if let Value::Table(table) = self.get_stack(table).clone() {
let end = if n == 0 { // 0,可变参数,直至栈顶的全部表达式
self.stack.len()
} else {
ivalue + n as usize
};
let values = self.stack.drain(ivalue .. end);
table.borrow_mut().array.extend(values);
} else {
panic!("not table");
}
}
- 既然对于可变参数的情况,可以在虚拟机执行时根据栈顶来获取实际的表达式数量,那之前固定表达式的情况是不是也可以在执行时决定表达式数量,而不用在语法分析阶段就确定?这样一来
SetList
关联的第2个参数是不是就没用了?答案是否定的,因为栈上可能有临时变量!比如下面的代码:
t = { g1+g2 }
表达式g1+g2
的两个操作数都是全局变量,在对整个表达式求值前,要都分别加载到栈上,需要占用2个临时变量的位置。栈布局如下:
| |
+-------+
| t |
+-------+
| g1+g2 | 先把g1加载到这里。然后在求值g1+g2时,结果也加载到这里,覆盖原来的g1。
+-------+
| g2 | 在求值g1+g2时,把全局变量g2加载到这里的临时位置
+-------+
| |
此时栈顶是g2,如果也按照从表后直至栈顶的做法,那么g2也会被认为是表的一个成员。所以,对于之前的情况(固定数量的表达式)还是需要在语法分析阶段确定表达式的数量。
- 那么,为什么对于可变参数的情况就可以根据栈顶来确定表达式数量呢?这就要求虚拟机在执行加载可变参数的字节码时,清理掉临时变量。这一点非常重要。具体代码如下:
ByteCode::VarArgs(dst) => {
self.stack.truncate(self.base + dst as usize); // 清理临时变量!!!
self.stack.extend_from_slice(&varargs); // 加载可变参数
}
至此,完成了可变参数作为表构造最后一个表达式的语句的处理。相关代码并不多,但理清思路和一些细节并不简单。
场景1:全部可变实参(续)
上面介绍了第1种场景下的表构造语句,现在介绍可变参数作为函数调用的最后一个参数的情况,光听这个描述就很绕。这两个语句对可变参数的处理方法差不多,这里只介绍下不同的地方。
本节上面介绍实参的语法分析时已经说明,所有实参通过explist()
函数依次加载到栈顶,并把实参个数写入到Call
字节码中。但当时的实现并不支持可变参数。现在为了支持可变参数,就要对最后一个表达式做特殊处理。为此我们修改explist()
函数,保留并返回最后一个表达式,而只是把前面的表达式依次加载到栈上。具体代码比较简单,这里略过。复习一下,在赋值语句中,读取等号=
右边的表达式列表时,也需要保留最后一个表达式不加载。这次改造了exp_list()
函数后,在赋值语句中就也可以使用这个函数了。
改造explist()
函数后,再结合上面对表构造语句的介绍,就可以实现函数调用中的可变参数了。代码如下:
fn args(&mut self) -> ExpDesc {
let ifunc = self.sp - 1;
let narg = match self.lex.next() {
Token::ParL => { // 括号()包裹的参数列表
if self.lex.peek() != &Token::ParR {
// 读取实参列表。保留和返回最后一个表达式last_exp,而把前面的
// 表达式依次加载到栈上并返回其个数nexp。
let (nexp, last_exp) = self.explist();
self.lex.expect(Token::ParR);
if self.discharge_expand(last_exp) {
// 可变实参。生成新增的VarArgs字节码,读取全部可变实参!!
None
} else {
// 固定实参。last_exp也被加载到栈上,作为最后1个实参。
Some(nexp + 1)
}
} else { // 没有参数
self.lex.next();
Some(0)
}
}
Token::CurlyL => { // 不带括号的表构造
self.table_constructor();
Some(1)
}
Token::String(s) => { // 不带括号的字符串常量
self.discharge(ifunc+1, ExpDesc::String(s));
Some(1)
}
t => panic!("invalid args {t:?}"),
};
// 对于n个固定实参,转换为n+1;
// 对于可变实参,转换为0。
let narg_plus = if let Some(n) = narg { n + 1 } else { 0 };
ExpDesc::Call(ifunc, narg_plus)
}
跟之前介绍的表构造语句不一样的地方是,表构造语句对应的字节码是SetList
,在固定成员的情况下,其关联的用于表示数量的参数不会是0
;所以就可以用0
作为特殊值,来表示可变数量的成员。但是,对于函数调用语句,本来就支持没有实参的情况,也就是说字节码Call
关联的用户表示实参数量的参数本来就可能是0
,所以就不能简单把0
作为特殊值。那么,就有2个方案:
- 换一个特殊值,比如用
u8::MAX
,即255作为特殊值; - 仍然用
0
做特殊值,但是在固定实参的情况下,把参数加1。比如5个实参,那么就在Call
字节码中写入6;N个字节码就写入N+1;这样就可以确保固定参数的情况下,这个参数肯定是大于0的。
我感觉第1个方案稍微好一点,更清晰,不容易出错。但是Lua官方实现用的是第2个方案。我们也采用第2个方案。对应到上述代码中的两个变量:
narg: Option<usize>
表示实际的参数数量,None
表示可变参数,Some(n)
代表有n
个固定参数;narg_plus: usize
是修正后的值,用来写入到Call
字节码中。
跟之前介绍的表构造语句一样的地方是,既然用0
这个特殊值来表示可变参数,那么虚拟机执行的时候,就需要有办法知道实际参数的个数。只能通过栈顶指针和函数入口的距离来计算出实际参数的个数,那也就需要确保栈顶都是参数,而没有临时变量。对于这个要求,有两种情况:
- 实参也是可变参数,也就是最后一个实参是
VarArgs
,比如调用语句是foo(1, 2, ...)
,那么由于之前介绍过VarArgs
的虚拟机执行会确保清理临时变量,所以这个情况下就无需再次清理; - 实参是固定参数,比如调用语句是
foo(g1+g2)
,那么就需要清理可能存在的临时变量。
对应的,在虚拟机执行阶段的函数调用,也就是Call
字节码的执行,需要如下修改:
- 修正关联参数narg_plus;
- 在需要时,清理栈上可能的临时变量。
代码如下:
ByteCode::Call(func, narg_plus) => { // narg_plus是修正后的实参个数
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
// 实参数量
let narg = if narg_plus == 0 {
// 可变实参。上面介绍过,VarArgs字节码的执行会清理掉可能的
// 临时变量,所以可以用栈顶来确定实际的参数个数。
self.stack.len() - self.base
} else {
// 固定实参。需要减去1做修正。
narg_plus as usize - 1
};
if narg < f.nparam { // 填补nil,原有逻辑
self.fill_stack(narg, f.nparam - narg);
} else if f.has_varargs && narg_plus != 0 {
// 如果被调用的函数支持可变形参,并且调用是固定实参,
// 那么需要清理栈上可能的临时变量
self.stack.truncate(self.base + narg);
}
self.execute(&f);
}
至此,我们完成了可变参数的第1种场景的部分。这部分是最基本的,也是最复杂的。下面介绍另外两种场景。
场景2:前N个可变实参
现在介绍可变参数的第2种场景,需要固定个数的可变实参。这个场景中需要使用的参数个数固定,可以编入字节码中,比上个场景简单很多。
这个场景包括2条语句:局部变量定义语句和赋值语句。当可变参数作为等号=
后面最后一个表达式时,会按需求扩展或缩减个数。比如下面的示例代码:
local x, y = ... -- 取前2个实参,分别赋值给x和y
t.k, t.j = a, ... -- 取前1个实参,赋值给t.j
这两个语句的处理方式基本一样。这里只介绍第一个局部变量定义语句。
之前这个语句的处理流程是,首先把=
右边的表达式依次加载到栈上,完成对局部变量的赋值。如果当=
右边表达式的个数小于左边局部变量的个数时,则生成LoadNil
字节码对多出的局部变量进行赋值;如果不小于则无需处理。
现在需要对最后一个表达式特殊处理:如果表达式的个数小于局部变量的个数,并且最后一个表达式是可变参数...
,那么就按需读取参数;如果不是可变参数,那还是回退成原来的方法,即用LoadNil
来填补。刚才改造过的explist()
函数就又派上用场了,具体代码如下:
let want = vars.len();
// 读取表达式列表。保留和返回最后一个表达式last_exp,而把前面的
// 表达式依次加载到栈上并返回其个数nexp。
let (nexp, last_exp) = self.explist();
match (nexp + 1).cmp(&want) {
Ordering::Equal => {
// 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
// 加载到栈上即可。
self.discharge(self.sp, last_exp);
}
Ordering::Less => {
// 如果表达式少于局部变量个数,则需要尝试特殊处理最后一个表达式!!!
self.discharge_expand_want(last_exp, want - nexp);
}
Ordering::Greater => {
// 如果表达式多于局部变量个数,则调整栈顶指针;最后一个表达式
// 也就无需处理了。
self.sp -= nexp - want;
}
}
上述代码中,新增的逻辑是discharge_expand_want()
函数,用以加载want - nexp
个表达式到栈上。代码如下:
fn discharge_expand_want(&mut self, desc: ExpDesc, want: usize) {
debug_assert!(want > 1);
let code = match desc {
ExpDesc::VarArgs => {
// 可变参数表达式
ByteCode::VarArgs(self.sp as u8, want as u8)
}
_ => {
// 对于其他类型表达式,还是用之前的方法,即用LoadNil来填补
self.discharge(self.sp, desc);
ByteCode::LoadNil(self.sp as u8, want as u8 - 1)
}
};
self.fp.byte_codes.push(code);
}
这个函数跟上面第1种场景中的discharge_expand()
函数很像,但有两个区别:
-
之前是需要实际执行中所有的可变参数,但这个函数有确定的个数需求,所以多了一个参数
want
; -
之前函数需要返回是否为可变参数,以便调用者再做区别处理;但这个函数因为需求明确,不需要调用者做区别处理,所以没有返回值。
跟上面第1个场景相比,还有一个重要改变是VarArgs
字节码新增一个关联参数,用以表示需要加载具体多少个参数到栈上。因为在这种场景下,这个参数肯定不小于2,而在下一种场景下,这个参数固定是1,都没有用到0,所以可以用0作为特殊值,来表示上面第1种场景中的执行时实际所有参数。
这个字节码的虚拟机执行代码也改变如下:
ByteCode::VarArgs(dst, want) => {
self.stack.truncate(self.base + dst as usize);
let len = varargs.len(); // 实际参数个数
let want = want as usize; // 需要参数个数
if want == 0 { // 需要实际全部参数,流程不变
self.stack.extend_from_slice(&varargs);
} else if want > len {
// 需要的比实际的多,则用fill_stack()填补nil
self.stack.extend_from_slice(&varargs);
self.fill_stack(dst as usize + len, want - len);
} else {
// 需要的比实际的一样或更少
self.stack.extend_from_slice(&varargs[..want]);
}
}
至此,完成可变参数第2种场景部分。
场景3:只取第1个可变实参
前面介绍的两种场景都是在特定的语句上下文中,分别通过discharge_expand_want()
或discharge_expand()
函数,把可变参数加载到栈上。而第3种场景是除了上述特定语句上下文外的其他所有地方。所以从这个角度说,第3个场景可以算是通用场景,那么也就要用通用的加载方式。在本节介绍可变参数这个表达式之前,其他所有表达式都是通过调用discharge()
函数加载到栈上,可以看做是通用的加载方式。于是这个场景下,也要通过discharge()
函数来加载可变参数表达式。
其实上面已经遇到了这种场景。比如,在上述第2种场景中,如果=
右边的表达式个数和局部变量个数相等时,最后一个表达式就是通过discharge()
函数处理的:
let (nexp, last_exp) = self.explist();
match (nexp + 1).cmp(&want) {
Ordering::Equal => {
// 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
// 加载到栈上即可。
self.discharge(self.sp, last_exp);
}
这里discharge()
的最后一个表达式也可能是可变参数表达式...
,那么就是当前场景。
再比如,上述两个场景中都调用了explist()
函数来处理表达式列表。除了最后一个表达式外,前面的表达式都会被这个函数通过调用discharge()
来加载到栈上。如果前面的表达式里就有可变参数表达式...
,比如foo(a, ..., b)
,那么也是当前场景。
另外,上面也罗列了可变表达式在其他语句中的示例,都是属于当前场景。
既然这个场景属于通用场景,那么在语法分析阶段就不需要做什么改造,而只需要补齐discharge()
函数中对可变表达式ExpDesc::VarArgs
这个表达式的处理即可。这个处理也很简单,就是使用上面介绍的VarArgs
字节码,只加载第1个参数到栈上:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::VarArgs => ByteCode::VarArgs(dst as u8, 1), // 1表示只加载第1个参数
这就完成了第3种场景。
至此,终于介绍完可变参数的所有场景。
小结
本节开始分别介绍了形参和实参的机制。对于形参,语法分析把形参加到局部变量表中,作为局部变量使用。对于实参,调用者把参数加载到栈上,相当于给参数赋值。
后面大部分篇幅介绍了可变参数的处理,包括3种场景:实际全部实参,固定个数实参,和通用场景下第1个实参。